fix(mesh-phase5): harden telemetry client onError contract (validate/isolate/defend)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 16:12:02 -04:00
parent f131c1cca5
commit a279a43ed4
4 changed files with 258 additions and 23 deletions
@@ -1,3 +1,4 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
@@ -18,10 +19,18 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
/// generated property is <see langword="null"/>, so the nullable domain fields map via
/// <c>?.ToDateTime()</c> and an absent Timestamp becomes <see langword="null"/>. The
/// non-nullable domain fields (published / sampled / transition timestamps) are always set by
/// the node mapper, so they map via a direct <c>.ToDateTime()</c> (which yields a
/// the node mapper, so they map via <see cref="Required"/> (which yields a
/// <see cref="DateTimeKind.Utc"/> value).
/// </para>
/// <para>
/// <b>A required Timestamp is defended, not assumed.</b> Across a mixed-version fleet an
/// out-of-contract sender could leave a required Timestamp unset. <see cref="Required"/> throws
/// a clear <see cref="InvalidOperationException"/> naming the kind + field instead of letting a
/// bare <see cref="NullReferenceException"/> escape — the client's per-event isolation then logs
/// it as a dropped malformed event and continues, because it is a code/version fault, never a
/// transport fault.
/// </para>
/// <para>
/// <b>optional-string presence is honoured.</b> A proto3 <c>optional string</c> that the node
/// left unset reads back as <see langword="null"/> (via the generated <c>Has…</c> presence),
/// distinguishing null from the empty string; likewise the <c>optional bool</c>
@@ -71,7 +80,7 @@ public static class TelemetryProtoMapCentral
Severity: msg.Severity,
Message: msg.Message,
User: msg.User,
TimestampUtc: msg.TimestampUtc.ToDateTime(),
TimestampUtc: Required(msg.TimestampUtc, "AlarmTransition", "timestamp_utc"),
AlarmTypeName: msg.AlarmTypeName,
Comment: msg.HasComment ? msg.Comment : null,
HistorizeToAveva: msg.HasHistorizeToAveva ? msg.HistorizeToAveva : null,
@@ -89,7 +98,7 @@ public static class TelemetryProtoMapCentral
ScriptId: msg.ScriptId,
Level: msg.Level,
Message: msg.Message,
TimestampUtc: msg.TimestampUtc.ToDateTime(),
TimestampUtc: Required(msg.TimestampUtc, "ScriptLog", "timestamp_utc"),
VirtualTagId: msg.HasVirtualTagId ? msg.VirtualTagId : null,
AlarmId: msg.HasAlarmId ? msg.AlarmId : null,
EquipmentId: msg.HasEquipmentId ? msg.EquipmentId : null);
@@ -109,7 +118,7 @@ public static class TelemetryProtoMapCentral
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
LastError: msg.HasLastError ? msg.LastError : null,
ErrorCount5Min: msg.ErrorCount5Min,
PublishedUtc: msg.PublishedUtc.ToDateTime());
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"));
}
/// <summary>Projects a <see cref="DriverResilienceStatus"/> onto a <see cref="DriverResilienceStatusChanged"/>.</summary>
@@ -126,7 +135,23 @@ public static class TelemetryProtoMapCentral
ConsecutiveFailures: msg.ConsecutiveFailures,
CurrentInFlight: msg.CurrentInFlight,
LastBreakerOpenUtc: msg.LastBreakerOpenUtc?.ToDateTime(),
LastSampledUtc: msg.LastSampledUtc.ToDateTime(),
PublishedUtc: msg.PublishedUtc.ToDateTime());
LastSampledUtc: Required(msg.LastSampledUtc, "DriverResilienceStatus", "last_sampled_utc"),
PublishedUtc: Required(msg.PublishedUtc, "DriverResilienceStatus", "published_utc"));
}
/// <summary>
/// Converts a <b>required</b> proto <see cref="Timestamp"/> to a UTC <see cref="DateTime"/>,
/// throwing a clear <see cref="InvalidOperationException"/> naming the telemetry kind + proto
/// field if an out-of-contract sender left it unset — rather than letting a bare
/// <see cref="NullReferenceException"/> escape.
/// </summary>
/// <param name="ts">The required proto Timestamp (<see langword="null"/> when the sender omitted it).</param>
/// <param name="kind">The telemetry sub-message name, for the diagnostic.</param>
/// <param name="field">The proto field name, for the diagnostic.</param>
/// <returns>The Timestamp as a <see cref="DateTimeKind.Utc"/> value.</returns>
/// <exception cref="InvalidOperationException"><paramref name="ts"/> is unset.</exception>
private static DateTime Required(Timestamp? ts, string kind, string field) =>
ts is null
? throw new InvalidOperationException($"telemetry {kind} missing required timestamp {field}")
: ts.ToDateTime();
}