Unified metric ingestion contract across agent/sysmon/snmp, native add-ons, and wasm plugins (OCSF-correct) #3788

Open
opened 2026-06-13 19:41:52 +00:00 by mfreeman451 · 5 comments
Owner

TL;DR

A single CPU-utilization metric reaches the anomaly engine through three structurally disjoint pipelines (Go agent sysmon, WASM/native plugin via plugin_result, native add-on via OTLP relay) that converge only on shared gRPC transport (AgentGatewayService.StreamStatus with opaque JSON in GatewayServiceStatus.Message, disambiguated by an agent-set Source string) and shared IngressId/Sr-Ingress-* attribution headers — never on schema, subject, point-identity, or storage. Three series-identity algorithms and three DB sinks (cpu_metrics, timeseries_metrics, otel_metric_points) mean the same physical core produces three uncorrelatable anomaly series, and only the OTLP path is analyzed by default.

The core defects, all verified against code:

  1. Metrics are smuggled inside status/check-result envelopes because producers have no first-class metric emit path. The agent-gateway then does a parse-forward-reparse-republish dance — the same metric JSON is decoded up to three times (gateway publisher → core StatusHandler after forward → core Metrics processor off NATS).
  2. Metric-shape fragmentation is the real disease, not OCSF: 4 wire schemas, 3 sinks, 3 non-alignable series-identity recipes, no shared point identity.
  3. No kind / temporality anywhere in the producer SDKs, and the anomaly consumer discards kind/temporality/unit even when the OTLP path supplies them — a cumulative counter fed raw into z-sigma looks like a ramp.

OCSF is NOT mis-used. ServiceRadar correctly keeps metric time-series out of OCSF classes; the separation is deliberate and documented (elixir/serviceradar_core/lib/serviceradar/event_writer/ocsf.ex:16-20). The fix is metric-shape unification, which is orthogonal to OCSF and must not be solved by routing into it.

The fix is additive, not a rewrite. PR #3787 already shipped serviceradar.metric.v1 as a thin bridge and is the de-facto reference implementation. Evolve it to an OTLP-grade contract (schema_version 1→2, add resource/kind/temporality/points[]), give all producers a first-class metric emit path, lower the legacy sysmon/snmp/shadow special-cases onto it, and delete the migration-era debt.


Architectural principle

Metrics are a first-class signal. Producers (WASM plugins, native add-ons, and the agent sysmon/snmp/icmp/mtr/sweep/rperf collectors) emit one serviceradar.metric.v1 shape directly onto a metric path — never embedded inside a status / check-result / plugin-result envelope. The gateway stops re-extracting metrics from statuses, and the per-source (sysmon/snmp/plugin) special-cases collapse into one emit + one ingest + one sink.


Current state — four disjoint pipelines (CPU metric traced end-to-end)

Go agent sysmon WASM/native plugin native add-on OTLP relay
producer shape MetricSample.cpus[]{core_id,usage_percent} (go/pkg/sysmon/metrics.go:25-180) Metric{name,value,unit,warn,crit,min,max} (serviceradar-sdk-go/sdk/result.go:52-60; Rust src/result.rs:138-184) OTLP NumberDataPoint in ExportMetricsServiceRequest
wrap / source JSON in GatewayServiceStatus.Message, Source="sysmon-metrics" (push_loop_status.go:200-233) embedded in serviceradar.plugin_result.v1 over env.submit_result, Source="plugin-result" opaque OTLP, Source="otlp-relay"
gateway publisher SysmonMetricsPublisherserviceradar.sysmon.metrics.v1metrics.sysmon.<family> PluginMetricsPublisherserviceradar.metric.v1metrics.timeseries.<type>.<name> OtlpRelayPublisher → verbatim → otel.metrics.raw
stream metrics metrics events (separate)
core processor → sink MetricsSysmonMetricsIngestorcpu_metrics per-family hypertable MetricsTelemetrytimeseries_metrics OtelMetrics (protobuf) → otel_metric_points
anomaly series id sysmon:cpu:<host>:<core_id> cpu:<TimeseriesSeriesKey md5> otel:<svc>:<name>:<attributes_hash>
kind/temporality/unit none none (unit stored, then dropped) full — then discarded by consumer

The gateway is not a normalizer — it is four source-keyed pass-through publishers selected by the agent-supplied Source string (status_processor.ex:57-97,328-431).

Divergences (why the same core yields three uncorrelatable series)

  • 3 series-identity algorithms, none alignable: sysmon string-built (sample_extractor.ex ~:123), plugin TimeseriesSeriesKey md5 (timeseries_series_key.ex:6-35), OTLP attributes_hash recipe-v2 (otel_metric_point.ex:19-198).
  • 3 DB sinks / schemas with no unifying view or join (repo-wide search found no UNION/view merging timeseries_metrics and otel_metric_points).
  • 3 wire schemas / encodings: serviceradar.sysmon.metrics.v1 (whole-sample blob), serviceradar.metric.v1 (per-scalar JSON), raw OTLP protobuf.
  • Coverage asymmetry: anomaly enabled_subjects defaults to ['otel.metrics.>'] only (anomaly_detection/config.ex:18), so only the native-addon OTLP CPU metric is analyzed out of the box; sysmon and plugin CPU are subscribed but gated off until ANOMALY_ANALYSIS_ENABLED_SUBJECTS is widened.
  • Per-device identity collapse on the plugin path only: serviceradar.metric.v1 never sets device_id/target_device_ip/if_index and per-metric labels are not promoted to tags, so many devices under one agent collide into one series (telemetry.ex:112-137).
  • Timestamp semantics differ: OTLP has true per-point time_unix_nano; sysmon shares one sample-level timestamp across all cores; plugin shares the Result envelope observed_at across all metrics.
  • Consumer destroys fidelity uniformly: SampleExtractor → ContextOwner → CausalReasoner reduces every path to {value, observed_at_unix_nano} (sample_extractor.ex, context_owner.ex:349-363). kind/temporality/unit/is_monotonic never reach the reasoner even when otel_metric_points has them.

The double/triple-decode (the "re-publish" smell)

status_processor.ex forward_then_publish/1 (:78-97) does two passes over the same payload: (1) forward(status) ships the whole status to core for status/state processing, then (2) re-parses status.message and republishes the extracted metrics onto JetStream via three source-specific publishers. Evidence of the same metric JSON decoded three times:

  • sysmon_metrics_publisher.ex:42-51 / snmp_metrics_publisher.ex:44-53 / plugin_metrics_publisher.ex:44-53 — each Jason.decode(status.message) (decode #2).
  • metrics.ex:46-72 — core re-decodes the republished NATS message (decode #3).

It is not a duplicate DB write today: the direct-insert sink is flag-gated off (plugin_result_ingestor.ex:6-10,:52,:200-206, :plugin_result_direct_metrics_enabled default false), and the core gRPC-direct-to-DB metric handlers are already no-op stubs (results_router.ex:239-245,:611acknowledge_metrics_cutover/1 → :ok). So half the migration is already done — it just hasn't been cleaned up. The remaining smell is purely the smuggle-and-re-extract architecture, which the first-class metric path eliminates.


OCSF finding — NOT a category error (keep the separation, add guardrails)

This was investigated directly and the "metrics are mis-bucketed into OCSF" claim was REFUTED across producers, transport, consumers, and schema:

  • Policy is documented in code: ocsf.ex:16-20"OTel traces/metrics: Keep native format (observability, not security)" and "Telemetry metrics: Keep native format (time-series data)".
  • ocsf_events (migration 20260203120000_create_ocsf_events.exs:14-45) is a pure security-event shape (class_uid/category_uid/activity_id/severity_id/observables/actor/device/endpoints) with zero metric value/unit/temporality/metric_name columns — it physically cannot hold a series.
  • Both WASM SDKs only have ocsf_event/otel_log payload kinds (serviceradar-sdk-go/sdk/signal_schema.go:17-18, serviceradar-sdk-rust/src/result.rs:356-357) — there is no metric signal type, so a metric cannot be serialized as an OCSF event.
  • The only numbers legitimately inside OCSF are (a) NetFlow byte/packet counters as traffic{} in ocsf_network_activity class 4001 (event-shaped, OCSF-sanctioned), and (b) anomaly/capacity Findings (class 2004) carrying a single anomalous value + thresholds as event context (verdict_emitter.ex:34-74,:96-118) — derived from metrics, not duplicating the series. Both are correct and stay.

Recommendations:

  • Do NOT add a "metric" OCSF class. OCSF has no metric/time-series primitive; inventing one would be the actual category error.
  • Keep using OCSF for events derived from metrics (threshold breaches → Findings) — the code already does this correctly.
  • Add mis-bucket guardrails with the unified contract: the metrics processor rejects any serviceradar.metric.v1 payload carrying class_uid; the OCSF processor rejects payloads carrying kind/temporality/points[]. A misrouted message then errors loudly instead of landing in the wrong hypertable.

Confirmed gaps (verified against code)

  1. No metric kind (gauge/counter/histogram) in any producer SDK or in three of four gateway envelopes — result.go:52-60, result.rs:138-184. Downstream metric_type is a routing label, not an OTel kind.
  2. No temporality (delta/cumulative) in SDKs or the serviceradar.metric.v1 / serviceradar.sysmon.metrics.v1 envelopes (plugin_metrics_publisher.ex:93-111). SNMP carries only is_delta. Result: monotonic counters look like ramps to z-sigma.
  3. No first-class metric emit helper in either WASM SDKtelemetry.go has only NewOCSFTelemetryRecord(:36) and NewOTELLogTelemetryRecord(:53); telemetry.rs only ocsf_event(:60)/otel_log(:76). Metrics can only ride embedded in submit_result; there is no metric payload kind.
  4. No per-metric tags / resource identity / point identity on the plugin_result Metric shape — labels are envelope-level (result.go:37, result.rs:385); per-metric dimensions are lost (plugin_metrics_publisher.ex:113-191).
  5. Per-device identity collapse on the plugin pathserviceradar.metric.v1 never sets device_id/target_device_ip/if_index, so many devices under one agent collapse into one series.
  6. metrics.timeseries.* and otel.metrics.raw decode into different sinks (timeseries_metrics vs otel_metric_points) with no canonical merge.
  7. Anomaly consumer drops kind/temporality/unit at extraction even when OTLP supplies them (sample_extractor.ex, context_owner.ex:349-363).
  8. Coverage losses inside the (correct) metric namespace: OTLP exponential histograms & summaries are counted-then-dropped (otel_metrics.ex ~:258-281); the SNMP gateway publisher drops every OID except ifHCInOctets/ifHCOutOctets (snmp_metrics_publisher.ex); sysmon Network[] interface metrics are collected but dropped at the gateway (only cpu/memory/disk/process families emitted).
  9. No Nats-Msg-Id / duplicate_window on the metrics stream (config.ex:206-219). With consumer_max_deliver=5 and the planned multi-point fan-out, at-least-once delivery would double-count; idempotency must be enforced at the stream and/or ingestor.
  10. No schema/proto validation of serviceradar.metric.v1 — the producer hand-builds the map and the consumer tolerantly defaults missing fields (metric_type→'gauge'); malformed envelopes become gauge/unknown rows instead of being rejected. Non-numeric metrics are silently dropped (encode_metric returns {:ok, nil} with no counter — invisible data loss).

Producer refactor work-list (every producer smuggling metrics through status)

Swept all 12 native add-ons, both WASM SDKs, and in-tree first-party checkers (22 producers classified).

SMUGGLES_METRICS — first-party in-tree checkers (the real refactor targets)

producer metrics source string evidence
sysmon cpu/disk/memory/network-iface/process sysmon-metrics go/pkg/sysmon/metrics.go:25-180, push_loop_status.go:200-233
snmp OID counters/gauges w/ if_index,interface_uid snmp-metrics push_loop_snmp.go:30-42,48-57,63
icmp response_time_ns, packet_loss results push_loop_icmp_results.go:29-68, icmp_checker.go:69-77
mtr per-hop RTT/loss/jitter results mtr_checker.go:61-70,86-127, proto/monitoring.proto:1012-1046
network sweep scanner perf counters (SweepScannerStats) results proto/monitoring.proto:149-200, push_loop_sweep_results.go:28-118
mapper/discovery per-interface metric descriptors (InterfaceMetric) — catalog, not samples discovery status proto/discovery/discovery.proto:188-214, push_loop_mapper_netprobe.go:36-93
rperf (Rust checker) throughput/loss/jitter (TestSummary) plugin-result (generic path) proto/rperf/rperf.proto:60-72, rust/rperf-client/src/server.rs:259-300

Note: mapper/discovery carries metric definitions (the catalog the snmp checker samples), not time-series — decide whether the unified contract carries metric-descriptors on a separate channel.

SDK smuggling helpers (add first-class emit here, then deprecate)

  • Go: Result.Metrics slot (serviceradar-sdk-go/sdk/result.go:36), Metric struct (:52-60), AddMetric/WithMetric builders (:257-273).
  • Rust: Metric struct (serviceradar-sdk-rust/src/result.rs:138-151), add_metric_spec/with_metric_spec (:578-585), add_metric/with_metric (:587-612).
  • 6 SDK examples (http-check, udp-check, tcp-check, widgets-check ×Go; http-check, widgets-check ×Rust) and 2 golden fixtures (testdata/service_monitoring_result.json ×2) demonstrate the pattern and must update in lockstep.

OTLP_NATIVE (already first-class)

  • otel-addon + rust/otel library relay customer OTLP metrics via the acked RelayOtlp stream (otlp-relay:v1): rust/otel-addon/src/addon.rs:27,556, rust/otel/src/lib.rs:529,754, addon.proto:159-165 (OTLP_METRICS=5, OTLP_DERIVED_METRIC=6). Decision needed: normalize OTLP_METRICS frames into serviceradar.metric.v1 downstream, or keep otel.metrics.raw as a high-fidelity express lane.

NO_METRICS (no refactor) — explicitly verified to avoid false positives

bumblebee-scan, endpoint-inventory, scalibr-endpoint-inventory, advisory-producer, sample-addon, powerdns, rdp-adapter, workload-identity, bmp-collector, log-collector, trapd, flowgger, rust-sample-addon, rperf-server. Correction to parallel sub-agents: netprobe, flow-collector, and otel-addon's StreamTelemetry expose only operational Prometheus/OCSF-usage self-metrics, NOT smuggled observability time-series — they are NOT refactor targets.


Tech-debt to delete / collapse (the unification makes the special-cases deletable)

DELETE (pure migration-era scaffolding)

  • @legacy_sysmon_schema "serviceradar.sysmon.shadow.v1" acceptance — metrics.ex:19,:151.
  • AGENT_GATEWAY_SYSMON/SNMP_METRICS_SHADOW_ENABLED env defaults (runtime.exs:314,318, default "true") + helm *MetricsShadowEnabled aliases (agent-gateway.yaml:30,36).
  • Flag-gated legacy direct insert maybe_insert_metrics + :plugin_result_direct_metrics_enabled (plugin_result_ingestor.ex:52,:200-206; core/config.exs:131-133). Keep build_status_row/insert_status — only the metric insert is debt.
  • Vestigial no-op router handlers handle_sysmon_metrics/handle_snmp_metrics/acknowledge_metrics_cutover (results_router.ex:239-245,:611) and their dispatch entries (status_handler.ex:109-111).

COLLAPSE (replace source-specific handling with the unified path)

  • SysmonMetricsPublisher (sysmon_metrics_publisher.ex whole module) and SnmpMetricsPublisher (snmp_metrics_publisher.ex whole module) → fold into a generalization of PluginMetricsPublisher (which already emits serviceradar.metric.v1). These are the publish boundary (agents are NATS-denied), so they must be replaced, not merely deleted.
  • Sysmon family-envelope carve-out in metrics.ex:18,50,59-181 (parse_sysmon/filter_sysmon_sample/partition_messages/ingest_sysmon_messages/metric_kind) → the generic Telemetry/timeseries path.
  • SysmonMetricsIngestor (sysmon_metrics_ingestor.ex) 5-hypertable fan-out → unified ingest.
  • Gateway double-pass forward_then_publish + publish_sysmon/snmp/plugin_metrics + *_source? guards (status_processor.ex:78-97,313-427); per-source max_message_bytes/strict_message_size_source? (agent_gateway_server.ex:665-680) — preserve the large sysmon size budget on the unified path.
  • Anomaly per-source carve-outs: sample_extractor.ex:67-71,114-189, series_config.ex:201-214, config.ex:108-109 (ANALYSIS_METRICS_SYSMON/SNMP).
  • Per-source subject prefixes / NATS ACLs (config.exs:30,35, runtime.exs:346,351, agent-gateway.yaml:127,131, nats.yaml:211, values-demo.yaml:488, values.yaml:946,948) — re-bind stream subjects + durable consumer filters atomically during cutover.

MIGRATE (producer code switches to first-class emit)

  • Go agent sysmon producer (push_loop_status.go:118-233, sysmon_service.go:604-612, go/pkg/sysmon/{metrics,collector}.go) — flatten cpus/clusters/disks/processes into per-series scalar points.
  • Go agent SNMP producer (push_loop_snmp.go:66,77,101,140) — keep if_index/target_device_ip identity.
  • (+ icmp/mtr/sweep/rperf per the producer work-list above.)

KEEP — do NOT delete (sysmon-named but config/UI, not metric debt)

  • sysmon_profiles table + migrations (20260519162000_*, 20260123093000_*), sysmon_compiler.ex, settings UI (sysmon_profiles_live/index.ex) — configuration.
  • Device-detail sysmon charts UI (device_tab_runtime.ex) and SRQL catalog — repoint to the unified table, do not delete.
  • go/pkg/sysmon/collector.go + sysmon_service.go — the collector itself (migrate the emit shape, not the collector).
  • The five sysmon metric hypertables (cpu_metrics/cpu_cluster_metrics/memory_metrics/disk_metrics/process_metrics) + _hourly caggs — unification candidates, but they back live UI charts and anomaly baselines: data-migrate before any DROP.

Tests that lock in the per-source schemas/subjects (will need rewriting)

metrics_test.exs (legacy shadow + sysmon/snmp fixtures), sysmon_metrics_publisher_test.exs, snmp_metrics_publisher_test.exs, status_processor_test.exs:428,456, sysmon_metrics_ingestor_test.exs, snmp_metrics_ingestor_integration_test.exs, results_router_test.exs:431,445, sample_extractor_test.exs, pipeline_test.exs, config_test.exs:40-88, series_config_test.exs/synthetic_dataset_test.exs/verdict_emitter_test.exs/baseline_seeder_test.exs/stateful_alert_engine_test.exs, jetstream_consumer_test.exs, pipeline_ack_test.exs/pipeline_batch_span_test.exs.


Proposed unified contract: serviceradar.metric.v1 (evolve additively from the PR #3787 bridge)

Reuse the existing schema id; bump schema_version 1→2 and add the OTLP-grade fields below. Version-1 flat envelopes remain valid (treated as a single gauge point: value→points[0].value, metric_type→kind, tags→points[0].attributes, observed_at→points[0].time_unix_nano). Consumers MUST reject envelopes whose schema is absent/unknown rather than coercing to gauge.

field type purpose OTLP equiv
schema const "serviceradar.metric.v1" route + version-gate Schema URL analog
schema_version uint32 (default 1) additive evolution
resource {service_name(req), service_instance_id, scope_name, scope_version, attributes:map} resource identity carried once per batch/point — the field the SDK Metric and PR3787 envelope both lack. sysmon: service_name='sysmon', attrs carry host_id/core_id/mount_point/pid; snmp: target_device_ip/if_index/interface_uid Resource + InstrumentationScope
name string (req) metric stream name (dotted semconv), no longer the sole identity Metric.name
kind enum {gauge, sum, histogram, exp_histogram, summary} the field every producer is missing; lets the engine rate-compute counters Metric.data oneof
temporality enum {unspecified, delta, cumulative} (req for sum/histogram) counter-rate computability; snmp is_delta→delta aggregation_temporality
is_monotonic bool (kind=sum) counter vs UpDownCounter Sum.is_monotonic
unit string (UCUM: %,By,1,ms,Hz) stops n_sigma mixing percent/bytes/counts Metric.unit
points array (req, ≥1) one OR MANY points per emit so cores/mounts/interfaces/guests dimension without splitting envelopes (fixes per-device collapse) data_points (repeated)
points[].time_unix_nano uint64 (req) per-point measurement time DataPoint.time_unix_nano
points[].start_time_unix_nano uint64 (req for cumulative rate) counter reset/window anchor DataPoint.start_time_unix_nano
points[].attributes map<string,scalar> per-point dims (core_id,mount_point,if_index,guest_id); volatile keys excluded from identity DataPoint.attributes
points[].value double|int (gauge/sum) int vs double preserved NumberDataPoint.as_double/as_int
points[].histogram {count,sum,bucket_counts[],explicit_bounds[],min,max} (histogram) first-class histogram (none today); enables p50/p95 series currently dropped HistogramDataPoint
points[].exemplars [{time_unix_nano,value,trace_id,span_id,filtered_attributes}] (opt) metric→trace pivots the causal engine wants DataPoint.exemplars
thresholds {warn,crit,min,max} (opt) preserves Nagios perfdata semantics without conflating with metric data; alerting/display only — (deliberately outside OTLP)
ingress_id UUIDv8 (gateway-stamped) per-message ordering/idempotency; also set as Nats-Msg-Id
ingress_timestamp_unix_nano uint64 (gateway-stamped) ingest/transport time, distinct from measurement time
ingest_identity {ingest_agent_id, ingest_partition, ingest_identity:'agent:<id>'} (gateway-attested) mTLS-bound attested identity, separate from producer-claimed resource.attributes (untrusted) attested overlay

Per-SDK emit APIs (new third signal stream: signal_type:'metric' / payload_kind:'otel_metric')

  • go-wasm: sdk.NewGauge/NewCounter/NewHistogram(name, value, ...MetricOption) with WithUnit/WithAttributes/WithTimestamp/WithResource/WithThresholds/WithExemplar; collected into sdk.NewMetricBatch(resource); add NewMetricTelemetryRecord(batch) so metrics ride env.emit_telemetry. Keep Result.AddMetric as a shim lowering to a gauge point.
  • rust-wasm: MetricPoint::gauge/counter/histogram(...) + MetricBatch::new(ResourceRef::service(...)) + TelemetryRecord::otel_metric(batch). Keep Metric/add_metric_spec as a perfdata shim.
  • native add-on (rust/addon-sdk): MetricBatchBuilder::new(resource).gauge/counter/histogram(...) + Addon::emit_metrics(batch) wrapping a new TelemetryPayloadKind::SR_METRIC_V1 (alongside OTLP_METRICS=5, OTLP_DERIVED_METRIC=6). OTLP-native authors keep relay_otlp() unchanged.
  • agent sysmon/snmp/icmp/mtr/sweep: internal go/pkg/metricpoint builder mirroring the SDK so the gateway publishers emit serviceradar.metric.v1 multi-point envelopes (one Metric{kind,unit,points[]} per family), replacing the raw-sample blob and the 2-OID hardcode; emit the currently-dropped sysmon Network[] and SNMP non-octet OIDs.
  • shared: ResourceRef.service(name).instance(id).scope(name,ver).attr(k,v) so all producers construct the resource tuple once.

Subject topology

One canonical tree metrics.v1.<kind>.<domain>.<name-token> on the existing high-rate metrics stream (widen the binding to also own metrics.v1.>). Leading token = the real kind field (no longer regex-guessed). Legacy subjects (metrics.sysmon.*, metrics.snmp.interface.*, metrics.timeseries.<type>.<name>) stay bound during migration. The addon raw-OTLP path is NOT moved by alias — a decode-and-republish shim lowers ExportMetricsServiceRequest into metrics.v1.* for a single consumer shape (or keep otel.metrics.raw as the express lane — see open questions). Critical hardening before fan-out: gateway sets Nats-Msg-Id=ingress_id on every publish AND the metrics stream gains a duplicate_window. Add metrics.v1.> to ANOMALY_ANALYSIS_ENABLED_SUBJECTS.


Relationship to PR #3787

PR #3787 is a correct, deliberately-thin bridge that should be evolved, not replaced. It ships serviceradar.metric.v1 as a flat {schema, metric_name, metric_type, value, unit, tags, metadata} envelope on metrics.timeseries.<type>.<name> → Telemetry → timeseries_metrics, wires the anomaly consumer (ANALYSIS_METRICS_TIMESERIES), and cleanly gates off the legacy direct-DB insert. It is the de-facto reference implementation of the unified contract. It explicitly does not solve the resource-metric SDK contract: no kind/temporality, no resource identity (device_id/if_index/target_device_ip always nil → per-device collapse), no per-metric labels, and metric_type is regex-guessed when the producer omits it (note: producer-declared type is honored first — plugin_metrics_publisher.ex:126-131). Two hardening items it left open and this work must close: the metrics stream still has no Nats-Msg-Id/duplicate_window, and two unrelated changes rode along (refresh_trace_summaries_worker chunk-size 3600→300, web-ng raw-traces fallback) that should be separated for clean revertability.


Recommendations (ordered, for the implementing agent)

  1. Land additively, not as a v2 break. Reuse serviceradar.metric.v1, bump schema_version 1→2, add resource/kind/temporality/is_monotonic/points[]/exemplars as optional. Version-1 flat envelopes map to a single gauge point; the Telemetry processor keeps consuming both shapes.
  2. Add the first-class metric emit path to both WASM SDKs first (signal_type:'metric' + payload_kind:'otel_metric' + NewMetricTelemetryRecord / TelemetryRecord::otel_metric via the existing emit_telemetry import). Keep AddMetric/add_metric_spec as perfdata shims so existing plugins keep working.
  3. Make producers declare kind explicitly and gate the gateway regex inference to legacy version-1 envelopes only — eliminates the silent series-reshuffle and subject-token instability.
  4. Add Nats-Msg-Id=ingress_id + a metrics-stream duplicate_window BEFORE enabling multi-point fan-out, and enforce idempotency in the TimescaleDB ingestors. At-least-once with consumer_max_deliver=5 will double-count otherwise.
  5. Pick ONE point-identity recipe over (resource tuple + name + point.attributes) and converge both hypertable PKs (otel_metric_points recipe-v2 vs TimeseriesSeriesKey). This is a PK migration on both tables — sequence it deliberately.
  6. Upgrade the anomaly consumer to be kind/temporality-aware (sample_extractor.ex/context_owner.ex): carry kind/temporality/unit through; rate-compute monotonic counters; ingest histogram count/sum/buckets for p50/p95 instead of rejecting value=nil. The contract is only worth its richness if a consumer uses it.
  7. Migrate the gateway sysmon/snmp/icmp/mtr/sweep producers LAST, after the shared go/pkg/metricpoint builder lands: re-emit the legacy schemas as serviceradar.metric.v1 multi-point envelopes, collapsing the three legacy schemas into one and fixing the gateway drops at the same time.
  8. Keep the addon raw-OTLP relay OTLP-native end to end (zero re-instrumentation for OTLP authors). Unify via a decode-and-republish shim. Decide two-lane (keep otel.metrics.raw as the high-fidelity express lane) vs one-lane.
  9. Do NOT touch OCSF. Keep the documented separation; add the mis-bucket guardrails; continue emitting anomaly Findings (class 2004) and NetFlow Traffic (class 4001).
  10. Add schema validation at gateway ingress and core consumer: reject absent/unknown schema instead of coercing to gauge; reject malformed serviceradar.metric.v1 rather than silently dropping non-numeric metrics.

Open questions

  1. Wire encoding: keep serviceradar.metric.v1 as JSON (matches today, debuggable, loses int/double distinction, heavier) or move the high-rate/addon-volume path to protobuf closer to OTLP NumberDataPoint? Hybrid (JSON for plugins, protobuf for collectors) is plausible.
  2. Two-lane vs one-lane for the addon raw-OTLP path: retire otel.metrics.raw in favor of the decode-and-republish shim, or keep it as the permanent high-fidelity express lane (exemplars/exp-histograms)?
  3. Resource-identity trust: producer-claimed resource.attributes vs gateway-attested ingest_identity can disagree (a plugin reporting many guest devices under one mTLS agent). Which wins for series identity, and do we need a per-point attested device binding (the DIRE behavioral-identity concern) rather than trusting producer labels?
  4. Single point-identity recipe: otel_metric_points recipe-v2 vs TimeseriesSeriesKey differ; which recipe, and what migration sequence for both PKs?
  5. Consumer scope: does the anomaly/causal engine get upgraded to be kind/temporality-aware (rate counters, ingest histogram p50/p95) as part of this, or does the contract carry the fields while the consumer keeps collapsing to a scalar?
  6. Histogram storage: otel_metric_points stores bucket_counts/explicit_bounds but drops exp-histograms/summaries; timeseries_metrics has no histogram columns. Extend timeseries_metrics or force all histograms to otel_metric_points?
  7. Unit normalization ownership: producers emit UCUM and the gateway validates, or the gateway normalizes free-form units?
  8. Sysmon per-family hypertables: retain cpu_metrics/memory_metrics/disk_metrics/process_metrics for query-shape reasons after sysmon emits serviceradar.metric.v1, or collapse into the unified sink? (Migrating onto the contract does not by itself mandate dropping the per-family tables.)
  9. Metric descriptors: mapper/discovery emits per-interface metric definitions (InterfaceMetric), not samples. Does the contract carry metric-descriptors on a separate channel, or is the catalog out of scope?

Verification note

Findings were produced by parallel subsystem readers and an adversarial verification pass; load-bearing claims were re-checked against code. Three initial claims were REFUTED and corrected above: (a) metrics are NOT mis-bucketed into OCSF; (b) native add-ons DO have a friendly metric API (same plugin_result path as wasm) rather than only raw OTLP; (c) PluginMetricsPublisher honors a producer-declared metric_type first, regex is only the fallback. The producer sweep also corrected three sub-agent over-classifications (netprobe / flow-collector / otel-addon StreamTelemetry are operational self-metrics, not smuggled time-series).

## TL;DR A single CPU-utilization metric reaches the anomaly engine through **three structurally disjoint pipelines** (Go agent sysmon, WASM/native plugin via `plugin_result`, native add-on via OTLP relay) that converge **only** on shared gRPC transport (`AgentGatewayService.StreamStatus` with opaque JSON in `GatewayServiceStatus.Message`, disambiguated by an agent-set `Source` string) and shared `IngressId`/`Sr-Ingress-*` attribution headers — **never on schema, subject, point-identity, or storage**. Three series-identity algorithms and three DB sinks (`cpu_metrics`, `timeseries_metrics`, `otel_metric_points`) mean the same physical core produces three uncorrelatable anomaly series, and only the OTLP path is analyzed by default. The core defects, all verified against code: 1. **Metrics are smuggled inside status/check-result envelopes** because producers have no first-class metric emit path. The agent-gateway then does a **parse-forward-reparse-republish** dance — the same metric JSON is decoded up to **three times** (gateway publisher → core `StatusHandler` after `forward` → core `Metrics` processor off NATS). 2. **Metric-shape fragmentation** is the real disease, *not* OCSF: 4 wire schemas, 3 sinks, 3 non-alignable series-identity recipes, no shared point identity. 3. **No `kind` / `temporality` anywhere in the producer SDKs**, and the anomaly consumer **discards `kind`/`temporality`/`unit` even when the OTLP path supplies them** — a cumulative counter fed raw into z-sigma looks like a ramp. **OCSF is NOT mis-used.** ServiceRadar correctly keeps metric time-series out of OCSF classes; the separation is deliberate and documented (`elixir/serviceradar_core/lib/serviceradar/event_writer/ocsf.ex:16-20`). The fix is metric-shape unification, which is **orthogonal to OCSF** and must not be solved by routing into it. **The fix is additive, not a rewrite.** PR #3787 already shipped `serviceradar.metric.v1` as a thin bridge and is the de-facto reference implementation. Evolve it to an OTLP-grade contract (`schema_version 1→2`, add `resource`/`kind`/`temporality`/`points[]`), give all producers a first-class metric emit path, lower the legacy sysmon/snmp/shadow special-cases onto it, and delete the migration-era debt. --- ## Architectural principle > **Metrics are a first-class signal.** Producers (WASM plugins, native add-ons, and the agent sysmon/snmp/icmp/mtr/sweep/rperf collectors) emit one `serviceradar.metric.v1` shape **directly onto a metric path** — never embedded inside a status / check-result / plugin-result envelope. The gateway stops re-extracting metrics from statuses, and the per-source (`sysmon`/`snmp`/`plugin`) special-cases collapse into one emit + one ingest + one sink. --- ## Current state — four disjoint pipelines (CPU metric traced end-to-end) | | Go agent sysmon | WASM/native plugin | native add-on OTLP relay | |---|---|---|---| | **producer shape** | `MetricSample.cpus[]{core_id,usage_percent}` (`go/pkg/sysmon/metrics.go:25-180`) | `Metric{name,value,unit,warn,crit,min,max}` (`serviceradar-sdk-go/sdk/result.go:52-60`; Rust `src/result.rs:138-184`) | OTLP `NumberDataPoint` in `ExportMetricsServiceRequest` | | **wrap / source** | JSON in `GatewayServiceStatus.Message`, `Source="sysmon-metrics"` (`push_loop_status.go:200-233`) | embedded in `serviceradar.plugin_result.v1` over `env.submit_result`, `Source="plugin-result"` | opaque OTLP, `Source="otlp-relay"` | | **gateway publisher** | `SysmonMetricsPublisher` → `serviceradar.sysmon.metrics.v1` → `metrics.sysmon.<family>` | `PluginMetricsPublisher` → `serviceradar.metric.v1` → `metrics.timeseries.<type>.<name>` | `OtlpRelayPublisher` → verbatim → `otel.metrics.raw` | | **stream** | `metrics` | `metrics` | `events` (separate) | | **core processor → sink** | `Metrics`→`SysmonMetricsIngestor` → **`cpu_metrics`** per-family hypertable | `Metrics`→`Telemetry` → **`timeseries_metrics`** | `OtelMetrics` (protobuf) → **`otel_metric_points`** | | **anomaly series id** | `sysmon:cpu:<host>:<core_id>` | `cpu:<TimeseriesSeriesKey md5>` | `otel:<svc>:<name>:<attributes_hash>` | | **kind/temporality/unit** | none | none (unit stored, then dropped) | **full — then discarded by consumer** | The gateway is **not a normalizer** — it is four source-keyed pass-through publishers selected by the agent-supplied `Source` string (`status_processor.ex:57-97,328-431`). ### Divergences (why the same core yields three uncorrelatable series) - **3 series-identity algorithms**, none alignable: sysmon string-built (`sample_extractor.ex` ~`:123`), plugin `TimeseriesSeriesKey` md5 (`timeseries_series_key.ex:6-35`), OTLP `attributes_hash` recipe-v2 (`otel_metric_point.ex:19-198`). - **3 DB sinks / schemas** with no unifying view or join (repo-wide search found no UNION/view merging `timeseries_metrics` and `otel_metric_points`). - **3 wire schemas / encodings**: `serviceradar.sysmon.metrics.v1` (whole-sample blob), `serviceradar.metric.v1` (per-scalar JSON), raw OTLP protobuf. - **Coverage asymmetry**: anomaly `enabled_subjects` defaults to `['otel.metrics.>']` only (`anomaly_detection/config.ex:18`), so **only the native-addon OTLP CPU metric is analyzed out of the box**; sysmon and plugin CPU are subscribed but gated off until `ANOMALY_ANALYSIS_ENABLED_SUBJECTS` is widened. - **Per-device identity collapse** on the plugin path only: `serviceradar.metric.v1` never sets `device_id`/`target_device_ip`/`if_index` and per-metric labels are not promoted to tags, so many devices under one agent collide into one series (`telemetry.ex:112-137`). - **Timestamp semantics differ**: OTLP has true per-point `time_unix_nano`; sysmon shares one sample-level timestamp across all cores; plugin shares the `Result` envelope `observed_at` across all metrics. - **Consumer destroys fidelity uniformly**: `SampleExtractor → ContextOwner → CausalReasoner` reduces every path to `{value, observed_at_unix_nano}` (`sample_extractor.ex`, `context_owner.ex:349-363`). `kind`/`temporality`/`unit`/`is_monotonic` never reach the reasoner even when `otel_metric_points` has them. --- ## The double/triple-decode (the "re-publish" smell) `status_processor.ex` `forward_then_publish/1` (`:78-97`) does two passes over the same payload: (1) `forward(status)` ships the whole status to core for status/state processing, then (2) re-parses `status.message` and republishes the extracted metrics onto JetStream via three source-specific publishers. Evidence of the same metric JSON decoded three times: - `sysmon_metrics_publisher.ex:42-51` / `snmp_metrics_publisher.ex:44-53` / `plugin_metrics_publisher.ex:44-53` — each `Jason.decode(status.message)` (decode #2). - `metrics.ex:46-72` — core re-decodes the republished NATS message (decode #3). It is **not** a duplicate DB write today: the direct-insert sink is flag-gated off (`plugin_result_ingestor.ex:6-10,:52,:200-206`, `:plugin_result_direct_metrics_enabled` default `false`), and the core gRPC-direct-to-DB metric handlers are already no-op stubs (`results_router.ex:239-245,:611` → `acknowledge_metrics_cutover/1 → :ok`). So half the migration is already done — it just hasn't been cleaned up. The remaining smell is purely the smuggle-and-re-extract architecture, which the first-class metric path eliminates. --- ## OCSF finding — NOT a category error (keep the separation, add guardrails) This was investigated directly and the "metrics are mis-bucketed into OCSF" claim was **REFUTED** across producers, transport, consumers, and schema: - Policy is documented in code: `ocsf.ex:16-20` — *"OTel traces/metrics: Keep native format (observability, not security)"* and *"Telemetry metrics: Keep native format (time-series data)"*. - `ocsf_events` (migration `20260203120000_create_ocsf_events.exs:14-45`) is a pure security-event shape (`class_uid`/`category_uid`/`activity_id`/`severity_id`/observables/actor/device/endpoints) with **zero** metric value/unit/temporality/metric_name columns — it physically cannot hold a series. - Both WASM SDKs only have `ocsf_event`/`otel_log` payload kinds (`serviceradar-sdk-go/sdk/signal_schema.go:17-18`, `serviceradar-sdk-rust/src/result.rs:356-357`) — there is no metric signal type, so a metric cannot be serialized as an OCSF event. - The only numbers legitimately inside OCSF are (a) NetFlow byte/packet counters as `traffic{}` in `ocsf_network_activity` class 4001 (event-shaped, OCSF-sanctioned), and (b) anomaly/capacity **Findings** (class 2004) carrying a single anomalous value + thresholds as event context (`verdict_emitter.ex:34-74,:96-118`) — derived *from* metrics, not duplicating the series. Both are correct and stay. **Recommendations:** - **Do NOT add a "metric" OCSF class.** OCSF has no metric/time-series primitive; inventing one would be the actual category error. - **Keep using OCSF for events derived from metrics** (threshold breaches → Findings) — the code already does this correctly. - **Add mis-bucket guardrails** with the unified contract: the metrics processor rejects any `serviceradar.metric.v1` payload carrying `class_uid`; the OCSF processor rejects payloads carrying `kind`/`temporality`/`points[]`. A misrouted message then errors loudly instead of landing in the wrong hypertable. --- ## Confirmed gaps (verified against code) 1. **No metric `kind`** (gauge/counter/histogram) in any producer SDK or in three of four gateway envelopes — `result.go:52-60`, `result.rs:138-184`. Downstream `metric_type` is a routing label, not an OTel kind. 2. **No `temporality`** (delta/cumulative) in SDKs or the `serviceradar.metric.v1` / `serviceradar.sysmon.metrics.v1` envelopes (`plugin_metrics_publisher.ex:93-111`). SNMP carries only `is_delta`. Result: monotonic counters look like ramps to z-sigma. 3. **No first-class metric emit helper in either WASM SDK** — `telemetry.go` has only `NewOCSFTelemetryRecord`(`:36`) and `NewOTELLogTelemetryRecord`(`:53`); `telemetry.rs` only `ocsf_event`(`:60`)/`otel_log`(`:76`). Metrics can only ride embedded in `submit_result`; there is no metric payload kind. 4. **No per-metric tags / resource identity / point identity** on the `plugin_result` `Metric` shape — labels are envelope-level (`result.go:37`, `result.rs:385`); per-metric dimensions are lost (`plugin_metrics_publisher.ex:113-191`). 5. **Per-device identity collapse on the plugin path** — `serviceradar.metric.v1` never sets `device_id`/`target_device_ip`/`if_index`, so many devices under one agent collapse into one series. 6. **`metrics.timeseries.*` and `otel.metrics.raw` decode into different sinks** (`timeseries_metrics` vs `otel_metric_points`) with no canonical merge. 7. **Anomaly consumer drops `kind`/`temporality`/`unit`** at extraction even when OTLP supplies them (`sample_extractor.ex`, `context_owner.ex:349-363`). 8. **Coverage losses inside the (correct) metric namespace**: OTLP exponential histograms & summaries are counted-then-dropped (`otel_metrics.ex` ~`:258-281`); the SNMP gateway publisher drops every OID except `ifHCInOctets`/`ifHCOutOctets` (`snmp_metrics_publisher.ex`); sysmon `Network[]` interface metrics are collected but dropped at the gateway (only cpu/memory/disk/process families emitted). 9. **No `Nats-Msg-Id` / `duplicate_window` on the `metrics` stream** (`config.ex:206-219`). With `consumer_max_deliver=5` and the planned multi-point fan-out, at-least-once delivery would double-count; idempotency must be enforced at the stream and/or ingestor. 10. **No schema/proto validation of `serviceradar.metric.v1`** — the producer hand-builds the map and the consumer tolerantly defaults missing fields (`metric_type→'gauge'`); malformed envelopes become `gauge`/`unknown` rows instead of being rejected. Non-numeric metrics are silently dropped (`encode_metric` returns `{:ok, nil}` with no counter — invisible data loss). --- ## Producer refactor work-list (every producer smuggling metrics through status) Swept all 12 native add-ons, both WASM SDKs, and in-tree first-party checkers (22 producers classified). ### `SMUGGLES_METRICS` — first-party in-tree checkers (the real refactor targets) | producer | metrics | source string | evidence | |---|---|---|---| | **sysmon** | cpu/disk/memory/network-iface/process | `sysmon-metrics` | `go/pkg/sysmon/metrics.go:25-180`, `push_loop_status.go:200-233` | | **snmp** | OID counters/gauges w/ `if_index`,`interface_uid` | `snmp-metrics` | `push_loop_snmp.go:30-42,48-57,63` | | **icmp** | `response_time_ns`, `packet_loss` | `results` | `push_loop_icmp_results.go:29-68`, `icmp_checker.go:69-77` | | **mtr** | per-hop RTT/loss/jitter | `results` | `mtr_checker.go:61-70,86-127`, `proto/monitoring.proto:1012-1046` | | **network sweep** | scanner perf counters (`SweepScannerStats`) | `results` | `proto/monitoring.proto:149-200`, `push_loop_sweep_results.go:28-118` | | **mapper/discovery** | per-interface metric **descriptors** (`InterfaceMetric`) — catalog, not samples | discovery status | `proto/discovery/discovery.proto:188-214`, `push_loop_mapper_netprobe.go:36-93` | | **rperf** (Rust checker) | throughput/loss/jitter (`TestSummary`) | `plugin-result` (generic path) | `proto/rperf/rperf.proto:60-72`, `rust/rperf-client/src/server.rs:259-300` | > Note: mapper/discovery carries metric **definitions** (the catalog the snmp checker samples), not time-series — decide whether the unified contract carries metric-descriptors on a separate channel. ### SDK smuggling helpers (add first-class emit here, then deprecate) - Go: `Result.Metrics` slot (`serviceradar-sdk-go/sdk/result.go:36`), `Metric` struct (`:52-60`), `AddMetric`/`WithMetric` builders (`:257-273`). - Rust: `Metric` struct (`serviceradar-sdk-rust/src/result.rs:138-151`), `add_metric_spec`/`with_metric_spec` (`:578-585`), `add_metric`/`with_metric` (`:587-612`). - 6 SDK examples (`http-check`, `udp-check`, `tcp-check`, `widgets-check` ×Go; `http-check`, `widgets-check` ×Rust) and 2 golden fixtures (`testdata/service_monitoring_result.json` ×2) demonstrate the pattern and must update in lockstep. ### `OTLP_NATIVE` (already first-class) - **otel-addon** + `rust/otel` library relay customer OTLP metrics via the acked `RelayOtlp` stream (`otlp-relay:v1`): `rust/otel-addon/src/addon.rs:27,556`, `rust/otel/src/lib.rs:529,754`, `addon.proto:159-165` (`OTLP_METRICS=5`, `OTLP_DERIVED_METRIC=6`). Decision needed: normalize OTLP_METRICS frames into `serviceradar.metric.v1` downstream, or keep `otel.metrics.raw` as a high-fidelity express lane. ### `NO_METRICS` (no refactor) — explicitly verified to avoid false positives bumblebee-scan, endpoint-inventory, scalibr-endpoint-inventory, advisory-producer, sample-addon, powerdns, rdp-adapter, workload-identity, bmp-collector, log-collector, trapd, flowgger, rust-sample-addon, rperf-server. **Correction to parallel sub-agents:** netprobe, flow-collector, and otel-addon's `StreamTelemetry` expose only **operational Prometheus/OCSF-usage self-metrics**, NOT smuggled observability time-series — they are NOT refactor targets. --- ## Tech-debt to delete / collapse (the unification makes the special-cases deletable) ### DELETE (pure migration-era scaffolding) - `@legacy_sysmon_schema "serviceradar.sysmon.shadow.v1"` acceptance — `metrics.ex:19,:151`. - `AGENT_GATEWAY_SYSMON/SNMP_METRICS_SHADOW_ENABLED` env defaults (`runtime.exs:314,318`, default `"true"`) + helm `*MetricsShadowEnabled` aliases (`agent-gateway.yaml:30,36`). - Flag-gated legacy direct insert `maybe_insert_metrics` + `:plugin_result_direct_metrics_enabled` (`plugin_result_ingestor.ex:52,:200-206`; `core/config.exs:131-133`). Keep `build_status_row`/`insert_status` — only the metric insert is debt. - Vestigial no-op router handlers `handle_sysmon_metrics`/`handle_snmp_metrics`/`acknowledge_metrics_cutover` (`results_router.ex:239-245,:611`) and their dispatch entries (`status_handler.ex:109-111`). ### COLLAPSE (replace source-specific handling with the unified path) - `SysmonMetricsPublisher` (`sysmon_metrics_publisher.ex` whole module) and `SnmpMetricsPublisher` (`snmp_metrics_publisher.ex` whole module) → fold into a generalization of `PluginMetricsPublisher` (which already emits `serviceradar.metric.v1`). These are the publish boundary (agents are NATS-denied), so they must be **replaced, not merely deleted**. - Sysmon family-envelope carve-out in `metrics.ex:18,50,59-181` (`parse_sysmon`/`filter_sysmon_sample`/`partition_messages`/`ingest_sysmon_messages`/`metric_kind`) → the generic Telemetry/timeseries path. - `SysmonMetricsIngestor` (`sysmon_metrics_ingestor.ex`) 5-hypertable fan-out → unified ingest. - Gateway double-pass `forward_then_publish` + `publish_sysmon/snmp/plugin_metrics` + `*_source?` guards (`status_processor.ex:78-97,313-427`); per-source `max_message_bytes`/`strict_message_size_source?` (`agent_gateway_server.ex:665-680`) — **preserve the large sysmon size budget** on the unified path. - Anomaly per-source carve-outs: `sample_extractor.ex:67-71,114-189`, `series_config.ex:201-214`, `config.ex:108-109` (`ANALYSIS_METRICS_SYSMON`/`SNMP`). - Per-source subject prefixes / NATS ACLs (`config.exs:30,35`, `runtime.exs:346,351`, `agent-gateway.yaml:127,131`, `nats.yaml:211`, `values-demo.yaml:488`, `values.yaml:946,948`) — re-bind stream subjects + durable consumer filters **atomically** during cutover. ### MIGRATE (producer code switches to first-class emit) - Go agent sysmon producer (`push_loop_status.go:118-233`, `sysmon_service.go:604-612`, `go/pkg/sysmon/{metrics,collector}.go`) — flatten cpus/clusters/disks/processes into per-series scalar points. - Go agent SNMP producer (`push_loop_snmp.go:66,77,101,140`) — keep `if_index`/`target_device_ip` identity. - (+ icmp/mtr/sweep/rperf per the producer work-list above.) ### KEEP — do NOT delete (sysmon-named but config/UI, not metric debt) - `sysmon_profiles` table + migrations (`20260519162000_*`, `20260123093000_*`), `sysmon_compiler.ex`, settings UI (`sysmon_profiles_live/index.ex`) — **configuration**. - Device-detail sysmon charts UI (`device_tab_runtime.ex`) and SRQL catalog — **repoint** to the unified table, do not delete. - `go/pkg/sysmon/collector.go` + `sysmon_service.go` — the collector itself (migrate the emit shape, not the collector). - The five sysmon **metric** hypertables (`cpu_metrics`/`cpu_cluster_metrics`/`memory_metrics`/`disk_metrics`/`process_metrics`) + `_hourly` caggs — unification candidates, but they back live UI charts and anomaly baselines: **data-migrate before any DROP**. ### Tests that lock in the per-source schemas/subjects (will need rewriting) `metrics_test.exs` (legacy shadow + sysmon/snmp fixtures), `sysmon_metrics_publisher_test.exs`, `snmp_metrics_publisher_test.exs`, `status_processor_test.exs:428,456`, `sysmon_metrics_ingestor_test.exs`, `snmp_metrics_ingestor_integration_test.exs`, `results_router_test.exs:431,445`, `sample_extractor_test.exs`, `pipeline_test.exs`, `config_test.exs:40-88`, `series_config_test.exs`/`synthetic_dataset_test.exs`/`verdict_emitter_test.exs`/`baseline_seeder_test.exs`/`stateful_alert_engine_test.exs`, `jetstream_consumer_test.exs`, `pipeline_ack_test.exs`/`pipeline_batch_span_test.exs`. --- ## Proposed unified contract: `serviceradar.metric.v1` (evolve additively from the PR #3787 bridge) Reuse the existing schema id; bump `schema_version` 1→2 and add the OTLP-grade fields below. Version-1 flat envelopes remain valid (treated as a single gauge point: `value→points[0].value`, `metric_type→kind`, `tags→points[0].attributes`, `observed_at→points[0].time_unix_nano`). Consumers MUST reject envelopes whose `schema` is absent/unknown rather than coercing to `gauge`. | field | type | purpose | OTLP equiv | |---|---|---|---| | `schema` | const `"serviceradar.metric.v1"` | route + version-gate | Schema URL analog | | `schema_version` | uint32 (default 1) | additive evolution | — | | `resource` | `{service_name(req), service_instance_id, scope_name, scope_version, attributes:map}` | resource identity carried once per batch/point — the field the SDK `Metric` and PR3787 envelope both lack. sysmon: `service_name='sysmon'`, attrs carry `host_id`/`core_id`/`mount_point`/`pid`; snmp: `target_device_ip`/`if_index`/`interface_uid` | Resource + InstrumentationScope | | `name` | string (req) | metric stream name (dotted semconv), no longer the sole identity | Metric.name | | `kind` | enum `{gauge, sum, histogram, exp_histogram, summary}` | the field every producer is missing; lets the engine rate-compute counters | Metric.data oneof | | `temporality` | enum `{unspecified, delta, cumulative}` (req for sum/histogram) | counter-rate computability; snmp `is_delta→delta` | aggregation_temporality | | `is_monotonic` | bool (kind=sum) | counter vs UpDownCounter | Sum.is_monotonic | | `unit` | string (UCUM: `%`,`By`,`1`,`ms`,`Hz`) | stops n_sigma mixing percent/bytes/counts | Metric.unit | | `points` | array<DataPoint> (req, ≥1) | one OR MANY points per emit so cores/mounts/interfaces/guests dimension **without** splitting envelopes (fixes per-device collapse) | data_points (repeated) | | `points[].time_unix_nano` | uint64 (req) | per-point measurement time | DataPoint.time_unix_nano | | `points[].start_time_unix_nano` | uint64 (req for cumulative rate) | counter reset/window anchor | DataPoint.start_time_unix_nano | | `points[].attributes` | map<string,scalar> | per-point dims (`core_id`,`mount_point`,`if_index`,`guest_id`); volatile keys excluded from identity | DataPoint.attributes | | `points[].value` | double\|int (gauge/sum) | int vs double preserved | NumberDataPoint.as_double/as_int | | `points[].histogram` | `{count,sum,bucket_counts[],explicit_bounds[],min,max}` (histogram) | first-class histogram (none today); enables p50/p95 series currently dropped | HistogramDataPoint | | `points[].exemplars` | `[{time_unix_nano,value,trace_id,span_id,filtered_attributes}]` (opt) | metric→trace pivots the causal engine wants | DataPoint.exemplars | | `thresholds` | `{warn,crit,min,max}` (opt) | preserves Nagios perfdata semantics **without** conflating with metric data; alerting/display only | — (deliberately outside OTLP) | | `ingress_id` | UUIDv8 (gateway-stamped) | per-message ordering/idempotency; also set as `Nats-Msg-Id` | — | | `ingress_timestamp_unix_nano` | uint64 (gateway-stamped) | ingest/transport time, distinct from measurement time | — | | `ingest_identity` | `{ingest_agent_id, ingest_partition, ingest_identity:'agent:<id>'}` (gateway-attested) | mTLS-bound attested identity, separate from producer-claimed `resource.attributes` (untrusted) | attested overlay | ### Per-SDK emit APIs (new third signal stream: `signal_type:'metric'` / `payload_kind:'otel_metric'`) - **go-wasm**: `sdk.NewGauge/NewCounter/NewHistogram(name, value, ...MetricOption)` with `WithUnit/WithAttributes/WithTimestamp/WithResource/WithThresholds/WithExemplar`; collected into `sdk.NewMetricBatch(resource)`; add `NewMetricTelemetryRecord(batch)` so metrics ride `env.emit_telemetry`. Keep `Result.AddMetric` as a shim lowering to a gauge point. - **rust-wasm**: `MetricPoint::gauge/counter/histogram(...)` + `MetricBatch::new(ResourceRef::service(...))` + `TelemetryRecord::otel_metric(batch)`. Keep `Metric`/`add_metric_spec` as a perfdata shim. - **native add-on** (`rust/addon-sdk`): `MetricBatchBuilder::new(resource).gauge/counter/histogram(...)` + `Addon::emit_metrics(batch)` wrapping a new `TelemetryPayloadKind::SR_METRIC_V1` (alongside `OTLP_METRICS=5`, `OTLP_DERIVED_METRIC=6`). OTLP-native authors keep `relay_otlp()` unchanged. - **agent sysmon/snmp/icmp/mtr/sweep**: internal `go/pkg/metricpoint` builder mirroring the SDK so the gateway publishers emit `serviceradar.metric.v1` multi-point envelopes (one `Metric{kind,unit,points[]}` per family), replacing the raw-sample blob and the 2-OID hardcode; emit the currently-dropped sysmon `Network[]` and SNMP non-octet OIDs. - **shared**: `ResourceRef.service(name).instance(id).scope(name,ver).attr(k,v)` so all producers construct the resource tuple once. ### Subject topology One canonical tree `metrics.v1.<kind>.<domain>.<name-token>` on the existing high-rate `metrics` stream (widen the binding to also own `metrics.v1.>`). Leading token = the real `kind` field (no longer regex-guessed). Legacy subjects (`metrics.sysmon.*`, `metrics.snmp.interface.*`, `metrics.timeseries.<type>.<name>`) stay bound during migration. The addon raw-OTLP path is NOT moved by alias — a decode-and-republish shim lowers `ExportMetricsServiceRequest` into `metrics.v1.*` for a single consumer shape (or keep `otel.metrics.raw` as the express lane — see open questions). **Critical hardening before fan-out:** gateway sets `Nats-Msg-Id`=`ingress_id` on every publish AND the `metrics` stream gains a `duplicate_window`. Add `metrics.v1.>` to `ANOMALY_ANALYSIS_ENABLED_SUBJECTS`. --- ## Relationship to PR #3787 PR #3787 is a correct, deliberately-thin **bridge** that should be **evolved, not replaced**. It ships `serviceradar.metric.v1` as a flat `{schema, metric_name, metric_type, value, unit, tags, metadata}` envelope on `metrics.timeseries.<type>.<name>` → Telemetry → `timeseries_metrics`, wires the anomaly consumer (`ANALYSIS_METRICS_TIMESERIES`), and cleanly gates off the legacy direct-DB insert. It is the de-facto reference implementation of the unified contract. It explicitly does **not** solve the resource-metric SDK contract: no kind/temporality, no resource identity (`device_id`/`if_index`/`target_device_ip` always nil → per-device collapse), no per-metric labels, and `metric_type` is regex-guessed when the producer omits it (note: producer-declared type **is** honored first — `plugin_metrics_publisher.ex:126-131`). Two hardening items it left open and this work must close: the `metrics` stream still has no `Nats-Msg-Id`/`duplicate_window`, and two unrelated changes rode along (`refresh_trace_summaries_worker` chunk-size 3600→300, web-ng raw-traces fallback) that should be separated for clean revertability. --- ## Recommendations (ordered, for the implementing agent) 1. **Land additively, not as a v2 break.** Reuse `serviceradar.metric.v1`, bump `schema_version 1→2`, add `resource`/`kind`/`temporality`/`is_monotonic`/`points[]`/`exemplars` as optional. Version-1 flat envelopes map to a single gauge point; the Telemetry processor keeps consuming both shapes. 2. **Add the first-class metric emit path to both WASM SDKs first** (`signal_type:'metric'` + `payload_kind:'otel_metric'` + `NewMetricTelemetryRecord` / `TelemetryRecord::otel_metric` via the existing `emit_telemetry` import). Keep `AddMetric`/`add_metric_spec` as perfdata shims so existing plugins keep working. 3. **Make producers declare `kind` explicitly** and gate the gateway regex inference to legacy version-1 envelopes only — eliminates the silent series-reshuffle and subject-token instability. 4. **Add `Nats-Msg-Id`=`ingress_id` + a `metrics`-stream `duplicate_window` BEFORE enabling multi-point fan-out**, and enforce idempotency in the TimescaleDB ingestors. At-least-once with `consumer_max_deliver=5` will double-count otherwise. 5. **Pick ONE point-identity recipe** over `(resource tuple + name + point.attributes)` and converge both hypertable PKs (`otel_metric_points` recipe-v2 vs `TimeseriesSeriesKey`). This is a PK migration on both tables — sequence it deliberately. 6. **Upgrade the anomaly consumer to be kind/temporality-aware** (`sample_extractor.ex`/`context_owner.ex`): carry `kind`/`temporality`/`unit` through; rate-compute monotonic counters; ingest histogram count/sum/buckets for p50/p95 instead of rejecting `value=nil`. The contract is only worth its richness if a consumer uses it. 7. **Migrate the gateway sysmon/snmp/icmp/mtr/sweep producers LAST**, after the shared `go/pkg/metricpoint` builder lands: re-emit the legacy schemas as `serviceradar.metric.v1` multi-point envelopes, collapsing the three legacy schemas into one and fixing the gateway drops at the same time. 8. **Keep the addon raw-OTLP relay OTLP-native end to end** (zero re-instrumentation for OTLP authors). Unify via a decode-and-republish shim. Decide two-lane (keep `otel.metrics.raw` as the high-fidelity express lane) vs one-lane. 9. **Do NOT touch OCSF.** Keep the documented separation; add the mis-bucket guardrails; continue emitting anomaly Findings (class 2004) and NetFlow Traffic (class 4001). 10. **Add schema validation** at gateway ingress and core consumer: reject absent/unknown `schema` instead of coercing to `gauge`; reject malformed `serviceradar.metric.v1` rather than silently dropping non-numeric metrics. --- ## Open questions 1. **Wire encoding**: keep `serviceradar.metric.v1` as JSON (matches today, debuggable, loses int/double distinction, heavier) or move the high-rate/addon-volume path to protobuf closer to OTLP `NumberDataPoint`? Hybrid (JSON for plugins, protobuf for collectors) is plausible. 2. **Two-lane vs one-lane** for the addon raw-OTLP path: retire `otel.metrics.raw` in favor of the decode-and-republish shim, or keep it as the permanent high-fidelity express lane (exemplars/exp-histograms)? 3. **Resource-identity trust**: producer-claimed `resource.attributes` vs gateway-attested `ingest_identity` can disagree (a plugin reporting many guest devices under one mTLS agent). Which wins for series identity, and do we need a per-point attested device binding (the DIRE behavioral-identity concern) rather than trusting producer labels? 4. **Single point-identity recipe**: `otel_metric_points` recipe-v2 vs `TimeseriesSeriesKey` differ; which recipe, and what migration sequence for both PKs? 5. **Consumer scope**: does the anomaly/causal engine get upgraded to be kind/temporality-aware (rate counters, ingest histogram p50/p95) as part of this, or does the contract carry the fields while the consumer keeps collapsing to a scalar? 6. **Histogram storage**: `otel_metric_points` stores bucket_counts/explicit_bounds but drops exp-histograms/summaries; `timeseries_metrics` has no histogram columns. Extend `timeseries_metrics` or force all histograms to `otel_metric_points`? 7. **Unit normalization ownership**: producers emit UCUM and the gateway validates, or the gateway normalizes free-form units? 8. **Sysmon per-family hypertables**: retain `cpu_metrics`/`memory_metrics`/`disk_metrics`/`process_metrics` for query-shape reasons after sysmon emits `serviceradar.metric.v1`, or collapse into the unified sink? (Migrating onto the contract does not by itself mandate dropping the per-family tables.) 9. **Metric descriptors**: mapper/discovery emits per-interface metric *definitions* (`InterfaceMetric`), not samples. Does the contract carry metric-descriptors on a separate channel, or is the catalog out of scope? --- ## Verification note Findings were produced by parallel subsystem readers and an adversarial verification pass; load-bearing claims were re-checked against code. Three initial claims were **REFUTED** and corrected above: (a) metrics are NOT mis-bucketed into OCSF; (b) native add-ons DO have a friendly metric API (same `plugin_result` path as wasm) rather than only raw OTLP; (c) `PluginMetricsPublisher` honors a producer-declared `metric_type` first, regex is only the fallback. The producer sweep also corrected three sub-agent over-classifications (netprobe / flow-collector / otel-addon `StreamTelemetry` are operational self-metrics, not smuggled time-series).
Author
Owner

Companion issue filed: #3789Host/system (sysmon) metric collection needs first-class monotonic-counter support (wrap + reset/reboot detection); SNMP is the partial-but-flawed reference.

It is the collector-level + algorithm deep-dive for the kind / is_monotonic / temporality / points[].start_time_unix_nano fields proposed here: how the agent's two metric collectors (go/pkg/sysmon/ and go/pkg/agent/snmp/) must actually produce correct counter semantics, why both get it wrong today (sysmon ships raw since-boot counters with no rate → perpetual z-score ramp; SNMP turns every reboot into a ~4.29B false spike and discards the raw value), and the reset-vs-wrap detection algorithm to adopt.

Companion issue filed: #3789 — *Host/system (sysmon) metric collection needs first-class monotonic-counter support (wrap + reset/reboot detection); SNMP is the partial-but-flawed reference.* It is the collector-level + algorithm deep-dive for the `kind` / `is_monotonic` / `temporality` / `points[].start_time_unix_nano` fields proposed here: how the agent's two metric collectors (`go/pkg/sysmon/` and `go/pkg/agent/snmp/`) must actually *produce* correct counter semantics, why both get it wrong today (sysmon ships raw since-boot counters with no rate → perpetual z-score ramp; SNMP turns every reboot into a ~4.29B false spike and discards the raw value), and the reset-vs-wrap detection algorithm to adopt.
Author
Owner

Merged related PRs into staging:

  • #3787 plugin metrics / observability fixes
  • #3791 anomaly scale architecture OpenSpec
  • #3792 compact evaluator benchmark/test scaffold
  • #3793 counter + cgroup OpenSpec proposals
  • #3794 cumulative counter normalizer
  • #3795 SNMP raw counter semantics

Keeping this issue open because the full unified metric ingestion contract is not complete yet: schema v2, producer migrations, canonical SDK emit APIs, stream idempotency, and cleanup still need implementation.

Merged related PRs into staging: - #3787 plugin metrics / observability fixes - #3791 anomaly scale architecture OpenSpec - #3792 compact evaluator benchmark/test scaffold - #3793 counter + cgroup OpenSpec proposals - #3794 cumulative counter normalizer - #3795 SNMP raw counter semantics Keeping this issue open because the full unified metric ingestion contract is not complete yet: schema v2, producer migrations, canonical SDK emit APIs, stream idempotency, and cleanup still need implementation.
Author
Owner

Implementation status ledger (additive contract work)

Shipped a stack of small, independently-reviewable PRs covering every DELETE-list item and every isolated recommendation. Each is base staging unless noted.

Tech-debt DELETE list — complete (4/4)

item PR
Remove serviceradar.sysmon.shadow.v1 acceptance (metrics.ex) #3810
Remove flag-gated dead maybe_insert_metrics + :plugin_result_direct_metrics_enabled #3812
Remove vestigial no-op router handlers (handle_sysmon_metrics/handle_snmp_metrics/acknowledge_metrics_cutover + status_handler dispatch) #3814 (stacked on #3813)
Collapse AGENT_GATEWAY_{SYSMON,SNMP}_METRICS_SHADOW_ENABLED env + helm *MetricsShadowEnabled aliases to the single *_METRICS_ENABLED flag #3815

Recommendations — isolated items shipped

REC what PR
REC1 carry OTLP-grade v2 semantics (schema_version 1→2, kind/temporality/resource scaffolding) in the plugin metric envelope #3805
REC3 record whether plugin metric_type was producer-declared vs regex-inferred #3808
REC4 dedup the metrics JetStream stream via Nats-Msg-Id = ingress_id + duplicate_window #3804
REC7a shared go/pkg/metricpoint serviceradar.metric.v1 builder for producers #3809
REC7e make the gateway SNMP interface-metric allowlist configurable (was a 2-OID hardcode) #3807
REC9 reject mis-bucketed metric/OCSF payloads (guardrails) #3803
REC10 surface silent metric_type→gauge coercion + dropped non-numeric plugin metrics via telemetry #3806, #3802

The "double work" / re-publish smell (TL;DR defect #1)

what PR
Gateway stops forwarding sysmon-metrics/snmp-metrics statuses to core (the forward only hit the acknowledge_metrics_cutover no-op) — publishes directly to JetStream instead #3813

Remaining RECs — owned elsewhere or design-gated (NOT started, by design)

REC why not done here
REC2 (first-class metric emit in both WASM SDKs) Actively owned by the add-service-monitoring-sdk-parity effort in serviceradar-sdk-go / serviceradar-sdk-rust — both repos already carry feat: add first-class telemetry emission commits + uncommitted work. Touching them would collide.
REC5 (single point-identity recipe; converge otel_metric_points recipe-v2 vs TimeseriesSeriesKey PKs) Dual-table PK migration gated on open question #4 (which recipe wins). Needs an explicit decision + sequenced migration, not a small PR.
REC6 (kind/temporality-aware anomaly consumer; rate counters; histogram p50/p95) anomaly_detection/** is the concurrent agent's domain — landed as the compact evaluator (#3792) and counter/cgroup specs (#3793, #3789/#3790). Counter normalization already shipped in #3795.
REC7b (migrate agent sysmon/snmp/icmp/mtr/sweep producers onto metricpoint) Ordered LAST for good reason: depends on REC2 (SDK shape), REC5 (point identity), and REC6 (consumer). It's the cross-cutting COLLAPSE that rewires agent→gateway→core→anomaly atomically — not isolatable into a small reviewable PR until the above land.
REC8 (addon raw-OTLP one-lane vs two-lane) Pure design decision (open question #2): retire otel.metrics.raw via a decode-and-republish shim, or keep it as the high-fidelity express lane.

Net: the additive contract groundwork (REC1/3/4/7a/7e/9/10), all migration-era debt deletion, and the double-forward fix are done and verified. What remains is the coordinated producer migration (REC7b) which is intentionally blocked behind the SDK parity effort (REC2), the point-identity decision (REC5), and the anomaly-consumer upgrade (REC6) — all either in flight on other branches or awaiting an open-question decision.

## Implementation status ledger (additive contract work) Shipped a stack of small, independently-reviewable PRs covering every DELETE-list item and every isolated recommendation. Each is base `staging` unless noted. ### ✅ Tech-debt DELETE list — complete (4/4) | item | PR | |---|---| | Remove `serviceradar.sysmon.shadow.v1` acceptance (`metrics.ex`) | **#3810** | | Remove flag-gated dead `maybe_insert_metrics` + `:plugin_result_direct_metrics_enabled` | **#3812** | | Remove vestigial no-op router handlers (`handle_sysmon_metrics`/`handle_snmp_metrics`/`acknowledge_metrics_cutover` + `status_handler` dispatch) | **#3814** *(stacked on #3813)* | | Collapse `AGENT_GATEWAY_{SYSMON,SNMP}_METRICS_SHADOW_ENABLED` env + helm `*MetricsShadowEnabled` aliases to the single `*_METRICS_ENABLED` flag | **#3815** | ### ✅ Recommendations — isolated items shipped | REC | what | PR | |---|---|---| | REC1 | carry OTLP-grade v2 semantics (`schema_version 1→2`, kind/temporality/resource scaffolding) in the plugin metric envelope | **#3805** | | REC3 | record whether plugin `metric_type` was producer-declared vs regex-inferred | **#3808** | | REC4 | dedup the `metrics` JetStream stream via `Nats-Msg-Id` = `ingress_id` + `duplicate_window` | **#3804** | | REC7a | shared `go/pkg/metricpoint` `serviceradar.metric.v1` builder for producers | **#3809** | | REC7e | make the gateway SNMP interface-metric allowlist configurable (was a 2-OID hardcode) | **#3807** | | REC9 | reject mis-bucketed metric/OCSF payloads (guardrails) | **#3803** | | REC10 | surface silent `metric_type→gauge` coercion + dropped non-numeric plugin metrics via telemetry | **#3806**, **#3802** | ### ✅ The "double work" / re-publish smell (TL;DR defect #1) | what | PR | |---|---| | Gateway stops forwarding `sysmon-metrics`/`snmp-metrics` statuses to core (the forward only hit the `acknowledge_metrics_cutover` no-op) — publishes directly to JetStream instead | **#3813** | ### ⏳ Remaining RECs — owned elsewhere or design-gated (NOT started, by design) | REC | why not done here | |---|---| | **REC2** (first-class metric emit in both WASM SDKs) | **Actively owned** by the `add-service-monitoring-sdk-parity` effort in `serviceradar-sdk-go` / `serviceradar-sdk-rust` — both repos already carry `feat: add first-class telemetry emission` commits + uncommitted work. Touching them would collide. | | **REC5** (single point-identity recipe; converge `otel_metric_points` recipe-v2 vs `TimeseriesSeriesKey` PKs) | Dual-table PK migration gated on **open question #4** (which recipe wins). Needs an explicit decision + sequenced migration, not a small PR. | | **REC6** (kind/temporality-aware anomaly consumer; rate counters; histogram p50/p95) | `anomaly_detection/**` is the concurrent agent's domain — landed as the compact evaluator (#3792) and counter/cgroup specs (#3793, #3789/#3790). Counter normalization already shipped in #3795. | | **REC7b** (migrate agent sysmon/snmp/icmp/mtr/sweep producers onto `metricpoint`) | Ordered **LAST** for good reason: depends on REC2 (SDK shape), REC5 (point identity), and REC6 (consumer). It's the cross-cutting COLLAPSE that rewires agent→gateway→core→anomaly atomically — not isolatable into a small reviewable PR until the above land. | | **REC8** (addon raw-OTLP one-lane vs two-lane) | Pure design decision (**open question #2**): retire `otel.metrics.raw` via a decode-and-republish shim, or keep it as the high-fidelity express lane. | **Net:** the additive contract groundwork (REC1/3/4/7a/7e/9/10), all migration-era debt deletion, and the double-forward fix are done and verified. What remains is the coordinated producer migration (REC7b) which is intentionally blocked behind the SDK parity effort (REC2), the point-identity decision (REC5), and the anomaly-consumer upgrade (REC6) — all either in flight on other branches or awaiting an open-question decision.
Author
Owner

Implementation status ledger (additive contract work)

Shipped a stack of small, independently-reviewable PRs covering every DELETE-list item and every isolated recommendation. Each is base staging unless noted.

Tech-debt DELETE list — complete (4/4)

item PR
Remove serviceradar.sysmon.shadow.v1 acceptance (metrics.ex) #3810
Remove flag-gated dead maybe_insert_metrics + :plugin_result_direct_metrics_enabled #3812
Remove vestigial no-op router handlers (handle_sysmon_metrics/handle_snmp_metrics/acknowledge_metrics_cutover + status_handler dispatch) #3814 (stacked on #3813)
Collapse AGENT_GATEWAY_{SYSMON,SNMP}_METRICS_SHADOW_ENABLED env + helm *MetricsShadowEnabled aliases to the single *_METRICS_ENABLED flag #3815

Recommendations — isolated items shipped

REC what PR
REC1 carry OTLP-grade v2 semantics (schema_version 1→2, kind/temporality/resource scaffolding) in the plugin metric envelope #3805
REC3 record whether plugin metric_type was producer-declared vs regex-inferred #3808
REC4 dedup the metrics JetStream stream via Nats-Msg-Id = ingress_id + duplicate_window #3804
REC7a shared go/pkg/metricpoint serviceradar.metric.v1 builder for producers #3809
REC7e make the gateway SNMP interface-metric allowlist configurable (was a 2-OID hardcode) #3807
REC9 reject mis-bucketed metric/OCSF payloads (guardrails) #3803
REC10 surface silent metric_type→gauge coercion + dropped non-numeric plugin metrics via telemetry #3806, #3802

The "double work" / re-publish smell (TL;DR defect #1)

what PR
Gateway stops forwarding sysmon-metrics/snmp-metrics statuses to core (the forward only hit the acknowledge_metrics_cutover no-op) — publishes directly to JetStream instead #3813

Remaining RECs — owned elsewhere or design-gated (NOT started, by design)

REC why not done here
REC2 (first-class metric emit in both WASM SDKs) Actively owned by the add-service-monitoring-sdk-parity effort in serviceradar-sdk-go / serviceradar-sdk-rust — both repos already carry feat: add first-class telemetry emission commits + uncommitted work. Touching them would collide.
REC5 (single point-identity recipe; converge otel_metric_points recipe-v2 vs TimeseriesSeriesKey PKs) Dual-table PK migration gated on open question #4 (which recipe wins). Needs an explicit decision + sequenced migration, not a small PR.
REC6 (kind/temporality-aware anomaly consumer; rate counters; histogram p50/p95) anomaly_detection/** is the concurrent agent's domain — landed as the compact evaluator (#3792) and counter/cgroup specs (#3793, #3789/#3790). Counter normalization already shipped in #3795.
REC7b (migrate agent sysmon/snmp/icmp/mtr/sweep producers onto metricpoint) Ordered LAST for good reason: depends on REC2 (SDK shape), REC5 (point identity), and REC6 (consumer). It's the cross-cutting COLLAPSE that rewires agent→gateway→core→anomaly atomically — not isolatable into a small reviewable PR until the above land.
REC8 (addon raw-OTLP one-lane vs two-lane) Pure design decision (open question #2): retire otel.metrics.raw via a decode-and-republish shim, or keep it as the high-fidelity express lane.

Net: the additive contract groundwork (REC1/3/4/7a/7e/9/10), all migration-era debt deletion, and the double-forward fix are done and verified. What remains is the coordinated producer migration (REC7b) which is intentionally blocked behind the SDK parity effort (REC2), the point-identity decision (REC5), and the anomaly-consumer upgrade (REC6) — all either in flight on other branches or awaiting an open-question decision.

## Implementation status ledger (additive contract work) Shipped a stack of small, independently-reviewable PRs covering every DELETE-list item and every isolated recommendation. Each is base `staging` unless noted. ### ✅ Tech-debt DELETE list — complete (4/4) | item | PR | |---|---| | Remove `serviceradar.sysmon.shadow.v1` acceptance (`metrics.ex`) | **#3810** | | Remove flag-gated dead `maybe_insert_metrics` + `:plugin_result_direct_metrics_enabled` | **#3812** | | Remove vestigial no-op router handlers (`handle_sysmon_metrics`/`handle_snmp_metrics`/`acknowledge_metrics_cutover` + `status_handler` dispatch) | **#3814** *(stacked on #3813)* | | Collapse `AGENT_GATEWAY_{SYSMON,SNMP}_METRICS_SHADOW_ENABLED` env + helm `*MetricsShadowEnabled` aliases to the single `*_METRICS_ENABLED` flag | **#3815** | ### ✅ Recommendations — isolated items shipped | REC | what | PR | |---|---|---| | REC1 | carry OTLP-grade v2 semantics (`schema_version 1→2`, kind/temporality/resource scaffolding) in the plugin metric envelope | **#3805** | | REC3 | record whether plugin `metric_type` was producer-declared vs regex-inferred | **#3808** | | REC4 | dedup the `metrics` JetStream stream via `Nats-Msg-Id` = `ingress_id` + `duplicate_window` | **#3804** | | REC7a | shared `go/pkg/metricpoint` `serviceradar.metric.v1` builder for producers | **#3809** | | REC7e | make the gateway SNMP interface-metric allowlist configurable (was a 2-OID hardcode) | **#3807** | | REC9 | reject mis-bucketed metric/OCSF payloads (guardrails) | **#3803** | | REC10 | surface silent `metric_type→gauge` coercion + dropped non-numeric plugin metrics via telemetry | **#3806**, **#3802** | ### ✅ The "double work" / re-publish smell (TL;DR defect #1) | what | PR | |---|---| | Gateway stops forwarding `sysmon-metrics`/`snmp-metrics` statuses to core (the forward only hit the `acknowledge_metrics_cutover` no-op) — publishes directly to JetStream instead | **#3813** | ### ⏳ Remaining RECs — owned elsewhere or design-gated (NOT started, by design) | REC | why not done here | |---|---| | **REC2** (first-class metric emit in both WASM SDKs) | **Actively owned** by the `add-service-monitoring-sdk-parity` effort in `serviceradar-sdk-go` / `serviceradar-sdk-rust` — both repos already carry `feat: add first-class telemetry emission` commits + uncommitted work. Touching them would collide. | | **REC5** (single point-identity recipe; converge `otel_metric_points` recipe-v2 vs `TimeseriesSeriesKey` PKs) | Dual-table PK migration gated on **open question #4** (which recipe wins). Needs an explicit decision + sequenced migration, not a small PR. | | **REC6** (kind/temporality-aware anomaly consumer; rate counters; histogram p50/p95) | `anomaly_detection/**` is the concurrent agent's domain — landed as the compact evaluator (#3792) and counter/cgroup specs (#3793, #3789/#3790). Counter normalization already shipped in #3795. | | **REC7b** (migrate agent sysmon/snmp/icmp/mtr/sweep producers onto `metricpoint`) | Ordered **LAST** for good reason: depends on REC2 (SDK shape), REC5 (point identity), and REC6 (consumer). It's the cross-cutting COLLAPSE that rewires agent→gateway→core→anomaly atomically — not isolatable into a small reviewable PR until the above land. | | **REC8** (addon raw-OTLP one-lane vs two-lane) | Pure design decision (**open question #2**): retire `otel.metrics.raw` via a decode-and-republish shim, or keep it as the high-fidelity express lane. | **Net:** the additive contract groundwork (REC1/3/4/7a/7e/9/10), all migration-era debt deletion, and the double-forward fix are done and verified. What remains is the coordinated producer migration (REC7b) which is intentionally blocked behind the SDK parity effort (REC2), the point-identity decision (REC5), and the anomaly-consumer upgrade (REC6) — all either in flight on other branches or awaiting an open-question decision.
Author
Owner

Re-scoped against current staging + the OpenSpec set: #3788 is already decomposed and partly landed — it does not need a fresh from-scratch implementation. A proposal drafted for this pass surfaced that the work has moved well past this issue's snapshot.

Already decomposed across existing OpenSpec changes

  • add-protobuf-metric-envelope — ✓ Complete (landed). The binary serviceradar.metric.v1 wire format ships: elixir/serviceradar_core/lib/serviceradar/proto/metric/v1/metric.pb.ex + observability/metric_envelope.ex (its device_resolver fixes the per-device collapse). This is a hard cutover.
  • update-anomaly-evaluation-cadence (7/25 tasks). Its "Decision 0" already defines the serviceradar.metric.v1 field contract and ADDs Canonical Metric Signal Contract / First-Class Metric Path / Metric Stream Idempotency / Metric Schema Guardrails to the ingestion-routing capability — the largest overlap with this issue.
  • add-monotonic-counter-metric-semantics (8/16 tasks). Owns the kind/temporality/counter slice — the "a cumulative counter fed raw into z-sigma looks like a ramp" defect.
  • Downstream consumers: add-cgroup-v2-tenant-metrics (0/15), add-delta-metrics-lakehouse (1/22). Adjacent: add-bulk-payload-pipeline (0/21 — the gateway forward path).

The real open question is architectural, not "go implement #3788"

There is a philosophy clash between the additive schema_version 1→2 path described here (and in update-anomaly-evaluation-cadence) and the hard cutover already shipped in add-protobuf-metric-envelope. That needs a human decision before any further metric-contract work — landing a 4th parallel effort would only deepen the conflict.

Still genuinely open (regardless of which philosophy wins)

  • Finishing update-anomaly-evaluation-cadence + add-monotonic-counter-metric-semantics.
  • The cross-repo producer emit paths — first-class metric emit in ~/src/serviceradar-sdk-go, ~/src/serviceradar-sdk-rust, and the Go agent (sysmon/snmp/icmp/mtr/sweep/rperf), incl. the currently-dropped sysmon Network[] and SNMP non-octet OIDs.
  • The series-identity unification + single-sink migration (the 3-sinks → 1 part: cpu_metrics / timeseries_metrics / otel_metric_points).
  • The legacy sysmon/snmp reject-handler removal (the tail of #3801).

Recommendation

Treat #3788 as the umbrella that has been decomposed — either close it linking the changes above, or keep it as the tracking umbrella, but do not open a 4th parallel proposal. The next concrete step is the human consolidation call (additive-vs-cutover) plus finishing the two in-flight changes. (A consolidation-mapping draft exists locally and can be attached if useful.)

Re-scoped against current `staging` + the OpenSpec set: **#3788 is already decomposed and partly landed — it does not need a fresh from-scratch implementation.** A proposal drafted for this pass surfaced that the work has moved well past this issue's snapshot. ### Already decomposed across existing OpenSpec changes - **`add-protobuf-metric-envelope` — ✓ Complete (landed).** The binary `serviceradar.metric.v1` wire format ships: `elixir/serviceradar_core/lib/serviceradar/proto/metric/v1/metric.pb.ex` + `observability/metric_envelope.ex` (its `device_resolver` fixes the per-device collapse). This is a **hard cutover**. - **`update-anomaly-evaluation-cadence` (7/25 tasks).** Its "Decision 0" already defines the `serviceradar.metric.v1` field contract and ADDs `Canonical Metric Signal Contract` / `First-Class Metric Path` / `Metric Stream Idempotency` / `Metric Schema Guardrails` to the `ingestion-routing` capability — the largest overlap with this issue. - **`add-monotonic-counter-metric-semantics` (8/16 tasks).** Owns the `kind`/`temporality`/counter slice — the "a cumulative counter fed raw into z-sigma looks like a ramp" defect. - Downstream consumers: `add-cgroup-v2-tenant-metrics` (0/15), `add-delta-metrics-lakehouse` (1/22). Adjacent: `add-bulk-payload-pipeline` (0/21 — the gateway forward path). ### The real open question is architectural, not "go implement #3788" There is a philosophy clash between the **additive `schema_version 1→2`** path described here (and in `update-anomaly-evaluation-cadence`) and the **hard cutover** already shipped in `add-protobuf-metric-envelope`. That needs a human decision before any further metric-contract work — landing a 4th parallel effort would only deepen the conflict. ### Still genuinely open (regardless of which philosophy wins) - Finishing `update-anomaly-evaluation-cadence` + `add-monotonic-counter-metric-semantics`. - The **cross-repo producer emit paths** — first-class metric emit in `~/src/serviceradar-sdk-go`, `~/src/serviceradar-sdk-rust`, and the Go agent (sysmon/snmp/icmp/mtr/sweep/rperf), incl. the currently-dropped sysmon `Network[]` and SNMP non-octet OIDs. - The series-identity unification + single-sink migration (the 3-sinks → 1 part: `cpu_metrics` / `timeseries_metrics` / `otel_metric_points`). - The legacy sysmon/snmp reject-handler removal (the tail of #3801). ### Recommendation Treat #3788 as the umbrella that has been decomposed — either **close it** linking the changes above, or keep it as the tracking umbrella, but do **not** open a 4th parallel proposal. The next concrete step is the human consolidation call (**additive-vs-cutover**) plus finishing the two in-flight changes. (A consolidation-mapping draft exists locally and can be attached if useful.)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
carverauto/serviceradar#3788
No description provided.