Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs
T
Joseph Doherty 39db75083d feat(drivers): every driver reports the authored tags it skipped (Gitea #541)
A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.

New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.

The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.

Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.

Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.

Three details that are load-bearing rather than incidental:

- The skip digest is part of the publish fingerprint. A skipped tag leaves
  every other fingerprint component identical to a clean driver's, so without
  it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
  signal -- the trap that already bit the rediscovery and host-status
  channels. The digest covers the sample, not just the counts: a redeploy
  that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
  it is showing fewer than it counted. A truncated list that looked complete
  would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
  previous report standing. A partial snapshot would read as "we now skip
  fewer tags", the opposite of the truth.

The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.

Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
2026-08-05 19:28:46 -04:00

212 lines
11 KiB
C#

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;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which would collide
// with the proto DriverHealth this file maps.
using HostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus;
using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState;
// Same reason: the proto declares SkippedTag / SkippedTags alongside the domain records of near-identical
// name, so the domain side is named explicitly and the proto side reached through the wholesale import.
using SkippedTag = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTag;
using SkippedTagReport = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
/// <summary>
/// Central-side projection of a wire <see cref="TelemetryEvent"/> envelope back onto its domain
/// record (per-cluster mesh Phase 5). The exact reverse of <c>TelemetryProtoMapNode</c>: it
/// transcribes every proto field onto the matching domain field and selects the domain record from
/// the envelope's <c>oneof</c> arm.
/// </summary>
/// <remarks>
/// <para>
/// <b>Timestamp presence is honoured.</b> A proto <c>google.protobuf.Timestamp</c> field is a
/// message: when the node left it unset (a null nullable-<c>DateTime</c> on the domain side) the
/// 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 <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>
/// <c>historize_to_aveva</c> round-trips its three states (null / true / false).
/// </para>
/// </remarks>
public static class TelemetryProtoMapCentral
{
/// <summary>
/// Projects a wire <see cref="TelemetryEvent"/> onto its domain record, selecting the type from
/// the populated <c>oneof</c> arm. This switch is the coverage guard: every
/// <see cref="TelemetryProtoContract.HandledCases"/> value has an arm here, and an unset (or a
/// future unmapped) case throws <see cref="NotSupportedException"/> so adding a fifth oneof case
/// without a converter fails the coverage test.
/// </summary>
/// <param name="evt">The wire envelope to project.</param>
/// <returns>The domain record for the populated arm.</returns>
/// <exception cref="ArgumentNullException"><paramref name="evt"/> is null.</exception>
/// <exception cref="NotSupportedException">The envelope carries no handled <c>oneof</c> arm.</exception>
public static object MapEvent(TelemetryEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
return evt.EventCase switch
{
TelemetryEvent.EventOneofCase.AlarmTransition => ToAlarm(evt.AlarmTransition),
TelemetryEvent.EventOneofCase.ScriptLog => ToScript(evt.ScriptLog),
TelemetryEvent.EventOneofCase.DriverHealth => ToHealth(evt.DriverHealth),
TelemetryEvent.EventOneofCase.DriverResilience => ToResilience(evt.DriverResilience),
_ => throw new NotSupportedException(
$"TelemetryEvent carries no handled oneof arm (EventCase = {evt.EventCase})."),
};
}
/// <summary>Projects an <see cref="AlarmTransition"/> onto an <see cref="AlarmTransitionEvent"/>.</summary>
/// <param name="msg">The proto sub-message.</param>
/// <returns>The domain record.</returns>
public static AlarmTransitionEvent ToAlarm(AlarmTransition msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new AlarmTransitionEvent(
AlarmId: msg.AlarmId,
EquipmentPath: msg.EquipmentPath,
AlarmName: msg.AlarmName,
TransitionKind: msg.TransitionKind,
Severity: msg.Severity,
Message: msg.Message,
User: msg.User,
TimestampUtc: Required(msg.TimestampUtc, "AlarmTransition", "timestamp_utc"),
AlarmTypeName: msg.AlarmTypeName,
Comment: msg.HasComment ? msg.Comment : null,
HistorizeToAveva: msg.HasHistorizeToAveva ? msg.HistorizeToAveva : null,
ReferencingEquipmentPaths: msg.ReferencingEquipmentPaths.ToList());
}
/// <summary>Projects a <see cref="ScriptLog"/> onto a <see cref="ScriptLogEntry"/>.</summary>
/// <param name="msg">The proto sub-message.</param>
/// <returns>The domain record.</returns>
public static ScriptLogEntry ToScript(ScriptLog msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new ScriptLogEntry(
ScriptId: msg.ScriptId,
Level: msg.Level,
Message: msg.Message,
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);
}
/// <summary>Projects a <see cref="DriverHealth"/> onto a <see cref="DriverHealthChanged"/>.</summary>
/// <param name="msg">The proto sub-message.</param>
/// <returns>The domain record.</returns>
public static DriverHealthChanged ToHealth(DriverHealth msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new DriverHealthChanged(
ClusterId: msg.ClusterId,
DriverInstanceId: msg.DriverInstanceId,
State: msg.State,
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
LastError: msg.HasLastError ? msg.LastError : null,
ErrorCount5Min: msg.ErrorCount5Min,
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"),
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
HostStatuses: ToHostStatuses(msg),
SkippedTags: ToSkippedTags(msg));
}
/// <summary>
/// Projects the skipped-tag report, honouring the field's own presence: null when the node said
/// the driver cannot report skips, a report when it can — including an all-zero one, which is the
/// positive statement "this driver skipped nothing" and must not be flattened back to null.
/// </summary>
private static SkippedTagReport? ToSkippedTags(DriverHealth msg)
{
if (msg.SkippedTags is not { } s) return null;
var sample = new List<SkippedTag>(s.Sample.Count);
foreach (var entry in s.Sample)
{
sample.Add(new SkippedTag(entry.RawPath, entry.Reason));
}
return new SkippedTagReport(s.AuthoredCount, s.SkippedCount, sample);
}
/// <summary>
/// Projects the repeated host-connectivity field, honouring the explicit presence flag: null when
/// the node said the driver has no probe, an empty list when it has one that knows no hosts.
/// <para>An unparseable state string degrades to <see cref="HostState.Unknown"/> rather than
/// throwing — a node running a newer build that added an enum member must not be able to kill
/// central's telemetry stream, which is observability and has no business failing closed.</para>
/// </summary>
private static IReadOnlyList<HostConnectivityStatus>? ToHostStatuses(DriverHealth msg)
{
if (!msg.HasHostStatuses) return null;
var result = new List<HostConnectivityStatus>(msg.HostStatuses.Count);
foreach (var h in msg.HostStatuses)
{
result.Add(new HostConnectivityStatus(
h.HostName,
// System.Enum qualified: Google.Protobuf.WellKnownTypes also declares an Enum type.
System.Enum.TryParse<HostState>(h.State, ignoreCase: true, out var state) ? state : HostState.Unknown,
h.LastChangedUtc?.ToDateTime() ?? default));
}
return result;
}
/// <summary>Projects a <see cref="DriverResilienceStatus"/> onto a <see cref="DriverResilienceStatusChanged"/>.</summary>
/// <param name="msg">The proto sub-message.</param>
/// <returns>The domain record.</returns>
public static DriverResilienceStatusChanged ToResilience(DriverResilienceStatus msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new DriverResilienceStatusChanged(
DriverInstanceId: msg.DriverInstanceId,
HostName: msg.HostName,
BreakerOpen: msg.BreakerOpen,
ConsecutiveFailures: msg.ConsecutiveFailures,
CurrentInFlight: msg.CurrentInFlight,
LastBreakerOpenUtc: msg.LastBreakerOpenUtc?.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();
}