docs.fermihdi.io — observability.md

/ › hd › 2.0.5 › md

Download observability.mdView raw

Observability: metrics, logs and traces

This page documents what each node type emits — Prometheus metrics, structured logs, and OpenTelemetry traces — and how the DMS's OTEL Collector routes them to a destination you choose. For configuring the collector's exporters, see the DMS WebUX guide's OTEL Collector section.

Every node in an HD instance does two independent things with its telemetry:

  1. Serves its own /metrics and /healthz//livez//readyz directly, on its own observability port (8080 by default, OBS_PORT where the service honours the override — see the per-node notes below, since not every service does). A tool that scrapes nodes directly reads this.
  2. Pushes metrics, logs and traces to the DMS's OTEL Collector over OTLP (gRPC 4317 or HTTP 4318, the OpenTelemetry standard ports). The collector then re-exports what an administrator has configured — see The OTEL Collector below, including its own new pull-based Prometheus endpoint.

These are two views of the same instruments on the C++ nodes (SCC, SN, BloomRF) — one render function serves both /metrics and the OTLP push, so they cannot disagree. On the Go services, the two paths are typically implemented separately (a promhttp handler for /metrics, a distinct OTLP trace exporter for traces), so the two can and sometimes do carry different information — noted per service below.


The OTEL Collector

The collector is one process per instance, run under DMS-Core's management (config pushed over OpAMP). It always receives; what it does with what it receives is entirely admin-configured.

Always on, every deployment:

Port Purpose
4317 OTLP/gRPC receiver — every node's default push target
4318 OTLP/HTTP receiver — the opt-in alternative, and what the WebUX browser client's telemetry relay uses
13133 health_check extension (GET /health)

Admin-configured exporters (DMS → Telemetry page, or the /v1/otel/config API), each a distinct destination:

Exporter Direction Notes
OTLP/HTTP, OTLP/gRPC push any OpenTelemetry-compatible backend
Syslog (RFC 5424) push logs only, TCP or UDP, TLS on 6514 by default
Prometheus (remote write) push admin supplies the destination's remote-write ingest URL
Prometheus (scrape) pull opens a fixed :9464/metrics on the collector itself for an external Prometheus to scrape. At most one may be enabled — see below
Kafka push OTLP-proto or OTLP-JSON encoded, per-signal topic

With no exporters configured, the collector still receives everything (every node reports to it regardless) but forwards nothing — a debug sink at basic verbosity, so telemetry doesn't back up.

Prometheus (scrape) exporter

Every other exporter is a push: the collector dials out to a URL you supply. The scrape exporter is the one inbound exception, and it works differently because of that:


Logging format

Every node in an HD instance — Message Bus Ingester, SCC, SN, BloomRF, the DMS suite, the Query Proxy, the SDK (and therefore any client built on it), hd_mcp, hd_graphql, hd_sql, and Data Explore — emits the same JSON log envelope. This is a real, enforced standard (.agents/rules/observability-standard.md), not just a shared convention that happened to converge: two independent components (the process writing a line, and the DMS Collector's exporter reading it back out of the ring on the C++ nodes) depend on it staying exact.

{
  "logging_format_version": "2.0.0",
  "timestamp": 1786656385,
  "ts_ms": 1786656385433,
  "ts_us": 1786656385433025,
  "level": "info",
  "service": { "name": "hd_storage_node", "version": "1.0.0", "environment": "production", "node_name": "sn-3" },
  "event": { "action": "sn_rx_first", "category": "ingest_ops", "outcome": "success" },
  "opt_in": false,
  "tracing": { "trace_id": "0af7651916cd43dd8448eb211c80319c", "span_id": "b7ad6b7169203331" },
  "message": "[INGEST] sn received first record of batch",
  "data": { "batch_id": "0000…6a7e4d9c", "records": "60000" }
}

Every field is always present except tracing, which appears only when the line has real trace context to attach — never fabricated locally, never emitted with empty strings. opt_in is likewise always present, always at the top level: never nested under a fermihdi key, never omitted when false.

Facts worth knowing before treating this as a wire contract:

How tracing gets populated differs by service, and that's expected. SCC/SN/BloomRF/the Ingester and the DMS suite (dms-core, dms-ca, dms-opa-engine) generate real trace context locally — the DMS suite by creating a matching span per log call (the log message becomes the span name, purely to attach trace_id/span_id; there's no separate tracing step), the storage cluster by propagating the query's root span. hd_proxy does the same from its own genuine per-operation spans (ServeHTTP, PBAC_Evaluation, SCC_Fanout — see its own section below). hd_sql, hd_graphql, hd_mcp, and the SDK carry no OpenTelemetry SDK at all, so none of them generate trace context — a tracing block only appears in their logs when a caller explicitly hands one in (e.g. from an inbound request header, or OTEL_TRACE_ID/OTEL_SPAN_ID for the SDK), never generated locally.

Whether a log line reaches the DMS Collector is a separate question from its shape, and doesn't change with this schema. Two groups:


Metrics, logs and traces by node

Storage Cluster Controller (SCC)

Metrics (/metrics, port 8080, hardcoded — SCC's HTTP listener does not honour the OBS_PORT override):

Metric Type What it counts
fermihdi_ingest_system_*, fermihdi_ingest_scc_*, fermihdi_query_scc_fanout_* count/sum/min/max (4 series each) Periodic snapshot rollups of the ingest and fanout pipelines
fermihdi_scc_records_processed counter Records processed
fermihdi_scc_packets_ingested counter Packets ingested
fermihdi_scc_wire_bytes counter Bytes received over the wire — the bytes_received mandatory metric
fermihdi_scc_record_bytes counter Record payload bytes, wire overhead excluded
fermihdi_scc_fhdwp_overhead_bytes counter FHDWP protocol overhead bytes

queries_processed, records_scanned/records_selected and cpu_utilization/disk_utilization have no SCC-side equivalent — the SCC fans out and hashes; it does not itself scan or select records (that's the SN's job) and does not publish host-resource gauges.

A large additional set of dev-only metrics (queue depths, reactor busy/idle/steal time, NIC hardware counters, latency percentiles, memory-bandwidth/PMU samples) exists under FERMIHDI_DEV_METRICS/ FERMIHDI_DEV_METRICS_HOTPATH compile flags. Both default OFF in Release/distroless/rock builds — a production SCC image ships none of these; they exist for development-time bottleneck diagnosis only.

Logs: event.category values actually used include ingest_ops (scc_rx_first, scc_hash_closed, scc_rx_last) and query_ops (scc_q_trace, carrying a tracing block).

Traces: creates the query's root span (scc.query.dispatch) when a query is dispatched, with attributes for batch-ID count, QoS, and the SN sessions fanned out to. The trace ID is built from the issuing client's query_id. The ingest/data path creates no spans by design.

Health: /healthz, /livez, /readyz alongside /metrics, same port, served by a minimal raw-socket HTTP server in the OP (supervisor) process — the same process that also holds the metrics registry, so /metrics and the OTLP push read one shared set of instruments.

Storage Node (SN)

Metrics (/metrics, port 8080, hardcoded, same caveat as SCC):

Metric Type What it counts
fermihdi_ingest_sn_*, fermihdi_query_sn_* count/sum/min/max Periodic ingest/query rollups
fermihdi_query_records_read counter Records read during query execution — records_scanned
fermihdi_query_records_selected counter Records matching a query's filter — records_selected
fermihdi_sn_records_stored counter Records committed to storage — records_stored
fermihdi_sn_bytes_received counter bytes_received
fermihdi_sn_bytes_sent counter Query result bytes streamed back — bytes_sent
fermihdi_kv_puts/gets/deletes/cleanups/persistence_writes/backpressure_waits counters KV-store operation counts
fermihdi_kv_memory_bytes gauge memory_usage
fermihdi_kv_disk_bytes gauge disk_utilization (byte count, not a percentage)

Of the eight mandatory metrics in the observability rule, the SN is the one node that actually carries all of them (under the names above); cpu_utilization only exists as a dev-only PMU sample (FERMIHDI_DEV_METRICS), not in a production build. The same large dev-only metric set described for SCC exists here too (queue depths per pipeline stage, NIC counters, storage-device usage, latency percentiles, saturation counts) — same OFF-by-default gating.

Logs: ingest_ops (sn_validate_start/sn_validate_done, carrying data.batch_id) and query_ops (sn_q_trace, carrying a tracing block plus data.query_id, plus a family of sn_read-style actions on the read path).

Traces: continues the SCC's root span as a child span (sn.query.dispatch) via the inbound W3C traceparent, with query_id/batch-ID-count/QoS attributes. No spans on the ingest path, consistent with SCC.

Health: same shape as SCC — /healthz//livez//readyz//metrics on 8080 from the OP process; unhealthy means a child process died or missed a health-ping deadline (FERMIHDI_HEALTH_PONG_DEADLINE_MS, default 15s) — log volume/severity does not by itself affect health status.

BloomRF filter node

Metrics (/metrics, port 8080, hardcoded):

Metric Reality
fermihdi_bloomrf_filters_{total,verified,pending,rejected,evicted,bytes} Filter-store state: how many filters held, verified against the SCC, awaiting a verdict, hash-mismatched, evicted by retention, and their memory footprint
fermihdi_bloomrf_batches_closed, fermihdi_bloomrf_records_seen Data-plane intake counts
fermihdi_bloomrf_verify_{verified,mismatch,not_found} Verdicts from the SCC's batch-verification stream
fermihdi_bloomrf_lookups, _lookup_candidates, _lookup_evaluated, _lookup_excluded, _lookup_excluded_zone_map, _lookup_excluded_bloomrf, _lookup_no_filter Per-query FilterBatches RPC breakdown: how many candidate batches were asked about, how many a filter existed for, and how many were excluded (split by whether a cheap zone-map check or the BloomRF itself did the excluding)

All of the above are declared # TYPE ... gauge in the exporter, even the ones that only ever increase (lookups, batches_closed, the verify_* and lookup_* counts). If you graph these, treat them as you would a counter (use them under rate()/increase()-style reasoning conceptually) rather than expecting a _total suffix or a PromQL counter type — the Prometheus type metadata itself says gauge.

BloomRF has no direct equivalent of queries_processed (it pre-filters, it doesn't execute queries), records_stored/records_scanned (it folds records into filters rather than storing or scanning them — closest analog is records_seen), or bytes_received/bytes_sent/cpu_utilization/ disk_utilization (none tracked; filter memory footprint is the closest via filters_bytes).

Logs: rich event set across startup (config received, filter server bound, filters reloaded from persistence, DMS registration), config (schema applied/changed — a schema-generation change discards existing filters), dataplane (channel state), ingest (filter_corrupt on a bad batch filter), verify (verification stream lifecycle and verdicts), and query_ops (bloomrf_filter, a per-RPC measurement event carrying candidate/evaluated/excluded counts and elapsed microseconds).

Traces: BloomRF sits in the query path between the SCC and its own FilterBatches RPC, and receives the SCC's trace context over gRPC metadata, but does not itself call StartChildSpan to attach a span object — it carries trace correlation only through the tracing block on its query_ops log lines, not as a span in the trace view itself.

Persistence: filter reload/discard state (filters_reloaded vs filters_not_persistent) is logged, not metriced — check /healthz or the startup log for filter-store health, not a Prometheus series.

Message Bus Ingester

Logs reach the DMS Collector automatically, the same as SCC/SN/BloomRF — it uses the same flat 2.0.0 shape and the same always-on logging path (see Logging format above).

Metrics: no /metrics endpoint by default. The observability server this service could run is disabled by default. Internally it does keep records_processed_total, packets_sent_total, and send_errors_total as in-memory counters, but these are only ever written into structured log lines (event.category: "application"), never exposed for scraping unless an operator changes that default.

Health: a --healthcheck CLI flag (not an always-running HTTP listener) connects to 127.0.0.1:<healthcheck_port> (default 8080) and expects a JSON {"status":"ok","timestamp":...} — this is what a container orchestrator's health probe should invoke, per the CLI-healthcheck convention documented in .agents/rules/observability-standard.md.

Traces: none. The ingester is a pure ingest-path component; per the control-plane-only tracing design, it creates and propagates no spans.

Logs: event.category: "application" throughout, actions log, telemetry, metric (a periodic uptime line, not a real metric), and batch (batch-ID rollover). config.opt_in (env FERMIHDI_OPT_IN or --opt-in) marks records for FermiHDI sharing the same way every other service's top-level opt_in field does.

DMS suite

The three main DMS Go binaries — dms-core, dms-ca, dms-opa-engine — share the same observability implementation, so they behave identically: /healthz, /livez, /readyz and /metrics on OBS_PORT (default 8080, and this one genuinely honours the env override). Two more DMS binaries do not:

Metrics: dms-core (and only dms-core) declares six Prometheus series matching the mandatory-metric names — queries_processed_total, records_stored_total, records_scanned_total, bytes_received_total, bytes_sent_total, memory_usage_bytes — but none are incremented or set; they exist on /metrics and read zero. DMS sits on the control plane, not the data path, so most of those names don't apply to it in the first place. For DMS-specific activity — node registrations, licence checks/issuance, OTEL config pushes, OpAMP connection count — there is no dedicated Prometheus series: go to the logs (below) for that activity, or the WebUX's Telemetry page, which reads the OpAMP supervisor's live connection count directly rather than through a Prometheus series.

Logs (logging_format_version: "2.0.0" — see Logging format above): representative event.category/event.action pairs actually logged include security/renew_cert, security/crl_check, security/revocation, lifecycle/node_decommission, lifecycle/node_provision, storage_cluster/capacity_degraded, topology/graceful_offline, topology/shutdown_push, licensing/apply, config/schema_update (dms-core), and security/sign, security/license_apply, security/ica_install (dms-ca).

Traces: dms-core, dms-ca and dms-opa-engine all export traces, always-sampled, to the endpoint configured via OTEL_EXPORTER_OTLP_ENDPOINT. There are no explicit spans around specific operations (licence issuance, node registration, config push); a span is instead created implicitly, once, per log call — the log message becomes the span name, and its purpose is purely to attach trace_id/span_id to that one log record, not to model an operation's duration. dms-opa-adapter and dms-crash-vault create no traces.

DMS-WebUX (browser-side): opt-in, two-layer gate — the deployment's own opt-in decision (DMS-Core's /v1/telemetry/optin, cached 15s) and a per-operator localStorage override. When on, @opentelemetry/sdk-trace-web instruments page loads and fetch() calls and posts spans to a same-origin relay (/api/telemetry/v1/traces, 1 MiB/batch cap, a scrubbing step strips user-agent/query-strings/headers first), which forwards them server-side to the deployment's own collector. The browser never talks to the collector or to FermiHDI directly, and the relay enforces the opt-in decision — a client cannot bypass it by editing browser storage.

Query Proxy (hd_proxy)

Optional: deploy it when you want a single mTLS/PBAC front door between clients and your Storage Clusters — see the front door page.

Metrics (/metrics, port 8080): a minimal set — fermihdi_queries_processed_total, fermihdi_bytes_received_total, fermihdi_bytes_sent_total (all counters). No records-scanned/selected, no PBAC-decision counter, no resource gauges.

Traces: real, operation-scoped spans, exported over OTLP to the DMS Collector — the inbound request handler (ServeHTTP), the OPA policy decision (PBAC_Evaluation), and the broadcast to Storage Cluster Controllers (SCC_Fanout) each get their own span. This is genuine per-operation instrumentation, unlike the incidental per-log-line span the DMS suite creates.

Logs: 2.0.0-shaped JSON, stdout only — like hd_sql/hd_graphql/ hd_mcp, these do not reach the DMS Collector. Unlike those three, hd_proxy now attaches a real tracing block on log lines emitted from inside its ServeHTTP/PBAC_Evaluation/SCC_Fanout spans — a genuine improvement: its own log lines can now be correlated to the traces those same spans send to the DMS Collector, even though the log lines themselves stay local.

Query services

These sit behind the Proxy on the client-facing side, each exposing a different wire protocol onto the same query engine. First, the naming: hd_sql, hd_graphql, hd_mcp and hd_data_explore are four distinct services, not overlapping ones — an empty hd_graphql/ subdirectory inside the hd_sql repo is a reserved/unused path and unrelated to the top-level hd_graphql repo. All four are optional: deploy the ones your clients need.

hd_sql, hd_graphql, and hd_mcp write their logs locally only — none of the three are wired into the DMS Collector (see Logging format above). None of them carry an OpenTelemetry SDK, so they never generate real trace spans; the tracing block in their logs only ever appears when a caller hands them a trace/span ID it already has.

Service /metrics? Mandatory metrics Logging Tracing
hd_sql Yes, port 8080 Full set: hd_sql_queries_processed_total, hd_sql_records_scanned_total, hd_sql_records_selected_total, hd_sql_records_stored_total (defined but never incremented — read-only server), hd_sql_bytes_received_total, hd_sql_bytes_sent_total, plus hd_sql_connections (gauge, by protocol), hd_sql_query_duration_seconds (histogram), hd_sql_pushdown_filters_total, hd_sql_batches_requested_total/_confirmed_total 2.0.0, caller-supplied tracing only None — no spans generated anywhere in the service
hd_graphql Yes, port 8083 None defined — only whatever the Prometheus client library's default process collectors provide 2.0.0, caller-supplied tracing only None
hd_mcp Yes, port 8080 Same as hd_graphql: process collectors only, no explicit metrics 2.0.0, same shape and behaviour as hd_graphql None
hd_data_explore No N/A — frontend only Server-side: Sentry only, 2.0.0-shaped local logs are not yet wired here. Browser-side: 2.0.0-shaped structured logs to the console, relayed to the DMS Collector when opted in Browser-side real OpenTelemetry spans (page loads, fetch() calls), relayed to the DMS Collector when opted in — see below. Server-side: Sentry performance tracing (100% sample rate), a separate pipeline

If you're building a dashboard across "all query services," only hd_sql has real per-query metrics; treat the others' /metrics output as process-health only (or, for hd_data_explore, not present at all).

Data Explore (browser-side): opt-in, following the same shape as DMS-WebUX's telemetry relay but with a simpler gate — FERMIHDI_OPT_IN in this service's own server environment, not a DMS-Core API call (Data Explore has no mTLS client identity of its own to call DMS-Core with). When on, @opentelemetry/sdk-trace-web instruments page loads and fetch() calls and posts spans to a same-origin relay (/api/telemetry/v1/traces, 1 MiB/batch cap, the same scrubbing convention as DMS-WebUX's relay: no user-agent, query strings, or headers), which forwards them server-side to the deployment's own collector — the browser never talks to the collector directly. A structuredLog() helper emits the same 2.0.0 JSON envelope to the browser console.

Data Explore crash reporting: window.onerror/onunhandledrejection build a Sentry-format envelope (the crash vault's intake wire format, distinct from the logging_format_version JSON schema by design — see hd_dms/docs/crash_vault_openapi.yaml) and POST it to a second relay (/api/crash-report), which forwards it to the deployment's crash vault. Unlike the traces relay, this one is not gated by opt-in: the vault's intake always accepts and holds a report; opt-in only controls whether the vault later forwards a stack-only tier to FermiHDI on its own, a decision the vault makes, not this relay. This is genuinely new: no browser- originated crash-reporting path existed anywhere in this product before it.


Mandatory-metric coverage, by service

Before wiring up an alert against the "mandatory metrics" list in .agents/rules/observability-standard.md, check whether the service you're targeting actually publishes that metric — coverage varies by design (a control-plane service like DMS has no natural records_scanned, for example), not by omission everywhere:

Download observability.mdView raw