observability

package
v1.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: MIT Imports: 48 Imported by: 0

Documentation

Overview

Package observability — Auth metric bag (Plan 04-06 GREEN per MET-15 / GAP-6).

Owns one family per ADR §2.3 + CONTEXT GAP-6:

nornicdb_auth_attempts_total{result, protocol}

Closed enums (CONTEXT D-05e):

result   ∈ AllowedAuthResults    = {success, failure, denied}
protocol ∈ AllowedAuthProtocols  = {bolt, http, grpc}

Cardinality ceiling = |result| × |protocol| = 3 × 3 = 9 (RESEARCH §Q11). No tenant flag — auth is per-process global, not per-database (CONTEXT MET-21 omits `database` for auth; surfacing tenant identity on an unauthenticated counter would itself be a leak).

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `user`, `user_id`, `email`, `ip` are all in ForbiddenLabels and the registration helper panics on attempt — defense in depth against PII surfacing through label values. The closed `result` enum prevents callers from passing a raw error message as the result label.

MET-25 hot path: callers cache `Attempts.WithLabelValues(result, protocol)` in a local var or struct field; the per-attempt observation pays only an atomic add. The cardinality ceiling is so low (9) that we do not even expose a Bind helper — direct WithLabelValues at the call site is fine.

D-02d leaf-package boundary: pkg/observability never imports pkg/auth. The bag is constructed at cmd/nornicdb startup and injected into:

  • pkg/bolt/server.SetAuthMetrics (Plan 04-02 added the call site)
  • pkg/auth.Authenticator.SetMetrics (HTTP path; this plan wires it)
  • gRPC interceptor (TBD by integration site)

Package observability — Bolt metric bag (Plan 04-02 GREEN).

Owns six families per MET-07 + ADR §2.3:

nornicdb_bolt_connections_active
nornicdb_bolt_connections_total{result}
nornicdb_bolt_session_duration_seconds
nornicdb_bolt_messages_total{op, result}
nornicdb_bolt_message_duration_seconds{op}
nornicdb_bolt_packstream_decode_errors_total{reason}

Closed enums (CONTEXT D-11 / D-11a / D-11c):

result   ∈ AllowedBoltResults     = {success, error, timeout}
op       ∈ AllowedBoltOps         = {hello, run, pull, begin, commit,
                                     discard, reset, goodbye, route,
                                     ack_failure}
reason   ∈ AllowedPackstreamReasons =
                                     {truncated, invalid_marker,
                                      wrong_type, oversize}

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `op`, `result`, and `reason` are NOT in the forbidden list — closed-enum string literals at the call site enforce cardinality. The reason enum is the highest-risk axis (free-form `err.Error()` would be a cardinality bomb); CONTEXT D-11c locks it to four values, classified at the packstream decode boundary via `reasonFromError(err)`.

PULL chunks NOT separately observed (CONTEXT D-11b). Chunk timing rolls up into the parent PULL `message_duration_seconds`. Aligns with Phase 8 TRC-13 ("PULL chunks do not span") — same rationale: per-chunk observation taxes the streaming hot path with no SRE alerting benefit.

No `database` label on Bolt subsystem: sessions/messages cross databases via USE clauses; per-DB instrumentation lives at the Cypher subsystem. CONTEXT D-08 bool not threaded through this constructor.

Package observability — Cache + Runtime metric bag (Plan 04-01 GREEN).

Owns six families per MET-16 + ADR §2.3:

nornicdb_cache_hits_total{cache}
nornicdb_cache_misses_total{cache}
nornicdb_cache_size_bytes{cache}
nornicdb_cache_evictions_total{cache, reason}
nornicdb_process_uptime_seconds       (GaugeFunc; time.Since(start).Seconds())
nornicdb_build_info                   (GaugeFunc; constant 1 + const labels)

Closed enums (CONTEXT D-12, D-12b):

cache  ∈ AllowedCacheNames        = {query_result, schema, label, node_lookup}
reason ∈ AllowedEvictionReasons   = {lru, ttl, capacity, manual}

RISK-4 carry-forward: if a downstream plan determines a cache name has no actual increment site, prune the slice here AND amend ADR §2.3. Today we keep all four to match the planning contract; the catalog_cache_test.go AssertCardinalityCeiling(4) passes whether 3 or 4 emit (helper has no lower bound — RESEARCH RISK-7 / Plan 04-07 ships the lower-bound test).

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `cache` and `reason` are NOT in the forbidden list — closed-enum string literals at the call site enforce cardinality. Subsystem callers MUST use only AllowedCacheNames / AllowedEvictionReasons values.

Pitfall 1 / RESEARCH RISK-8 mitigation: every GaugeFunc body wraps a defer-recover that returns 0 on panic, preventing a buggy callback from poisoning the entire /metrics scrape (Pitfall 1: shutdown ordering vs. live scrape window — listener drains LAST per OBS-08).

Leaf-package boundary (Phase 1 D-01a / boundary_test.go) preserved: imports limited to stdlib + prometheus/client_golang + pkg/buildinfo. pkg/buildinfo is a leaf utility (it embeds VERSION + carries Commit ldflag vars), not a forbidden subsystem package.

Package observability — Cypher metric bag (Plan 04-03 GREEN).

Owns eleven families per MET-08 + ADR §2.3:

nornicdb_cypher_queries_total{op_type[, database]}
nornicdb_cypher_query_duration_seconds{op_type[, database]}
nornicdb_cypher_planner_duration_seconds{op_type}
nornicdb_cypher_planner_cache_hits_total
nornicdb_cypher_planner_cache_misses_total
nornicdb_cypher_planner_cache_size
nornicdb_cypher_rows_returned{op_type}
nornicdb_cypher_active_transactions
nornicdb_cypher_transaction_conflicts_total{[database]}
nornicdb_cypher_slow_queries_total{[database]}
nornicdb_cypher_slow_query_threshold_seconds   (GaugeFunc; D-15b)

Closed enums (CONTEXT D-04 corrected per RISK-1; D-04b parse_error sixth value):

op_type ∈ AllowedCypherOpTypes = {read, write, schema, admin, fabric, parse_error}

RISK-1 corrected classifier surface — the actual normal-path classifier reads `*QueryInfo` from QueryAnalyzer (see pkg/cypher/op_type.go), NOT a non-existent `plan.Root.Op` field. The closed enum above is the contract; `pkg/cypher/op_type.go::classifyOpType` is the producer; this bag is the consumer. Three observation sites (admin-dispatch, parse-error site, normal-path-after-Analyze) cover all six values.

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `query`, `user`, `user_id`, `ip`, `uuid`, `email` would all be cardinality bombs for Cypher and panic at registration. `op_type` is bounded by the closed enum above; `database` is bounded by the per-tenant-flag axis (D-08); callers MUST NEVER pass raw query text or query_hash as a Prometheus label.

D-12a planner cache locality: planner_cache_hits_total / planner_cache_misses_total / planner_cache_size live HERE under the cypher subsystem, not under the cross-cutting cache subsystem. Planner cache is Cypher-specific. The cross-cutting cache bag (catalog_cache.go) carries a separate `cache_hits_total{cache="query_result"}` mirror that the Cypher query-result cache emits in parallel — both layers see the metric.

MET-25 hot path: subsystems pre-bind via `BindQueryDuration("read", db)` and cache the resulting BoundLatencyObserver in struct fields at constructor time (see pkg/cypher/executor.go). The chokepoint Observe() call funnels through observeWithExemplar — zero exemplar overhead under Phase 1's NeverSample default; lights up automatically at Phase 6's sampler flip with no second-pass migration.

MET-26 / D-15b slow_query_threshold_seconds: registered directly on reg via prometheus.NewGaugeFunc(slowQueryThresholdFn) — NO struct field. The callback wraps a defer-recover that returns 0 on panic per RESEARCH RISK-8 / Pitfall 1. The value reflects cfg.Logging.SlowQueryThreshold (or whatever the injected callback reads) on every scrape — config reload is automatic; no event wiring required.

D-08 forward-compat: NewCypherMetrics(reg, tenantLabelsEnabled bool, slowQueryThresholdFn func() float64) decides label-set shape ONCE at construction. When false (Phase 4 default outside K8s), the `database` label is OMITTED from the labelnames slice — not set to empty string. Phase 5's K8s autodetect flips the bool's default value with no re-registration.

Package observability — Embeddings metric bag (Plan 04-05 GREEN).

Owns six families per MET-12 + ADR §2.3, plus the D-09 FFI panic counter:

nornicdb_embed_queue_depth                                (GaugeFunc; D-15b)
nornicdb_embed_processed_total{provider, model, result, mode}
nornicdb_embed_duration_seconds{provider, model, mode}    (long-tail; MET-05)
nornicdb_embed_cache_hits_total
nornicdb_embed_cache_misses_total
nornicdb_embed_worker_running                             (Set(0)/Set(1) at lifecycle)
nornicdb_embed_ffi_panics_total{mode}                     (D-09 — pkg/embed FFI recovery)

Closed enums (CONTEXT D-06a):

mode     ∈ AllowedEmbedBackends  = {gpu, cpu, cuda, metal, vulkan}
result   ∈ AllowedEmbedResults   = {success, failure, cached}
provider ∈ AllowedEmbedProviders = {ollama, openai, local, other}
model    is open-ish (bounded ceiling 250 per RESEARCH §Q11)

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `embedding_text` is the keystone — embedding *content* MUST NEVER be a label value. The forbidden-label panic at registration prevents anyone from labeling by raw input text. The other ForbiddenLabels (path, query, user, user_id, ip, uuid, trace_id, span_id, email) are not reachable from this subsystem.

MET-25 hot path: per-call observation uses pre-bound observers cached at the call site (see Plan 04-05 wiring + BenchmarkObserve_HotEmbed). The bag's Duration.Bind(provider, model, mode) returns a BoundEmbeddingLatencyObserver that is value-typed and concurrent-safe; callers store it in struct fields per MET-25.

D-15b GaugeFunc panic safety: the queue_depth callback wraps defer-recover that returns 0 on probe panic — concurrent shutdown of the embed queue cannot poison /metrics. Same pattern as Plan 04-04 MVCC GaugeFuncs.

D-15b distinction (worker_running): binary lifecycle state uses `Set(0)` / `Set(1)` at the lifecycle start/stop hook sites, NOT a GaugeFunc — the lifecycle event boundary is the right observation chokepoint.

D-02d leaf-package boundary: pkg/observability never imports pkg/embed or pkg/nornicdb. EmbedProbe is declared HERE; the embedder (or the embed-queue accessor adapter) satisfies it via a thin shim at the cmd/nornicdb wiring site.

D-08 forward-compat: Embed metrics are NOT tenant-tagged. provider, model, and mode are global (not per-database). Per CONTEXT MET-21 omission of embed/runtime/cache from the tenant axis. NewEmbedMetrics therefore omits the tenantLabelsEnabled bool parameter.

D-09 FFI panic counter: ffi_panics_total{mode} is the only counter where pkg/embed observably crashed during a CGo call — incremented from the deferred-recover wrapper in pkg/embed/ffi_recover.go (Plan 04-05-03). Closed mode enum prevents arbitrary panic-class labeling.

Package observability — HTTP metric bag (Plan 04-02 GREEN).

Owns five families per MET-06 + ADR §2.3:

nornicdb_http_requests_total{method, path_template, status_class[, database]}
nornicdb_http_request_duration_seconds{method, path_template, status_class[, database]}
nornicdb_http_in_flight_requests
nornicdb_http_request_body_bytes{method, path_template}
nornicdb_http_response_body_bytes{method, path_template}

Closed enums:

status_class ∈ AllowedStatusClasses = {1xx, 2xx, 3xx, 4xx, 5xx}
method       — bounded by HTTP method enum (GET, POST, PUT, DELETE,
                PATCH, HEAD, OPTIONS, CONNECT) ≈ 8 entries
path_template — open shape but bounded by route table (~15 templates;
                see RESEARCH §Q11 ceiling=1000 for headroom)

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `path` is in ForbiddenLabels; passing the raw URL would panic at registration. The label name `path_template` is NOT forbidden — but values MUST come from `r.Pattern` (Go 1.22+ stdlib ServeMux), NEVER from `r.URL.Path`. The instrumentedMux wrapper in pkg/server/server.go is the SOLE call site (D-03 single chokepoint).

Tenant-flag forward-compat (CONTEXT D-08): `NewHTTPMetrics(reg, tenantLabelsEnabled bool)` decides label-set shape ONCE at construction. When false (Phase 4 default outside K8s), the `database` label is OMITTED from the labelnames slice — not set to empty string. When true (Phase 5 K8s autodetect default), `database` is included. Subsystems use the bool-agnostic `BindRequestDuration` / `BindRequests` helpers; they thread the database arg through but the helper drops it when the bag was constructed with tenantLabelsEnabled=false.

Package observability — Knowledge-policy metric bag.

Owns ten families (see docs/plans/knowledge-policy-observability-plan.md):

nornicdb_knowledge_policy_scored_total{entity_kind,result[, database]}
nornicdb_knowledge_policy_decay_score{entity_kind[, database]}       (sampled 1/32)
nornicdb_knowledge_policy_suppressions_total{entity_kind,reason[, database]}
nornicdb_knowledge_policy_access_flush_batch_rows                     (no tenant label)
nornicdb_knowledge_policy_access_flush_duration_seconds               (no tenant label)
nornicdb_knowledge_policy_access_flush_buffer_fullness                (GaugeFunc; D-15b)
nornicdb_knowledge_policy_on_access_mutations_total{result[, database]}
nornicdb_knowledge_policy_deindex_enqueued_total{entity_kind[, database]}
nornicdb_knowledge_policy_read_filter_dropped_total{entity_kind[, database]}
nornicdb_knowledge_policy_reconcile_total{trigger[, database]}

Closed enums:

entity_kind ∈ AllowedKnowledgePolicyEntityKinds = {node, edge, property}
result      ∈ AllowedKnowledgePolicyScoreResults = {visible, suppressed, no_decay}
reason      ∈ AllowedKnowledgePolicySuppressReasons = {below_threshold, score_floor,
                                                        on_access, explicit_flag, rule_cap}
on_access_result ∈ AllowedKnowledgePolicyOnAccessResults = {applied, skipped_no_policy, error}
trigger     ∈ AllowedKnowledgePolicyReconcileTriggers = {schema_change, startup, manual}

Forbidden-label discipline: the graph-schema node `label` axis and user property keys are DELIBERATELY excluded — user DDL can create unbounded values. Use exemplar trace attributes or structured logs for per-label detail, NEVER Prometheus labels.

Hot-path discipline (MET-25): `Scorer.score()` runs per node returned from any Cypher match when decay is enabled. The bag pre-binds the per- entity_kind counters and histogram at construction; the caller caches the Bound* observers in a struct field and calls Observe/Inc without a WithLabelValues lookup on the hot path.

Sampling: DecayScore is sampled 1/32 via ObserveDecayScoreSampled so a 1M-row scan pays only ~31k histogram Observe calls instead of 1M. Counter fires (ScoredTotal, SuppressionsTotal) are cheap enough to fire every time.

D-08 forward-compat: NewKnowledgePolicyMetrics(reg, tenantLabelsEnabled, bufferFullnessFn) decides label-set shape ONCE at construction. When tenantLabelsEnabled is false, the `database` label is OMITTED from all labelnames. Flush-level metrics (batch_rows, duration, buffer_fullness) are intentionally NOT tenant-scoped — the AccessFlusher is cross-namespace.

Package observability — MVCC metric bag (Plan 04-04 GREEN).

Owns four families per MET-11 + ADR §2.3:

nornicdb_mvcc_pressure_band{[database], band}
nornicdb_mvcc_pinned_bytes               (GaugeFunc; D-15b)
nornicdb_mvcc_oldest_reader_age_seconds  (GaugeFunc; D-15b)
nornicdb_mvcc_active_readers             (GaugeFunc; D-15b)

Closed enums (CONTEXT D-14):

band ∈ AllowedMVCCBands = {normal, warn, high, critical}

D-14 thresholds (constants below):

ratio < 0.50           → normal
0.50 ≤ ratio < 0.75    → warn
0.75 ≤ ratio < 0.90    → high
ratio ≥ 0.90           → critical

PressureBand is set indicator-style: the active band gauge holds value 1 while the other three bands hold value 0 for the same database. The indicator pattern lets dashboards do `max by (database) (... == 1)` to pick the current band; alert rules can fire on `nornicdb_mvcc_pressure_band{band="critical"} == 1`.

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `query`, `user`, `user_id`, `ip`, `uuid`, `email` are never used here; `band` is bounded by AllowedMVCCBands; `database` is bounded by the per-tenant-flag axis (D-08).

RISK-2 fixed (Plan 04-04-01): the three GaugeFunc callbacks read the MVCCProbe accessors (PinnedBytes / OldestReaderAgeSeconds / ActiveReaders) which now exist on *BadgerEngine. Each callback is wrapped in a defer-recover that returns 0 on panic per RESEARCH RISK-8 / Pitfall 1 — concurrent shutdown cannot poison the /metrics scrape.

D-02d leaf-package boundary: pkg/observability never imports pkg/storage. MVCCProbe is declared HERE; pkg/storage's *BadgerEngine satisfies it via the accessors in pkg/storage/badger_mvcc.go.

D-08 forward-compat: NewMVCCMetrics(reg, tenantLabelsEnabled, probe) decides whether the `database` label is included in pressure_band ONCE at construction. The three live-read gauges have no labels (single process-wide value) so they are tenant-flag-independent.

Package observability — minimal type stubs for catalog bags whose GREEN implementations land in Plans 04-03..04-06 (Cypher, Storage, MVCC, Embed, Search, Replication, Auth).

These stubs exist to keep pkg/observability compiling while the per-bag RED tests sit in their `t.Skip("RED: pending Plan 04-NN")` state (Plan 04-01 Wave-0 RED-first cadence). Each stub:

  • Declares the struct fields the RED tests reference (e.g. `Bytes`, `IndexRebuild`, `LagBytes`, `AuthAttempts`, `FFIPanicTotal`).
  • Provides a constructor that PANICS at runtime — the RED test's leading `t.Skip` ensures the constructor is never called from a running test, so the panic is purely a load-bearing guard against accidental production usage before the GREEN bag lands.

When Plan 04-NN ships its real `NewXxxMetrics` constructor + bag, that plan REPLACES the stub here with the production bag (split into a per-subsystem catalog_<sub>.go file per CONTEXT D-02a).

Plan ownership map:

NewCypherMetrics        — Plan 04-03 (SHIPPED — see catalog_cypher.go)
NewStorageMetrics       — Plan 04-04 (SHIPPED — see catalog_storage.go)
NewMVCCMetrics          — Plan 04-04 (SHIPPED — see catalog_mvcc.go;
                          RISK-2 PinnedBytes accessor lives on
                          *BadgerEngine in pkg/storage/badger_mvcc.go)
NewEmbedMetrics         — Plan 04-05 (SHIPPED — see catalog_embed.go)
NewSearchMetrics        — Plan 04-05 (SHIPPED — see catalog_search.go)
NewReplicationMetrics   — Plan 04-06 (with RISK-3 PeerConfig.ID + GAP-1
                          last_contact_seconds + per-mode cardinality)
NewAuthMetrics          — Plan 04-06 (GAP-6 / MET-15)

Plan 04-02 (this plan) DELIVERS NewHTTPMetrics + NewBoltMetrics — those live in catalog_http.go and catalog_bolt.go respectively (NOT in this stubs file).

As of Plan 04-06 all bags have shipped — the stub bodies are empty and this file remains as a historical pointer to the per-subsystem catalog files (one entry per <plan ownership map> row).

Package observability — Replication metric bag (Plan 04-06 GREEN per MET-14 + GAP-1 + RISK-3 corrected).

Owns ten families per ADR §2.3 + CONTEXT §domain:

nornicdb_replication_role                          (Gauge int enum)
nornicdb_replication_term                          (Gauge int)
nornicdb_replication_commit_index                  (Gauge int)
nornicdb_replication_apply_index                   (Gauge int)
nornicdb_replication_lag_bytes{peer}               (GaugeVec)
nornicdb_replication_lag_entries{peer}             (GaugeVec)
nornicdb_replication_apply_duration_seconds        (LatencyHistogram)
nornicdb_replication_rtt_seconds{peer}             (HistogramVec)
nornicdb_replication_leader_changes_total          (Counter)
nornicdb_replication_last_contact_seconds{peer}    (GaugeVec — GAP-1)

**RISK-3 FIX (RESEARCH §RISK-3 verified):** pkg/replication/config.PeerConfig has fields {ID string; Addr string} — NOT `Name`. CONTEXT D-05 wording is obsolete. PeerLabel(p) returns `p.ID` if non-empty, falling back to `p.Addr`. NEVER reads from `conn.RemoteAddr()` — runtime sockets would explode cardinality.

**D-05a mode-aware ceiling:**

ha_standby:   8  (single peer + churn slack)
raft:         16 (5-node typical quorum + churn slack)
multi_region: 64 (3-region × ~5-peer + churn slack)

Stored in modeCeiling and accessible via Mode() / Ceiling() so the AssertCardinalityCeiling test parameterizes by mode.

**D-08a tenant-flag accepted but IGNORED at registration:** Replication is per-cluster, not per-database. `tenantLabelsEnabled` is part of the constructor signature so callers pass cfg uniformly across all subsystem bags, but the database label is never added to any replication family. Documented inline; verified by TestReplicationMetrics_*.

**D-15a per-event observation (zero polling):** pkg/replication.RaftReplicator + HAStandbyReplicator + MultiRegionReplicator call metrics.Role.Set(RoleEnum("leader")) at the SAME sites that emit the existing role-transition log line. No background goroutine polls state. Pre-bound observers cached in struct fields per MET-25.

**D-05b stale-peer GC (lifecycle.Component) lives in pkg/replication/peer_metrics_gc.go.** It calls DeleteLabelValues on the per-peer GaugeVecs after a configurable staleness threshold. Replicator rebinds on reconnect (Pitfall 3 mitigation per RESEARCH §Q7).

**D-02d leaf-package boundary:** pkg/observability never imports pkg/replication. The replication code imports pkg/observability and satisfies its peer-label inputs with PeerConfig (whose two-field shape {ID, Addr} is documented here for the test suite).

Package observability — Search metric bag (Plan 04-05 GREEN).

Owns four families per MET-13 + ADR §2.3:

nornicdb_search_requests_total{[database,] mode, result}
nornicdb_search_duration_seconds{[database,] mode, stage}
nornicdb_search_candidates                          (RowCountHistogram)
nornicdb_search_index_size_bytes{kind}              (GaugeFunc; D-15b)

Closed enums (CONTEXT MET-13 / D-15b):

mode   ∈ AllowedSearchModes        = {vector, bm25, hybrid}
result ∈ AllowedSearchResults      = {success, no_results, error}
stage  ∈ AllowedSearchStages       = {embed, index, fuse}
kind   ∈ AllowedSearchIndexKinds   = {hnsw, bm25}

stage is the keystone: per-stage observation lets SREs see whether embed (LLM call), index (vector / BM25 lookup), or fuse (RRF + rerank) dominates the latency budget. Closed enum at internal call sites prevents arbitrary stage-name labeling.

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `query` is the keystone — raw query text MUST NEVER be a label value. The registration.go panic catches anyone passing `query` as a label.

MET-25 hot path: per-stage observation uses pre-bound observers cached at the call site. The bag's BindDuration helper returns a BoundLatencyObserver that callers store in struct fields per MET-25.

D-15b GaugeFunc panic safety: the index_size_bytes callback per kind wraps defer-recover that returns 0 on probe panic — same pattern as Plan 04-04 storage.bytes / mvcc.pinned_bytes.

D-02d leaf-package boundary: pkg/observability never imports pkg/search. SearchProbe is declared HERE; pkg/search.Service satisfies it via thin adapter or a setter that AttachesMetrics.

D-08 forward-compat: NewSearchMetrics(reg, tenantLabelsEnabled, probe) decides whether the `database` label is included in requests_total + duration_seconds ONCE at construction. candidates and index_size_bytes are global — bytes per index kind, candidates per request distribution.

Package observability — Storage metric bag (Plan 04-04 GREEN).

Owns eight families per MET-10 + ADR §2.3:

nornicdb_storage_nodes_total                              (GaugeFunc; D-15b)
nornicdb_storage_edges_total                              (GaugeFunc; D-15b)
nornicdb_storage_bytes{kind}
nornicdb_storage_op_duration_seconds{op}
nornicdb_storage_compactions_total{level, result}
nornicdb_storage_compaction_duration_seconds{level}
nornicdb_storage_wal_lag_bytes
nornicdb_storage_index_rebuild_total{[database,] index, result}

Closed enums (CONTEXT D-07 / D-13c):

kind   ∈ AllowedStorageBytesKinds = {nodes, edges, index, wal, search}
op     ∈ AllowedStorageOps        = {get, put, delete, scan}
index  ∈ AllowedStorageIndexes    = {label, edge_between, temporal,
                                     embedding, user_created}
result ∈ AllowedStorageResults    = {success, failure, aborted}

D-13c the `index` enum is the keystone of T-04-02 mitigation: arbitrary user-created index names NEVER become label values. The pure mapping function `pkg/storage.classifyIndexName(internal string) string` buckets any unknown name to `user_created`. RESEARCH §Q11 cardinality ceiling.

Forbidden-label discipline (Phase 3 D-03 / registration.go ForbiddenLabels): `path`, `query`, `user`, `user_id`, `ip`, `uuid`, `embedding_text`, `trace_id`, `span_id`, `email` would all be cardinality bombs and panic at registration. Storage hot paths never label by raw key bytes or full paths.

D-07 bytes{kind} is populated by a 30s lifecycle.Component sweep that calls `badger.DB.EstimateSize(prefix)` for each prefix. Sweep lives in pkg/storage/bytes_metrics.go (Plan 04-04-04). RESEARCH §Q3.

MET-25 hot path: per-op observation uses pre-bound BoundLatencyObserver cached in BadgerEngine struct fields at constructor time. The bag's OpDuration.Bind("get") is hoisted out of the request loop. CompactionDuration is bound at compaction-event hook attachment.

RISK-6 wal_lag_bytes semantics: best-effort heuristic estimate of WAL backlog (vlog size minus LSM size from `badger.DB.Size()`). Alerting SHOULD use 5-minute trends, not single scrapes. The metric is documented here and in the bytes_metrics.go sweeper that populates it.

D-08 forward-compat: NewStorageMetrics(reg, tenantLabelsEnabled, probe) decides whether the `database` label is included in IndexRebuildTotal ONCE at construction. The plan deliberately does NOT add database to op_duration (D-08a) — per-database op latency lives at the Cypher subsystem (Plan 04-03).

Package observability is NornicDB's telemetry seam: metrics (Prometheus + OTel), traces (OTLP), and the building blocks for /metrics, /livez, /readyz, /version, and opt-in pprof endpoints.

Architecture

observability is a leaf package. It depends on stdlib, OpenTelemetry SDK, prometheus/client_golang, and pkg/lifecycle (one-way; pkg/lifecycle has zero observability deps). Business packages (cypher, storage, bolt, server) MUST NOT import this package directly — they receive instrumentation via accessors passed through dependency injection from cmd/nornicdb/main.go.

Wiring

Construct a *Provider via:

prov, err := observability.New(ctx, cfg.Observability, observability.ServiceInfo{
    Name:    "nornicdb",
    Version: buildinfo.Version(),
    NodeID:  cfg.NodeID,
})

New always succeeds at the Provider-construction level: a misconfigured or unreachable OTLP collector is reported via WARN log and a noop tracer provider is installed (OBS-11). Process startup never fails because of observability init failure.

Init order (OBS-03)

New constructs in this order, mandated by the ADR-0001 §2.5 contract:

  1. logger (deferred to Phase 2 slog migration; Phase 1 uses stdlib log).
  2. resource attributes (semconv.ServiceName/Version/InstanceID).
  3. meter + tracer providers (real SDK or noop fallback).
  4. registries (the Prometheus registry and the OTel→Prom bridge).
  5. middleware + listeners (Plan 03 — listener.go, health.go, pprof.go).

Endpoint precedence (OBS-12)

OTLP endpoint resolution honors:

OTEL_EXPORTER_OTLP_ENDPOINT > YAML tracing.endpoint > built-in default

See TracingConfig.OTLPEndpoint().

service.instance.id resolution (OBS-10)

resolveInstanceID resolves through this chain:

cfg.NodeID → POD_NAME env → os.Hostname() → "standalone"

The resolved value and its source are logged once at startup.

Compliance boundary

pkg/audit (Phase-2 untouched) is NOT migrated to OTel logs. The compliance audit trail keeps its existing retention/signing/serialization. This package only owns observability — operator-facing telemetry — not compliance.

Test isolation

Each test owns a fresh *prometheus.Registry, an in-memory span exporter, and a discard logger. Never touch prometheus.DefaultRegisterer or slog.SetDefault in tests. The NewTestEnv helper that codifies this ships in Plan 03.

Package observability — typed exemplar-wrapper layer for histogram observation (Phase 3, MET-24 + MET-25).

Design (CONTEXT D-02):

  • Wrappers (LatencyHistogram etc.) own the MET-24 exemplar-emission chokepoint. Subsystems use the wrapper for normal Observe paths; the raw *prometheus.HistogramVec from metrics.go remains accessible via Vec() for cardinality-ceiling assertions and edge cases.
  • Bind(lvs ...) returns a Bound*Observer that subsystems cache as a struct field at construction time — eliminates per-Observe WithLabelValues lookup (MET-25 hot-path discipline).
  • The IsValid()→ExemplarObserver chokepoint is a SINGLE function (observeWithExemplar) per AGENTS.md §7 DRY. Every wrapper Observe and every Bound Observe funnel through it.

Forward-compat (CONTEXT D-02a + Phase 2 D-05 precedent):

Phase 1 ships sdktrace.NeverSample() ⇒ every emitted SpanContext has
IsSampled()=false ⇒ chokepoint short-circuits ⇒ ZERO exemplar-path
allocations today (also true on the truly-empty noop SpanContext where
IsValid()=false). Phase 6 flips the sampler in ONE place (provider.go);
ALL Phase-3-wrapped histograms emit exemplars automatically — no
second-pass migration of the ~60 Phase 4 metric families. Same posture
as Phase 2's mandatory_fields trace-id resolution.

Allocation budget (CONTEXT D-02b, falsified by BenchmarkObserve_Hot):

hot_no_span sub-bench:  ≤ 2 allocs/op (target: 0 — chokepoint short-circuits)
hot_with_span sub-bench: ≤ 4 allocs/op (1 for Labels map, 2 for *.String() calls)
If hot_with_span exceeds 4, D-02b1 escalation triggers:
  sync.Pool[*prometheus.Labels] — captured here so future plans don't re-derive.

Counter/gauge exemplars deferred (CONTEXT D-02e). Phase 3 ships histograms only.

Package observability — Phase 5 K8s autodetect.

k8sProbe inspects env + filesystem signals (KUBERNETES_SERVICE_HOST and /var/run/secrets/kubernetes.io/serviceaccount/token presence) to decide whether tenant labels should default ON. Conservative AND-signal logic per CONTEXT.md D-02. Pure-ish function: only stdlib os reads.

Phase 5 / Plan 05-03 fills the Detect + ResolveTenantLabels bodies.

Package observability — knowledge-policy metrics global ref.

The Badger read-path filter (pkg/storage/badger_decay_filter.go) fires on EVERY node returned from a Cypher MATCH when decay is enabled. Threading a *KnowledgePolicyMetrics handle through every storage iterator signature would widen CreateNode / GetNode / iterateNodesVisibleAtInTxn and their many callers. Instead we use the same atomic.Pointer bridge the BSP self- metrics pattern (bsp_self_metrics.go) already established for injecting observability into a deep-nested subsystem without inverting ownership.

Semantics:

  • Set once at Provider init (from cmd/nornicdb/main.go, after NewKnowledgePolicyMetrics returns).
  • Overwritten on each New() so per-test TestEnv isolation still works.
  • Nil safe: GetKnowledgePolicyMetrics returns nil before Set is called or when metrics are disabled; all IncScored / IncSuppression / etc. helpers short-circuit on nil receiver.

Package observability — Phase 5 legacy translation layer.

RenderLegacy walks the unified pkg/observability registry and emits the 12 metric families that customer scrapers expect on :7474/metrics. Pure function: input *prometheus.Registry + time.Time, output []byte in Prometheus exposition format v0.0.4.

Phase 5 / Plan 05-02 fills the legacyMappings table function fields and the RenderLegacy body; Wave-0 (05-01) declared the public-API surface.

Package observability — typed metric constructors that enforce naming, bucket, and label discipline at registration time (Phase 3, MET-01..MET-05).

Design (CONTEXT D-01):

  • Bucket choice is encoded in the constructor function name; subsystem authors cannot pass arbitrary Buckets. Single source of truth (D-01).
  • Namespace="nornicdb" is injected by the helper; caller never sets it (D-01b/D-01c — final name is nornicdb_<subsystem>_<name>).
  • Subsystem is enforced against the closed allowedSubsystems list (registration.go).
  • Forbidden labels (cardinality bombs + PII) panic at registration (registration.go::validateLabels — D-03a).

Dual-access pattern (CONTEXT D-02 + <specifics>):

  • These constructors return native *prometheus.HistogramVec / *prometheus.CounterVec / *prometheus.GaugeVec so subsystems can pre-bind via WithLabelValues for MET-25 hot-path discipline.
  • The typed wrapper structs in exemplar.go (Plan 03-03) wrap the same *HistogramVec to centralize the MET-24 exemplar-emission chokepoint.
  • Wrappers do NOT hide the raw types — they're parallel returns. Phase 4 subsystems receive both: the raw *Vec for cardinality assertions and edge cases, the wrapper for normal Observe() paths.

Pitfall 8 / MustRegister precedent (analog: registry.go:28-29): validation failure at construction is a programming bug; panic IS the desired startup behavior — surfaces the bug before any traffic reaches the binary.

Package observability — registration-time validation primitives for the metrics-helper layer (Phase 3, MET-01..MET-05). Each helper in metrics.go calls these primitives BEFORE invoking reg.MustRegister; a violation is a programming bug per Phase 1 Pitfall 8 / MustRegister precedent — panic IS the desired startup behavior.

Doc analog: pkg/observability/registry.go:28-29 (MustRegister panic-as-feature).

Index

Constants

View Source
const (
	MVCCBandWarn     = 0.50
	MVCCBandHigh     = 0.75
	MVCCBandCritical = 0.90
)

D-14 pressure-band thresholds. Ratio = PinnedBytes / MVCCBudgetBytes.

View Source
const (
	ReasonExplicitYAML      = "explicit_yaml"
	ReasonK8sDetected       = "k8s_detected"
	ReasonServiceHostAbsent = "not_k8s_service_host_absent"
	ReasonTokenFileAbsent   = "not_k8s_token_file_absent"
	ReasonTokenFileEmpty    = "not_k8s_token_file_empty"
	ReasonTokenStatError    = "not_k8s_token_stat_error"
)

Reason* are the closed-set source strings logged at startup (D-02b).

View Source
const (
	LegacySunset      = "Fri, 31 Dec 2027 23:59:59 GMT"
	LegacyDeprecation = "true"
	LegacyContentType = "text/plain; version=0.0.4; charset=utf-8"
)

Public-API contract bytes — frozen in Wave-0. Any change requires ADR amendment per CLAUDE.md "Public API contract — Metric names and span names are versioned. Deprecations require Sunset header overlap of one minor release minimum."

View Source
const DecayScoreSampleDenominator = 32

DecayScoreSampleDenominator is the 1/N sampling rate for the decay_score histogram. One sample in DecayScoreSampleDenominator is recorded; the rest are dropped. Keep power-of-two for cheap bitmask tests.

Variables

View Source
var (
	AllowedKnowledgePolicyEntityKinds = []string{
		"node",
		"edge",
		"property",
	}
	AllowedKnowledgePolicyScoreResults = []string{
		"visible",
		"suppressed",
		"no_decay",
	}
	AllowedKnowledgePolicySuppressReasons = []string{
		"below_threshold",
		"score_floor",
		"on_access",
		"explicit_flag",
		"rule_cap",
	}
	AllowedKnowledgePolicyOnAccessResults = []string{
		"applied",
		"skipped_no_policy",
		"error",
	}
	AllowedKnowledgePolicyReconcileTriggers = []string{
		"schema_change",
		"startup",
		"manual",
	}
)

Closed-enum values. Keep in sync with docs/plans/knowledge-policy- observability-plan.md and catalog_knowledge_policy_test.go.

View Source
var AllowedAuthProtocols = []string{"bolt", "http", "grpc"}

AllowedAuthProtocols is the closed enum for the `protocol` label. The label is set at the protocol-specific adapter chokepoint (HELLO handler for Bolt; HTTP middleware for HTTP; UnaryInterceptor for gRPC) — the shared core Authenticator is protocol-agnostic and does NOT increment.

View Source
var AllowedAuthResults = []string{"success", "failure", "denied"}

AllowedAuthResults is the closed enum for the `result` label. Mirrors the three semantic outcomes per CONTEXT D-05e:

  • success: credentials validated; user identity established
  • failure: credentials presented but rejected (bad password, expired token)
  • denied: request rejected before credential evaluation (auth required but absent, unsupported scheme, account locked, role denied)

Adding a new result requires an ADR amendment AND callers must update classifyAuthResult in pkg/auth.

View Source
var AllowedBoltConnectionRejectReasons = []string{
	"max_connections",
	"sniff_timeout",
	"auth_timeout",
	"tls_handshake",
	"ws_handshake",
	"oversized_message",
	"requires_tls",
	"unrecognized_prefix",
	"ws_disabled",
}

AllowedBoltConnectionRejectReasons is the closed enum for the new `bolt_connections_rejected_total` counter introduced with WebSocket transport selection. Each reason is a discrete, named failure mode so dashboards can distinguish operator-misconfiguration from attack noise from organic load.

View Source
var AllowedBoltOps = []string{
	"hello",
	"run",
	"pull",
	"begin",
	"commit",
	"discard",
	"reset",
	"goodbye",
	"route",
	"ack_failure",
}

AllowedBoltOps is the closed enum for the `op` label per CONTEXT D-11a. Sourced from pkg/bolt/server.go MsgHello/MsgRun/... message-type constants. Adding a new Bolt message op = enum update + ADR §2.3 amendment.

View Source
var AllowedBoltResults = []string{"success", "error", "timeout"}

AllowedBoltResults is the closed enum for the `result` label per CONTEXT D-11. Mirrors the connection-close + message-dispatch outcome.

View Source
var AllowedBoltTransports = []string{"tcp", "tcp_tls", "ws", "ws_tls"}

AllowedBoltTransports is the closed enum for the `transport` label added in the bolt-over-websocket landing. The four values cover every wire-level transport the Bolt port multiplexes:

tcp     - raw bolt:// over plain TCP
tcp_tls - bolt+s:// (TLS-wrapped raw)
ws      - ws://     (WebSocket over plain TCP)
ws_tls  - wss://    (WebSocket over TLS)

Cardinality ceiling for `bolt_connections_total` rises from 3 to 12 (3 results x 4 transports) and `bolt_connections_active` from 1 to 4.

View Source
var AllowedCacheNames = []string{
	"query_result",
	"schema",
	"label",
	"node_lookup",
}

AllowedCacheNames is the closed enum for the `cache` label per CONTEXT D-12. Subsystem callers (cypher cache.go, schema/label/node_lookup callers) MUST pass only one of these values. Adding a new cache name requires a constants update here AND an ADR §2.3 amendment.

Mirrors Phase 3 D-01d allowedSubsystems cadence — closure-by-construction rather than runtime panic (the registration-time forbidden-label panic catches label NAMES; closed-value enforcement is a per-call discipline).

View Source
var AllowedCypherOpTypes = []string{
	"read",
	"write",
	"schema",
	"admin",
	"fabric",
	"parse_error",
}

AllowedCypherOpTypes is the closed enum for the `op_type` Cypher label per CONTEXT D-04 (corrected per RISK-1). Mirrors the QueryAnalyzer-derived classification + admin-dispatch + fabric-routing + parse-error sites.

Adding a new op_type = enum update + ADR §2.3 amendment + new test case in pkg/cypher/op_type_test.go::TestOpType_AllClauseShapes (D-04c table-driven).

View Source
var AllowedEmbedBackends = []string{"gpu", "cpu", "cuda", "metal", "vulkan"}

AllowedEmbedBackends is the closed enum for the `mode` label per CONTEXT D-06a. Mirrors the Embedder.Backend() return values declared in pkg/embed (build-tag matrix). Adding a new backend = enum update HERE + pkg/embed Backend() implementer + ADR §2.3 amendment.

View Source
var AllowedEmbedProviders = []string{"ollama", "openai", "local", "other"}

AllowedEmbedProviders is the closed enum for the `provider` label on processed_total + duration_seconds. Adding a provider = enum update + ADR amendment. The "other" bucket catches custom embedder factories.

View Source
var AllowedEmbedResults = []string{"success", "failure", "cached"}

AllowedEmbedResults is the closed enum for the `result` label on processed_total. Closed at the call site in pkg/embed wrappers (no user input flows here).

View Source
var AllowedEvictionReasons = []string{
	"lru",
	"ttl",
	"capacity",
	"manual",
}

AllowedEvictionReasons is the closed enum for the `reason` label on cache_evictions_total per CONTEXT D-12b.

View Source
var AllowedMVCCBands = []string{"normal", "warn", "high", "critical"}

AllowedMVCCBands is the closed enum for the `band` label per CONTEXT D-14. Adding a new band = enum update + ADR §2.3 amendment + threshold review.

View Source
var AllowedPackstreamReasons = []string{
	"truncated",
	"invalid_marker",
	"wrong_type",
	"oversize",
}

AllowedPackstreamReasons is the closed enum for the `reason` label per CONTEXT D-11c. Free-form `err.Error()` strings would be a cardinality bomb; the four-value enum is enforced by `reasonFromError(err)` at the decode boundary in pkg/bolt/packstream.go.

View Source
var AllowedReplicationModes = []string{"standalone", "ha_standby", "raft", "multi_region"}

AllowedReplicationModes is the closed enum of replication modes. Mirrors pkg/replication.ReplicationMode constants. `standalone` is permitted at the bag level but the standalone replicator never observes (see pkg/replication.StandaloneReplicator — zero-overhead noop).

View Source
var AllowedReplicationRoles = []string{"follower", "candidate", "leader", "standby"}

AllowedReplicationRoles is the closed enum for the role gauge value lookup. The role is published as a NUMERIC enum (Gauge.Set), not a label, because per-role series would either explode cardinality or produce stale "follower=0 / leader=1" series across transitions.

View Source
var AllowedSearchIndexKinds = []string{"hnsw", "bm25"}

AllowedSearchIndexKinds is the closed enum for the `kind` label on index_size_bytes per CONTEXT MET-13 / D-15b. Two index kinds:

  • hnsw: vector index (HNSW or IVF-HNSW or IVFPQ — all vector indexes bucket here from a capacity-planning perspective)
  • bm25: full-text index
View Source
var AllowedSearchModes = []string{"vector", "bm25", "hybrid"}

AllowedSearchModes is the closed enum for the `mode` label per CONTEXT MET-13. Mirrors the three search code paths in pkg/search/search.go: vectorSearchOnly (vector), fullTextSearchOnly (bm25), rrfHybridSearch (hybrid). Adding a mode = enum update + ADR §2.3 amendment.

View Source
var AllowedSearchResults = []string{"success", "no_results", "error"}

AllowedSearchResults is the closed enum for the `result` label on requests_total. no_results is distinct from error — an empty result set is a successful search; error reflects a pipeline failure.

View Source
var AllowedSearchStages = []string{"embed", "index", "fuse"}

AllowedSearchStages is the closed enum for the `stage` label on duration_seconds per CONTEXT MET-13. Three pipeline stages:

  • embed: text → vector (the embedder call)
  • index: vector / BM25 lookup
  • fuse: RRF + rerank + MMR

Closed at the call site (string literal at each observation point).

View Source
var AllowedStatusClasses = []string{"1xx", "2xx", "3xx", "4xx", "5xx"}

AllowedStatusClasses is the closed enum for the `status_class` HTTP label. Mirrors the standard 1xx-5xx HTTP response class buckets per ADR §2.3.

View Source
var AllowedStorageBytesKinds = []string{"nodes", "edges", "index", "wal", "search"}

AllowedStorageBytesKinds is the closed enum for the `kind` label per CONTEXT D-07. Adding a new bucket = enum update + ADR §2.3 amendment + new sweep entry in pkg/storage/bytes_metrics.go.

View Source
var AllowedStorageIndexes = []string{"label", "edge_between", "temporal", "embedding", "user_created"}

AllowedStorageIndexes is the closed enum for the `index` label per CONTEXT D-13c. The `user_created` bucket catches arbitrary user index names — the pure classifier function `pkg/storage.classifyIndexName` maps unknown names here. Cardinality bounded at 5.

View Source
var AllowedStorageOps = []string{"get", "put", "delete", "scan"}

AllowedStorageOps is the closed enum for the `op` label per CONTEXT D-07. Mirrors the four storage-engine chokepoints: GetNode/GetEdge (get), CreateNode/CreateEdge (put), DeleteNode/DeleteEdge (delete), iterator scans (scan). Hot-path observation uses pre-bound observers (MET-25).

View Source
var AllowedStorageResults = []string{"success", "failure", "aborted"}

AllowedStorageResults is the closed enum for the `result` label on compactions_total and index_rebuild_total. Note: NOT used on op_duration_seconds (per D-16, op_duration omits result; conflict classification lives at Cypher subsystem).

View Source
var BaggageAllowList = map[string]bool{}

BaggageAllowList is the set of baggage keys forwarded through the system (SEC-02). Default is empty — all unknown baggage keys are dropped at the HTTP edge. Operators configure allowed keys via NORNICDB_BAGGAGE_ALLOW_LIST.

View Source
var DefaultRedactKeys = []string{
	"password", "token", "authorization", "secret", "api_key", "credentials",
}

DefaultRedactKeys is the canonical sensitive-key allow-list for slog records. Keys are matched case-insensitively, regardless of slog.Group nesting depth. Extend at runtime via NORNICDB_LOG_REDACT_EXTRA (comma-separated).

Note: pkg/audit/audit.go owns its own signed audit stream and is intentionally NOT a consumer of this list (LOG-10 / SEC-05). A future leaf package pkg/redaction/ can canonicalize the constants if audit ever grows its own redaction needs — that's a post-M1 consolidation, out of Phase 2 scope.

`credentials` is included specifically to catch Bolt HELLO auth tokens (D-03a) — the protocol's own field name.

View Source
var EmbeddingLatencyBucketsSeconds = []float64{
	.001, .01, .1, .5, 1, 5, 10, 30, 60, 120, 300, 600,
}

EmbeddingLatencyBucketsSeconds covers ~1ms to 600s for LLM/local embedding calls. Long-tail (last bucket ≥ 60s) per MET-05 — verified by TestEmbeddingLatency_UsesLongTailBuckets. Used by NewEmbeddingLatencyHistogramVec (MET-05).

View Source
var ForbiddenLabels = []string{
	"path",
	"query",
	"user",
	"user_id",
	"ip",
	"uuid",
	"embedding_text",
	"trace_id",
	"span_id",
	"email",
}

ForbiddenLabels enumerates label names rejected at registration time. Case-insensitive match. Mirrors REQ MET-04 + ADR §2.2 verbatim (D-03a).

Mutating this list requires an ADR amendment — these label names represent either cardinality bombs (full HTTP path, raw Cypher text, UUIDs, IPs) or PII (user, user_id, email) or values that belong elsewhere in the exposition format (trace_id and span_id belong in exemplars, never as labels — see exemplar.go).

Falsifiability: TestForbiddenLabels_PanicsAtRegistration (60 cases: 10 labels × 6 helper constructors) + TestForbiddenLabels_CaseInsensitive + TestForbiddenLabels_AllTenEntriesPresent.

View Source
var LatencyBucketsSeconds = []float64{
	.0001, .0005, .001, .005, .01, .05, .1, .5, 1, 5, 10,
}

LatencyBucketsSeconds covers ~100us to 10s for request-latency histograms. Used by NewLatencyHistogramVec (MET-03).

View Source
var RowCountBuckets = []float64{
	1, 10, 100, 1000, 10000, 100000, 1000000,
}

RowCountBuckets covers 1 to 1M for query result-set sizes. Used by NewRowCountHistogramVec (MET-03).

View Source
var ScoreBuckets = []float64{
	0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0,
}

ScoreBuckets covers 0.0..1.0 for knowledge-policy decay / promotion score histograms. Ten equally-spaced buckets catch the bimodal fresh/stale distribution operators look for when validating half-life choices. Used by NewScoreHistogramVec (knowledge_policy subsystem).

View Source
var SensitiveKeys = map[string]bool{
	"auth.token":         true,
	"auth.password":      true,
	"auth.credentials":   true,
	"db.password":        true,
	"bolt.auth_token":    true,
	"http.authorization": true,
	"user.password":      true,
	"user.token":         true,
	"credentials":        true,
	"password":           true,
	"secret":             true,
	"api_key":            true,
	"access_token":       true,
	"refresh_token":      true,
	"session_token":      true,
	"private_key":        true,
}

SensitiveKeys is the set of span attribute keys that must be redacted before export (SEC-01). Shared with pkg/audit via this exported variable so the redaction list has a single source of truth.

View Source
var SizeBucketsBytes = []float64{
	64, 256, 1024, 4096, 16384, 65536,
	262144, 1048576, 4194304, 16777216, 67108864,
}

SizeBucketsBytes covers 64B to 64MB for payload-size histograms. Used by NewSizeHistogramVec (MET-03).

Functions

func DefaultK8sProbe

func DefaultK8sProbe() k8sProbe

DefaultK8sProbe returns a probe wired to the live OS reads.

func NewCounterVec

func NewCounterVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.CounterVec

NewCounterVec constructs a counter on reg. Same validation + Pitfall-8 panic semantics. Suffix: _total (MET-02).

func NewEmbeddingLatencyHistogramVec

func NewEmbeddingLatencyHistogramVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.HistogramVec

NewEmbeddingLatencyHistogramVec constructs a long-tail latency histogram for LLM / local embedding calls (MET-05). Same validation + Pitfall-8 panic semantics. Buckets: EmbeddingLatencyBucketsSeconds (tail ≥ 60s). Suffix: _seconds.

func NewGaugeVec

func NewGaugeVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.GaugeVec

NewGaugeVec constructs a gauge on reg. No suffix validation — ADR §2.2 does not lock a single gauge suffix (gauges may be unitless like _ratio, sized like _bytes, count like _count — context-specific). Subsystem and label discipline still enforced.

func NewLatencyHistogramVec

func NewLatencyHistogramVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.HistogramVec

NewLatencyHistogramVec constructs a request-latency histogram on reg. Validates Subsystem/Name/labels/Help per D-01a (panics on violation — programming bug per Pitfall 8). Returns the raw *prometheus.HistogramVec for hot-path WithLabelValues pre-binding (MET-25); see exemplar.go for the typed LatencyHistogram wrapper that centralizes exemplar emission.

Final metric name: nornicdb_<opts.Subsystem>_<opts.Name>. Buckets locked to LatencyBucketsSeconds (MET-03 single source of truth). Suffix locked to _seconds (MET-02).

func NewLogger

func NewLogger(cfg LoggerConfig, info ServiceInfo) (*slog.Logger, io.Writer, error)

NewLogger constructs the production *slog.Logger with the 4-layer handler stack per D-02a (outermost → innermost):

recoveringHandler          (D-09: catches panics in any inner layer)
  └─ mandatoryFieldsHandler  (D-05: service/version/node_id + trace ctx)
       └─ redactingHandler   (D-03/D-03b: PII allow-list + CRLF strip)
            └─ nornicdbJSONHandler (D-02: ≤2 allocs/record)

Returns:

  • *slog.Logger: the assembled logger; never nil even on error.
  • io.Writer: the underlying writer (file/stderr/stdout) so the caller can stash it in Provider.writerRef for D-09a opportunistic Sync().
  • error: non-nil if cfg.Output points at an unopenable path. The logger remains usable (writes to stderr) so the process keeps running — OBS-11 fail-closed analog.

Per D-08 bootstrap order: cmd/nornicdb MUST call NewLogger BEFORE observability.New so the *slog.Logger can be threaded through the Provider construction.

Per Pitfall 10: the LevelVar is allocated as a pointer (`&slog.LevelVar{}`) so the handler holds a stable address that survives the function return.

func NewPprofListener

func NewPprofListener(cfg PprofConfig) (*pprofListener, error)

NewPprofListener returns the optional :9091 listener.

Returns (nil, nil) — NOT an error — when cfg.Enabled is false. The caller (Plan 04 main.go) skips registration as a Component in that case, so pprof imposes zero runtime cost when disabled.

Default Listen is "127.0.0.1:9091" per ADR §A9; ":9091" or "0.0.0.0:9091" only if the operator explicitly overrides via NORNICDB_PPROF_LISTEN. The /debug/pprof/* handlers expose goroutine stacks and heap data; a non-loopback default would be a security bug.

Handlers are registered EXPLICITLY on a custom mux. We do NOT use the blank/underscore side-effect import form because that registers on http.DefaultServeMux, which we deliberately avoid for isolation. A dedicated mux also prevents accidental cross-pollution if some unrelated import elsewhere ever registers something on DefaultServeMux.

func NewRedactingSpanProcessor

func NewRedactingSpanProcessor(inner sdktrace.SpanProcessor) sdktrace.SpanProcessor

NewRedactingSpanProcessor wraps inner with attribute redaction (SEC-01).

func NewRowCountHistogramVec

func NewRowCountHistogramVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.HistogramVec

NewRowCountHistogramVec constructs a result-set-size histogram on reg. Same validation + Pitfall-8 panic semantics. Buckets: RowCountBuckets. Suffix: _rows (MET-02).

func NewScoreHistogramVec

func NewScoreHistogramVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.HistogramVec

NewScoreHistogramVec constructs a 0.0..1.0 score histogram on reg for knowledge-policy decay / promotion score distributions. Same validation + Pitfall-8 panic semantics. Buckets: ScoreBuckets. Suffix: MUST end in _score (unitless score families follow ADR §2.2's "_score" convention).

func NewSizeHistogramVec

func NewSizeHistogramVec(reg *prometheus.Registry, opts MetricOpts, labels []string) *prometheus.HistogramVec

NewSizeHistogramVec constructs a payload-size histogram on reg. Same validation + Pitfall-8 panic semantics as NewLatencyHistogramVec. Buckets: SizeBucketsBytes. Suffix: _bytes (MET-02).

func NewTelemetryListener

func NewTelemetryListener(prov *Provider, health *Health) (*telemetryListener, error)

NewTelemetryListener builds the :9090 listener.

The net.Listener is opened in this constructor so that bind failures (EADDRINUSE) surface during observability.New rather than asynchronously inside the supervisor goroutine — matching Pattern 6 / Plan-02 idioms.

/metrics is registered ONLY when prov.MetricsEnabled() && prov.Registry() is non-nil (OBS-04). When metrics are disabled, the route is not registered and the mux falls through to a 404 — operators can still hit /livez, /readyz, /version.

func PeerLabel

func PeerLabel(p PeerConfigLike) string

PeerLabel returns the stable peer label value per RISK-3 (corrected). The two-field PeerConfigLike interface is the leaf-package boundary indirection (D-02d) — pkg/replication/config.PeerConfig satisfies it via its actual {ID, Addr} fields.

Resolution order:

  1. ID (non-empty) — UUID-stable across restarts; preferred.
  2. Addr (fallback) — stable across reconnects to the same configured peer.
  3. "" (empty) — allowed but a sign of misconfiguration; tests flag.

**NEVER** read from runtime sockets (`conn.RemoteAddr()`). That would produce a fresh label every reconnect and explode cardinality — exactly the RISK-3 failure mode the corrected wiring prevents.

func RenderLegacy

func RenderLegacy(reg *prometheus.Registry, now time.Time) []byte

RenderLegacy walks reg.Gather() once, indexes families by name, and emits the 12 legacy metric families in lexicographic LegacyName order (D-01d) using Prometheus exposition format v0.0.4. Returns nil-safe empty buffer when reg is nil; tolerates partial-state Gather() errors (RESEARCH Pitfall 2).

The now parameter is reserved for future relative-timestamp emission (CONTEXT D-01 future-proofs the API); currently unused.

func ResolveAndLogTenantLabels

func ResolveAndLogTenantLabels(explicit *bool, logger *slog.Logger) bool

ResolveAndLogTenantLabels is the cmd-level convenience wrapper used at startup to resolve the tenant-labels-enabled bool AND emit the single MET-22 forensic log line in one call. It:

  1. Constructs the production K8s probe (DefaultK8sProbe).
  2. Resolves the bool via ResolveTenantLabels(explicit, probe).
  3. Re-derives the two boolean signal flags from the same probe inputs so the log line documents what was actually checked (no duplicate AND-logic).
  4. Emits exactly one slog INFO record via the supplied logger with the four canonical fields: enabled, reason, service_host_present, token_file_present.

LOG-09 compliance: the caller must inject the *slog.Logger — this helper never touches slog.Default() / slog.SetDefault(). cmd/nornicdb/main.go passes the same Phase 2 D-08 logger that flows to pkg/server / pkg/bolt.

Returns the resolved bool which the caller writes into cfg.Observability.Metrics.TenantLabelsEnabled before any Phase 4 bag constructor reads it.

func ResolveTenantLabels

func ResolveTenantLabels(explicit *bool, probe k8sProbe) (resolved bool, source string)

ResolveTenantLabels enforces D-02a precedence:

explicit YAML (TenantLabelsExplicit *bool, R-02) > K8s autodetect > default false.

When explicit is non-nil the autodetect probe is short-circuited and the operator's intent wins (returning ReasonExplicitYAML). When explicit is nil the probe's Detect outcome and reason are returned verbatim.

func RoleEnum

func RoleEnum(role string) float64

RoleEnum maps a role string to a numeric gauge value. Closed mapping — any caller passing an unknown role gets -1 so the gauge value is distinguishable from a legitimate role state during debugging.

Mapping per CONTEXT §domain:

-1 = unknown (defensive — never set in production)
 0 = follower
 1 = candidate
 2 = leader
 3 = standby (HA mode)

The numeric values are the WIRE contract for alert rules — flipping them is a breaking change requiring an ADR amendment. Operators write rules like `nornicdb_replication_role == 2` for "is leader".

func SetKnowledgePolicyMetrics

func SetKnowledgePolicyMetrics(m *KnowledgePolicyMetrics)

SetKnowledgePolicyMetrics publishes the active metrics handle for the read-path filter to consume. Safe to call multiple times; the last call wins. Pass nil to tear down (used in tests that reset global state).

Types

type AuthMetrics

type AuthMetrics struct {
	// AuthAttempts counts authentication attempts by result and protocol.
	// Cardinality ceiling = 9 (RESEARCH §Q11). Closed enums enforced by the
	// call sites; the registration helper rejects any attempt to add `user`,
	// `user_id`, `email`, or `ip` as a label (Phase 3 D-03a).
	AuthAttempts *prometheus.CounterVec
}

AuthMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the Auth subsystem. One bag per Provider, constructed at cmd/nornicdb startup and injected into pkg/bolt and pkg/auth. Bolt's HELLO call site nil-checks before observing (Plan 04-02 contract); this plan supplies the non-nil bag.

Dual-access pattern (D-02): the raw *prometheus.CounterVec is exposed so subsystem tests can drive AssertCardinalityCeiling directly. The hot path (Inc) does not need a wrapper — counters do not emit exemplars in M1 (CONTEXT D-02e defers counter exemplars).

func NewAuthMetrics

func NewAuthMetrics(reg *prometheus.Registry) *AuthMetrics

NewAuthMetrics constructs the auth bag against reg.

No tenant flag — auth events are global per CONTEXT MET-21. Surfacing a `database` label on an unauthenticated counter would leak tenant identity at the K8s scrape boundary; the auth subsystem deliberately omits it.

Validation chain inherited from pkg/observability.NewCounterVec:

  • subsystem "auth" must be in allowedSubsystems (metrics.go line 59)
  • name must end in _total (registration.go validateNameSuffix)
  • labels must NOT include any ForbiddenLabel (registration.go validateLabels)

Pitfall 8 / MustRegister precedent: validation failure panics — programming bug surfaces at startup before any traffic.

type BoltMetrics

type BoltMetrics struct {
	// ConnectionsActive is the live connection count gauge labelled by
	// transport. Cardinality ceiling = 4 (len(AllowedBoltTransports)).
	ConnectionsActive *prometheus.GaugeVec

	// ConnectionsTotal counts connection terminations by result and
	// transport. Cardinality ceiling = 12 (3 results x 4 transports).
	ConnectionsTotal *prometheus.CounterVec

	// ConnectionsRejectedTotal counts connections rejected before the
	// session loop started — sniff timeouts, oversized messages, TLS
	// handshake failures, RequireTLS rejections, and so on. Closed enum
	// for the reason label (AllowedBoltConnectionRejectReasons).
	ConnectionsRejectedTotal *prometheus.CounterVec

	// WebSocketOversizedTotal counts WebSocket messages that exceeded
	// the configured frame size limit. Discrete because oversized
	// messages are a WS-only failure mode worth a dedicated alert.
	WebSocketOversizedTotal prometheus.Counter

	// SessionDuration histograms the wall-clock lifetime of each Bolt session
	// (accept → close). Phase-3-locked LatencyBucketsSeconds. No labels.
	SessionDuration *LatencyHistogram

	// MessagesTotal counts dispatched Bolt messages by op + result.
	// Cardinality ceiling = 30 (10 ops × 3 results; AllowedBoltOps ×
	// AllowedBoltResults — RESEARCH §Q11).
	MessagesTotal *prometheus.CounterVec

	// MessageDuration histograms the per-message dispatch duration.
	// Cardinality ceiling = 10 (len(AllowedBoltOps)).
	MessageDuration *LatencyHistogram

	// PackstreamDecodeErrors counts decode failures classified by closed
	// reason enum. Cardinality ceiling = 4 (len(AllowedPackstreamReasons);
	// RESEARCH §Q11). Free-form `err.Error()` MUST NEVER reach this Vec —
	// classification happens via `reasonFromError(err)` at the decode
	// boundary.
	PackstreamDecodeErrors *prometheus.CounterVec
}

BoltMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the Bolt subsystem. One bag per Provider; constructed at cmd/nornicdb startup and injected into pkg/bolt.Server via SetBoltMetrics(...) so the connection- accept goroutine and per-message dispatch loop observe through pre-bound observers (MET-25).

Hot-path discipline (MET-25): the Bolt server pre-builds a per-op `[]BoundLatencyObserver` indexed by AllowedBoltOps so the dispatch loop pays zero WithLabelValues overhead per message. The `BindMessageDuration(op)` helper amortizes the lookup at session construction (the per-message observe call uses the cached observer).

func NewBoltMetrics

func NewBoltMetrics(reg *prometheus.Registry) *BoltMetrics

NewBoltMetrics constructs the Bolt bag against reg.

Validation + Pitfall-8 panic semantics inherited from Phase 3 typed constructors: missing _total/_seconds suffixes or forbidden labels panic at registration.

Construction is idempotent against this bag's six families ONLY for a fresh registry — re-constructing on the same registry triggers AlreadyRegisteredError per Pitfall 8.

func (*BoltMetrics) BindMessageDuration

func (b *BoltMetrics) BindMessageDuration(op string) BoundLatencyObserver

BindMessageDuration returns a pre-bound BoundLatencyObserver for the given op. Used by pkg/bolt.Server to pre-build a per-op slice at session construction (MET-25 hot-path discipline) so the dispatch loop pays zero WithLabelValues overhead per message.

type BoundEmbeddingLatencyObserver

type BoundEmbeddingLatencyObserver struct {
	// contains filtered or unexported fields
}

BoundEmbeddingLatencyObserver is the pre-bound hot-path observer for EmbeddingLatencyHistogram.

func (BoundEmbeddingLatencyObserver) Observe

type BoundLatencyObserver

type BoundLatencyObserver struct {
	// contains filtered or unexported fields
}

BoundLatencyObserver is the struct-field-cacheable hot-path observer. Stateless beyond the embedded prometheus.Observer — concurrent Observe calls are race-clean per client_golang HistogramVec promise (RESEARCH §1/§4).

func (BoundLatencyObserver) Observe

func (b BoundLatencyObserver) Observe(ctx context.Context, sec float64)

Observe routes through the sampled-span ExemplarObserver chokepoint (D-02a). On Phase 1 NeverSample default ⇒ IsSampled()=false ⇒ zero exemplar overhead. On Phase 6 sampler flip ⇒ exemplars emit automatically.

type BoundRowCountObserver

type BoundRowCountObserver struct {
	// contains filtered or unexported fields
}

BoundRowCountObserver is the pre-bound hot-path observer for RowCountHistogram.

func (BoundRowCountObserver) Observe

func (b BoundRowCountObserver) Observe(ctx context.Context, rows float64)

type BoundSizeObserver

type BoundSizeObserver struct {
	// contains filtered or unexported fields
}

BoundSizeObserver is the pre-bound hot-path observer for SizeHistogram.

func (BoundSizeObserver) Observe

func (b BoundSizeObserver) Observe(ctx context.Context, bytes float64)

type CacheMetrics

type CacheMetrics struct {
	// Hits / Misses / SizeBytes use the {cache} label set; closed enum per
	// AllowedCacheNames.
	Hits      *prometheus.CounterVec
	Misses    *prometheus.CounterVec
	SizeBytes *prometheus.GaugeVec

	// Evictions uses the {cache, reason} label set; closed enums per
	// AllowedCacheNames × AllowedEvictionReasons (cardinality ≤ 16).
	Evictions *prometheus.CounterVec
	// contains filtered or unexported fields
}

CacheMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the Cache + Runtime subsystem. One bag per Provider; constructed once at cmd/nornicdb startup between Phase 1's "registries" and "listeners" init steps (D-02c). Subsystems receive the bag via DI and call `bag.Hits.WithLabelValues("query_result").Inc()` etc.

Hot-path discipline (MET-25): subsystem callers SHOULD pre-bind via `bag.Hits.WithLabelValues("query_result")` once at construction and cache the resulting prometheus.Counter in a struct field — Phase 3's BenchmarkObserve_Hot template applies (see exemplar_bench_test.go).

func NewCacheMetrics

func NewCacheMetrics(reg *prometheus.Registry) *CacheMetrics

NewCacheMetrics constructs the Cache + Runtime bag against reg.

Validation + Pitfall-8 panic semantics inherited from Phase 3 typed constructors: invalid subsystem names, missing _total/_bytes suffixes, or forbidden labels panic at registration. process_uptime_seconds and build_info register directly via prometheus.NewGaugeFunc (Phase 3's MetricOpts intentionally omits ConstLabels — CONTEXT D-13a uses GaugeFunc to side-step the omission).

Construction is idempotent against this bag's six families ONLY for a fresh registry — re-constructing on the same registry triggers AlreadyRegisteredError per Pitfall 8.

type CardinalityT

type CardinalityT interface {
	Helper()
	Errorf(format string, args ...interface{})
	FailNow()
}

CardinalityT is the minimal *testing.T-shaped surface that AssertCardinalityCeiling consumes. *testing.T satisfies it transparently; negative-falsifiability sub-tests can plug in an in-package fake that captures Errorf/FailNow without propagating failure into the parent *testing.T (Go's c.Fail() unconditionally walks c.parent.Fail() — there is no way to scope a *testing.T failure to a sub-test alone).

Mirrors github.com/stretchr/testify/require.TestingT plus Helper(); kept local so we don't take a public dependency on require's internal type.

type CheckFunc

type CheckFunc func(ctx context.Context) error

CheckFunc is the readiness probe contract.

Callers (storage, search, replication, ...) inject implementations from the composition root (cmd/nornicdb/main.go). This keeps pkg/observability a leaf in the import graph (OBS-01) — the registry holds func values, never types from business packages.

Implementations MUST be safe for concurrent use and MUST honor ctx cancellation; the registry runs checks in parallel and bounds each one with a per-check timeout.

type CheckOpts

type CheckOpts struct {
	// Required: when true, a failing check flips ReadyResult.OK to false (and
	// the /readyz handler returns 503). When false (the default), the failure
	// still appears in the JSON response but the overall status stays OK —
	// useful for informational probes (downstream service health, warmup
	// progress, etc.) that should not block kubelet rollouts.
	Required bool
	// Timeout is the per-check budget. Default 1s. A check that exceeds its
	// budget reports the deadline error and Ready returns promptly.
	Timeout time.Duration
}

CheckOpts configures a single registered check.

type CheckStatus

type CheckStatus struct {
	OK      bool   `json:"ok"`
	Latency int64  `json:"latency_ms"`
	Error   string `json:"error,omitempty"`
}

CheckStatus is one entry in ReadyResult.Checks.

The JSON contract is intentionally narrow: ok, latency_ms, and an optional error string. Phase 1 deliberately omits the `progress` field that K8S-06 will add — operators reading Phase-1 /readyz must not see it; tests (TestHealth_RenderJSON_HasNoProgressFieldInPhase1) enforce the omission.

type CypherMetrics

type CypherMetrics struct {
	// Queries is the per-op_type query-count counter; same label-set as
	// QueryDuration so the `_total` and `_seconds_count` series have
	// identical cardinality.
	Queries *prometheus.CounterVec

	// QueryDuration is a latency histogram with Phase-3-locked buckets
	// (LatencyBucketsSeconds: ~100us to 10s). Labels: {op_type[, database]}.
	QueryDuration *LatencyHistogram

	// PlannerDuration is the latency histogram for the planning step
	// (parse + plan + cache lookup); same buckets as QueryDuration.
	// Labels: {op_type} only — planner cost is the same regardless of
	// tenant. Tenant-flag-independent surface.
	PlannerDuration *LatencyHistogram

	// PlannerCacheHits / PlannerCacheMisses are the planner-specific
	// cache counters per D-12a (Cypher subsystem owns these — NOT the
	// cross-cutting cache subsystem).
	PlannerCacheHits   *prometheus.CounterVec
	PlannerCacheMisses *prometheus.CounterVec

	// PlannerCacheSize is the gauge tracking current planner cache
	// occupancy. Set by pkg/cypher/cache.go on Get/Put/Evict.
	PlannerCacheSize prometheus.Gauge

	// RowsReturned is the result-set-size histogram with Phase-3-locked
	// buckets (RowCountBuckets: 1 to 1M). Labels: {op_type}.
	RowsReturned *RowCountHistogram

	// ActiveTransactions is the gauge of currently-open Cypher
	// transactions. Inc'd at transaction Begin; Dec'd at Commit/Rollback.
	ActiveTransactions prometheus.Gauge

	// TransactionConflicts counts ErrConflict events surfaced from the
	// storage layer (D-16: storage detects, Cypher counts — preserves
	// AGENTS.md §8 separation). Labels: {[database]}.
	TransactionConflicts *prometheus.CounterVec

	// SlowQueries counts queries that exceeded the slow-query threshold
	// (matches the Phase 2 D-04c slow-query log emission gate).
	// Labels: {[database]}.
	SlowQueries *prometheus.CounterVec
	// contains filtered or unexported fields
}

CypherMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the Cypher subsystem. One bag per Provider; constructed at cmd/nornicdb startup between Phase 1's "registries" and "listeners" init steps (D-02c).

Hot-path discipline (MET-25): the Cypher executor (pkg/cypher/executor.go) is the SOLE call site; it threads (op_type[, database]) through the `BindQueryDuration` / `BindQueries` helpers which return tenant-flag- agnostic Bound observers. StorageExecutor caches BoundLatencyObserver in struct fields for the high-frequency op_types (read, write).

Dual-access pattern (D-02): the bag exposes the raw *prometheus.CounterVec and Phase-3 typed *LatencyHistogram / *RowCountHistogram so subsystem tests can drive AssertCardinalityCeiling and edge cases.

MET-26 slow_query_threshold_seconds is NOT a struct field — it's a GaugeFunc registered directly on reg. The callback (slowQueryThresholdFn) reads cfg.Logging.SlowQueryThreshold().Seconds() on every scrape so config reload flows through automatically (D-15b). RISK-8 / Pitfall 1: callback wraps a defer-recover that returns 0 on panic.

func NewCypherMetrics

func NewCypherMetrics(reg *prometheus.Registry, tenantLabelsEnabled bool, slowQueryThresholdFn func() float64) *CypherMetrics

NewCypherMetrics constructs the Cypher bag against reg.

tenantLabelsEnabled is the D-08 forward-compat hook. When true, the `database` label is included in Queries, QueryDuration, TransactionConflicts, SlowQueries; when false, it is omitted. Phase 5's K8s autodetect (MET-22) decides the value.

slowQueryThresholdFn is the D-15b live-read callback for the slow_query_threshold_seconds GaugeFunc — typically reads cfg.Logging.SlowQueryThreshold().Seconds(). The callback is wrapped in a defer-recover that returns 0 on panic (RESEARCH RISK-8 / Pitfall 1) so a buggy callback cannot poison the entire /metrics scrape.

Validation + Pitfall-8 panic semantics inherited from Phase 3 typed constructors: missing _total/_seconds/_rows suffixes or forbidden labels (e.g. accidentally passing "query" as a label) panic at registration.

func (*CypherMetrics) BindQueries

func (c *CypherMetrics) BindQueries(opType, database string) prometheus.Counter

BindQueries returns a pre-bound prometheus.Counter for the (opType, database) tuple. Tenant-flag-aware per BindQueryDuration above.

func (*CypherMetrics) BindQueryDuration

func (c *CypherMetrics) BindQueryDuration(opType, database string) BoundLatencyObserver

BindQueryDuration returns a pre-bound BoundLatencyObserver for the (opType, database) tuple. When the bag was constructed with tenantLabelsEnabled=false, the database arg is dropped. Subsystems are bool-agnostic and pass database unconditionally.

Hot-path discipline (MET-25): subsystem callers SHOULD hoist the Bind call out of the request loop (cache the BoundLatencyObserver in a struct field at construction). Per-call BindQueryDuration still pays a WithLabelValues lookup.

func (*CypherMetrics) BindSlowQueries

func (c *CypherMetrics) BindSlowQueries(database string) prometheus.Counter

BindSlowQueries returns a pre-bound prometheus.Counter for the (database) tuple — emitted at the Phase 2 D-04c slow-query gate alongside the existing slow-query log record. Tenant-flag-aware.

func (*CypherMetrics) BindTransactionConflicts

func (c *CypherMetrics) BindTransactionConflicts(database string) prometheus.Counter

BindTransactionConflicts returns a pre-bound prometheus.Counter for the (database) tuple — D-16 wiring site at the Cypher transaction wrapper where storage's ErrConflict surfaces. Tenant-flag-aware: drops the database arg when the bag was constructed with tenantLabelsEnabled=false.

func (*CypherMetrics) ObserveQueryDuration

func (c *CypherMetrics) ObserveQueryDuration(ctx context.Context, opType, database string, sec float64)

ObserveQueryDuration is a thin convenience wrapper around BindQueryDuration().Observe — used by tests and cold paths. Hot-path callers should hoist Bind calls out of the request loop.

func (*CypherMetrics) TenantLabelsEnabled

func (c *CypherMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled. Read-only after construction.

type EmbedMetrics

type EmbedMetrics struct {
	// Processed counts embedding outcomes per (provider, model, result, mode).
	// Cardinality ceiling 250 per RESEARCH §Q11 (4 providers × ~30 models ×
	// 3 results × ~5 modes; over-provisioned to absorb new providers).
	Processed *prometheus.CounterVec

	// Duration is the per-call latency histogram with long-tail buckets
	// (EmbeddingLatencyBucketsSeconds, tail to 600s for slow local llama).
	// Hot-path: pre-bound via Duration.Bind(provider, model, mode) cached
	// in caller struct fields per MET-25.
	Duration *EmbeddingLatencyHistogram

	// CacheHits / CacheMisses count embed-cache outcomes — provider-agnostic
	// (the cache is a generic LRU keyed by hash). Bridges into the
	// cross-cutting Cache bag (Plan 04-01) at the cached_embedder.go call
	// sites. No labels — single flat counter pair per CONTEXT MET-12.
	CacheHits   prometheus.Counter
	CacheMisses prometheus.Counter

	// WorkerRunning is the binary lifecycle gauge (Set(0) on stop, Set(1)
	// on start). Per D-15b distinction: NOT a GaugeFunc — the lifecycle
	// event boundary is the right observation chokepoint.
	WorkerRunning prometheus.Gauge

	// FFIPanicTotal counts CGo / purego panics recovered in the FFI call
	// site wrapper (D-09; pkg/embed/ffi_recover.go). Mode = the build-tag
	// backend at the time of panic; closed enum.
	FFIPanicTotal *prometheus.CounterVec
}

EmbedMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the embeddings subsystem. One bag per Provider; constructed at cmd/nornicdb startup. The mode label is bound at observation time using the embedder's Backend() value (D-06).

Hot-path discipline (MET-25): subsystem callers cache BoundEmbeddingLatencyObserver in struct fields at constructor time so per-call observation pays zero WithLabelValues lookup overhead. The processed_total CounterVec is incremented via `WithLabelValues(...)` at the embed completion site.

Dual-access pattern (D-02): the bag exposes raw *prometheus.CounterVec / Gauge / *EmbeddingLatencyHistogram so subsystem tests can drive AssertCardinalityCeiling and edge cases.

func NewEmbedMetrics

func NewEmbedMetrics(reg *prometheus.Registry, probe EmbedProbe) *EmbedMetrics

NewEmbedMetrics constructs the embeddings bag against reg.

probe is the EmbedProbe accessor surface — typically the EmbedWorker or a thin adapter wrapping it. nil is tolerated (queue_depth GaugeFunc returns 0). The probe is consulted on every /metrics scrape; the callback wraps defer-recover so a probe panic does not poison the scrape (RISK-8 / Pitfall 1).

No tenantLabelsEnabled parameter: embed families are global per CONTEXT MET-21 omission (provider/model/mode are not per-DB attributes; per-DB embedding latency lives at the Cypher subsystem if needed).

type EmbedProbe

type EmbedProbe interface {
	QueueLen() int
}

EmbedProbe is the seam between pkg/nornicdb embed-queue accessors and the observability queue_depth GaugeFunc callback (D-02d leaf-package boundary — pkg/observability never imports pkg/nornicdb or pkg/embed). The embed worker (or a thin adapter at cmd/nornicdb) satisfies this interface.

QueueLen returns the current number of nodes pending embedding. The pull-based EmbedWorker model means this is typically 0 in steady state; during back-pressure / cold start it surfaces the real depth. Defensive callers may return 0 if no metric source is available.

type EmbeddingLatencyHistogram

type EmbeddingLatencyHistogram struct {
	// contains filtered or unexported fields
}

EmbeddingLatencyHistogram wraps *prometheus.HistogramVec for LLM/embedding latency families (MET-05 long-tail buckets). See LatencyHistogram for design notes.

func NewEmbeddingLatencyHistogram

func NewEmbeddingLatencyHistogram(reg *prometheus.Registry, opts MetricOpts, labels []string) *EmbeddingLatencyHistogram

func (*EmbeddingLatencyHistogram) Bind

func (*EmbeddingLatencyHistogram) Observe

func (h *EmbeddingLatencyHistogram) Observe(ctx context.Context, lvs []string, sec float64)

func (*EmbeddingLatencyHistogram) Vec

type FilteredBaggagePropagator

type FilteredBaggagePropagator struct {
	propagation.Baggage
}

FilteredBaggagePropagator wraps propagation.Baggage and strips keys not in BaggageAllowList on Extract. Inject is unmodified (we only control inbound).

func (FilteredBaggagePropagator) Extract

type HTTPMetrics

type HTTPMetrics struct {
	// RequestDuration is a latency histogram with Phase-3-locked buckets
	// (LatencyBucketsSeconds: ~100us to 10s).
	RequestDuration *LatencyHistogram

	// Requests is the request count counter; same label-set as RequestDuration
	// so the `_total` and `_seconds_count` series have identical cardinality.
	Requests *prometheus.CounterVec

	// InFlight is the active-request gauge; instrumentedMux Inc() on entry,
	// Dec() on exit (deferred). Single value, no labels (cardinality=1).
	InFlight prometheus.Gauge

	// RequestBodyBytes / ResponseBodyBytes are payload-size histograms with
	// Phase-3-locked buckets (SizeBucketsBytes: 64B to 64MB).
	RequestBodyBytes  *SizeHistogram
	ResponseBodyBytes *SizeHistogram
	// contains filtered or unexported fields
}

HTTPMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the HTTP subsystem. One bag per Provider; constructed at cmd/nornicdb startup between Phase 1's "registries" and "listeners" init steps.

Hot-path discipline (MET-25): the instrumentedMux chokepoint (pkg/server/server.go) is the SOLE call site; it threads (method, template, status_class[, database]) through the `BindRequestDuration` / `BindRequests` helpers which return tenant-flag- agnostic Bound observers. Per-template Bind cache lives at the chokepoint (not in the bag) because path_template is not known until route resolution.

Dual-access pattern (D-02): the bag exposes the raw *prometheus.CounterVec and Phase-3 typed *LatencyHistogram / *SizeHistogram so subsystem tests can drive AssertCardinalityCeiling and edge cases.

func NewHTTPMetrics

func NewHTTPMetrics(reg *prometheus.Registry, tenantLabelsEnabled bool) *HTTPMetrics

NewHTTPMetrics constructs the HTTP bag against reg.

tenantLabelsEnabled is the D-08 forward-compat hook. When true, the `database` label is included in RequestDuration and Requests; when false, it is omitted. Phase 5's K8s autodetect (MET-22) decides the value.

Validation + Pitfall-8 panic semantics inherited from Phase 3 typed constructors: missing _total/_seconds/_bytes suffixes or forbidden labels (e.g. accidentally passing "path" instead of "path_template") panic at registration.

func (*HTTPMetrics) BindRequestDuration

func (h *HTTPMetrics) BindRequestDuration(method, template, statusClass, database string) BoundLatencyObserver

BindRequestDuration returns a pre-bound BoundLatencyObserver for the (method, template, statusClass, database) tuple. When the bag was constructed with tenantLabelsEnabled=false, the database arg is dropped. Subsystems are bool-agnostic and pass database unconditionally.

Hot-path discipline (MET-25): the per-template Bind cache lives at the instrumentedMux chokepoint, NOT inside this helper — calling BindRequestDuration per request still pays a WithLabelValues lookup. The chokepoint caches the BoundLatencyObserver in a sync.Map keyed by the label tuple so per-request cost is amortized to near-zero.

func (*HTTPMetrics) BindRequests

func (h *HTTPMetrics) BindRequests(method, template, statusClass, database string) prometheus.Counter

BindRequests returns a pre-bound prometheus.Counter for the (method, template, statusClass, database) tuple. Tenant-flag-aware per BindRequestDuration above.

func (*HTTPMetrics) ObserveRequestDuration

func (h *HTTPMetrics) ObserveRequestDuration(ctx context.Context, method, template, statusClass, database string, sec float64)

ObserveRequestDuration is a thin convenience wrapper around BindRequestDuration().Observe — used by tests and cold paths. Hot-path callers should hoist Bind calls out of the request loop.

func (*HTTPMetrics) TenantLabelsEnabled

func (h *HTTPMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled. Read-only after construction.

type Health

type Health struct {
	// contains filtered or unexported fields
}

Health is the readiness check registry served by /readyz.

Lookup is read-heavy (every kubelet probe — every few seconds in production) and registration is one-shot at startup (or once per t.Cleanup in tests), so sync.RWMutex matches the access pattern. Tests stress concurrent register/deregister/Ready under -race.

func NewHealth

func NewHealth() *Health

NewHealth constructs an empty registry.

func (*Health) Deregister

func (h *Health) Deregister(name string)

Deregister removes a check by name. Deregistering an unknown name is a no-op (idempotent — supports test teardown via t.Cleanup without coupling to registration order).

func (*Health) Ready

func (h *Health) Ready(ctx context.Context) ReadyResult

Ready runs every registered check in parallel and aggregates results.

Per-check timeout (CheckOpts.Timeout, default 1s) is enforced via a child context derived from ctx. The aggregate `OK` is true iff every REQUIRED check passed; informational (Required=false) failures appear in the Checks map but don't flip OK.

Implementation note: a registry snapshot under RLock, then run checks without holding the lock so a concurrent Register/Deregister can't be blocked by a slow check. This matches the access-pattern documentation on Health and is safe under -race.

func (*Health) Register

func (h *Health) Register(name string, fn CheckFunc, opts ...CheckOpts)

Register adds (or replaces) a check by name.

Re-registering the same name OVERWRITES the previous entry — this is the idiomatic behavior for t.Cleanup re-registration in tests and for hot-reload scenarios in long-running daemons. Concurrent-safe.

If opts is omitted (variadic empty), the zero value is used: Required=false, Timeout=1s default.

type KnowledgePolicyMetrics

type KnowledgePolicyMetrics struct {
	// Scored counts scoring evaluations by entity_kind × result.
	Scored *prometheus.CounterVec

	// DecayScore is a 0.0..1.0 score distribution sampled 1/32. See
	// ObserveDecayScoreSampled for the chokepoint.
	DecayScore *prometheus.HistogramVec

	// Suppressions counts suppression events by entity_kind × reason.
	Suppressions *prometheus.CounterVec

	// AccessFlushBatchRows observes the row count of each AccessFlusher
	// flush — reused RowCountBuckets (1..1M).
	AccessFlushBatchRows *RowCountHistogram

	// AccessFlushDuration observes end-to-end flush wall-clock time.
	AccessFlushDuration *LatencyHistogram

	// OnAccessMutations counts on-access policy evaluations by result.
	OnAccessMutations *prometheus.CounterVec

	// DeindexEnqueued counts visibility-flip deindex enqueues.
	DeindexEnqueued *prometheus.CounterVec

	// ReadFilterDropped counts read-path filter suppressions (called on
	// every node/edge returned by Badger when decay is enabled).
	ReadFilterDropped *prometheus.CounterVec

	// Reconcile counts policy-change reconcile passes by trigger.
	Reconcile *prometheus.CounterVec
	// contains filtered or unexported fields
}

KnowledgePolicyMetrics is the typed handle-bag for the knowledge_policy subsystem. One bag per Provider; constructed at cmd/nornicdb startup and attached to the Scorer/AccessFlusher through constructor injection, plus published to the pkg/storage read-path filter via SetKnowledgePolicyMetrics.

func GetKnowledgePolicyMetrics

func GetKnowledgePolicyMetrics() *KnowledgePolicyMetrics

GetKnowledgePolicyMetrics returns the currently-published metrics handle, or nil if none has been set. Callers MUST nil-check the return value (all Inc* / Observe* methods on *KnowledgePolicyMetrics are nil-safe, so the idiomatic pattern is `observability.GetKnowledgePolicyMetrics(). IncReadFilterDropped("node", "")` without a separate nil branch).

func NewKnowledgePolicyMetrics

func NewKnowledgePolicyMetrics(
	reg *prometheus.Registry,
	tenantLabelsEnabled bool,
	bufferFullnessFn func() float64,
) *KnowledgePolicyMetrics

NewKnowledgePolicyMetrics constructs the knowledge-policy bag against reg.

bufferFullnessFn is the passive-scrape callback for access_flush_buffer_fullness — typically reads len(accumulator.buffer)/maxBufferSize from the current AccessFlusher. The callback is wrapped in a defer-recover that returns 0 on panic (RISK-8 / Pitfall 1) so a buggy callback cannot poison the scrape.

Validation + Pitfall-8 panic semantics inherited from Phase 3 typed constructors: missing suffix, forbidden labels, or invalid subsystem panic at registration.

func (*KnowledgePolicyMetrics) IncDeindexEnqueued

func (k *KnowledgePolicyMetrics) IncDeindexEnqueued(entityKind, database string)

IncDeindexEnqueued increments deindex_enqueued_total for the entity_kind.

func (*KnowledgePolicyMetrics) IncOnAccess

func (k *KnowledgePolicyMetrics) IncOnAccess(result, database string)

IncOnAccess increments on_access_mutations_total for the given result.

func (*KnowledgePolicyMetrics) IncReadFilterDropped

func (k *KnowledgePolicyMetrics) IncReadFilterDropped(entityKind, database string)

IncReadFilterDropped increments read_filter_dropped_total. Called from the storage layer via the atomic-pointer bridge (GetKnowledgePolicyMetrics).

func (*KnowledgePolicyMetrics) IncReconcile

func (k *KnowledgePolicyMetrics) IncReconcile(trigger, database string)

IncReconcile increments reconcile_total for the trigger.

func (*KnowledgePolicyMetrics) IncScored

func (k *KnowledgePolicyMetrics) IncScored(entityKind, result, database string)

IncScored increments scored_total for the given (entity_kind, result) tuple. Tenant-flag-aware: database arg dropped when tenantLabelsEnabled is false. Callers pass the database unconditionally.

func (*KnowledgePolicyMetrics) IncSuppression

func (k *KnowledgePolicyMetrics) IncSuppression(entityKind, reason, database string)

IncSuppression increments suppressions_total for the given tuple.

func (*KnowledgePolicyMetrics) ObserveAccessFlushBatchRows

func (k *KnowledgePolicyMetrics) ObserveAccessFlushBatchRows(ctx context.Context, rows float64)

ObserveAccessFlushBatchRows records one batch-size sample.

func (*KnowledgePolicyMetrics) ObserveAccessFlushDuration

func (k *KnowledgePolicyMetrics) ObserveAccessFlushDuration(ctx context.Context, sec float64)

ObserveAccessFlushDuration records one flush-duration sample.

func (*KnowledgePolicyMetrics) ObserveDecayScoreSampled

func (k *KnowledgePolicyMetrics) ObserveDecayScoreSampled(
	ctx context.Context, entityKind, database string, score float64,
)

ObserveDecayScoreSampled samples the decay_score histogram at 1/ DecayScoreSampleDenominator. Callers invoke this on every score evaluation; the helper drops ~31/32 without a lock or per-goroutine RNG state so the hot path pays only a single atomic increment and a bitmask test.

Using a uniform deterministic sampler (rather than rand.IntN) produces a slightly biased sample but the distribution shape is preserved at the histogram-bucket level — which is all operators care about.

func (*KnowledgePolicyMetrics) TenantLabelsEnabled

func (k *KnowledgePolicyMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled. Read-only after construction.

type LatencyHistogram

type LatencyHistogram struct {
	// contains filtered or unexported fields
}

LatencyHistogram wraps *prometheus.HistogramVec to centralize the MET-24 exemplar-emission chokepoint for latency histograms. Subsystems use this wrapper for normal Observe paths; the raw *HistogramVec remains accessible via Vec() for cardinality-ceiling assertions (D-02 dual-access pattern).

func NewLatencyHistogram

func NewLatencyHistogram(reg *prometheus.Registry, opts MetricOpts, labels []string) *LatencyHistogram

NewLatencyHistogram constructs the wrapper around a fresh NewLatencyHistogramVec. Validation + Pitfall-8 panic semantics inherited from metrics.go.

func (*LatencyHistogram) Bind

Bind returns a pre-bound observer cached as a struct field at construction time (MET-25). WithLabelValues lookup amortized away from request-loop hot path. The returned BoundLatencyObserver is value-typed (no pointer alloc).

func (*LatencyHistogram) Observe

func (h *LatencyHistogram) Observe(ctx context.Context, lvs []string, sec float64)

Observe is the cold-path entry: pays a WithLabelValues lookup per call. Funnels through observeWithExemplar (D-02a chokepoint).

func (*LatencyHistogram) Vec

Vec returns the underlying *prometheus.HistogramVec — used by Phase 4 subsystem tests for AssertCardinalityCeiling and edge-case observation (D-02 dual-access pattern; <specifics> §6).

type LoggerConfig

type LoggerConfig struct {
	Level  string
	Format string
	Output string
}

LoggerConfig is the local mirror of the relevant subset of pkg/config.LoggingConfig. We can't import pkg/config here because pkg/config already imports pkg/observability for ObservabilityConfig (would create an import cycle). The runServe site translates between the two structs.

Fields:

  • Level: "debug" / "info" / "warn" / "error" (case-insensitive). Default: info.
  • Format: "json" (default) or "text". Phase 2 only ships JSON; the field is held for forward compat with future TextHandler dev mode.
  • Output: "stdout" (default), "stderr", or a filesystem path.

type MVCCMetrics

type MVCCMetrics struct {
	// PressureBand holds one indicator gauge per (database, band) tuple.
	// Use UpdateBand(database, ratio) to set the active band to 1 and
	// reset the other three to 0 for the same database.
	PressureBand *prometheus.GaugeVec
	// contains filtered or unexported fields
}

MVCCMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the MVCC subsystem. One bag per Provider; constructed at cmd/nornicdb startup.

PressureBand is exposed as a struct field so subsystem callers (pkg/storage at MVCC reader open/close sites) can call `bag.UpdateBand(database, ratio)` to flip the active band gauge. The three live-read gauges (pinned_bytes / oldest_reader_age_seconds / active_readers) are NOT struct fields — they are GaugeFunc registrations that read the probe on every scrape. RESEARCH Pattern 1.

func NewMVCCMetrics

func NewMVCCMetrics(reg *prometheus.Registry, tenantLabelsEnabled bool, probe MVCCProbe) *MVCCMetrics

NewMVCCMetrics constructs the MVCC bag against reg.

tenantLabelsEnabled (D-08) decides whether `database` is included in the pressure_band label-set. The three GaugeFunc gauges have no labels.

probe is the MVCCProbe accessor surface — typically *BadgerEngine. nil is tolerated as a defensive fallback (returns 0 from each gauge); the RISK-2 accessors are always safe to call so this is paranoia, not a real fallback path in production.

func (*MVCCMetrics) TenantLabelsEnabled

func (m *MVCCMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled.

func (*MVCCMetrics) UpdateBand

func (m *MVCCMetrics) UpdateBand(database string, ratio float64)

UpdateBand sets the indicator gauge for the active band to 1 and resets the other three bands to 0 for the same database. Threshold mapping per D-14 — callers pass the raw ratio (PinnedBytes / MVCCBudgetBytes) and this helper picks the band.

When the bag was constructed with tenantLabelsEnabled=false, the database arg is dropped at the WithLabelValues call site.

type MVCCProbe

type MVCCProbe interface {
	PinnedBytes() int64
	OldestReaderAgeSeconds() float64
	ActiveReaders() int64
}

MVCCProbe is the seam between pkg/storage MVCC accessors and the observability GaugeFunc callbacks (D-02d leaf-package boundary — pkg/observability never imports pkg/storage). *BadgerEngine satisfies this interface via Plan 04-04-01 (RISK-2 fix).

Plan 04-01 Wave-0 published this interface stub so the RED tests compile; Plan 04-04 ships the production GREEN bag here. The signatures are stable across both — int64 and float64 to keep the wire-shape trivial and panic-free even when the engine is in shutdown.

type MetricOpts

type MetricOpts struct {
	Subsystem string // MUST be one of allowedSubsystems (D-01d — declared below in this file)
	Name      string // MUST end in _total | _seconds | _bytes | _rows per type (MET-02)
	Help      string // MUST be non-empty (client_golang convention)
}

MetricOpts carries the helper-injected Subsystem/Name/Help triple (CONTEXT D-01). Namespace is always "nornicdb" — caller cannot override (D-01b). ConstLabels intentionally omitted in M1; see CONTEXT.md "Deferred Ideas" for rationale.

type MetricsConfig

type MetricsConfig struct {
	// Enabled defaults to true. When false, pkg/observability.New returns a
	// Provider with a nil registry and the Plan-03 listener does NOT register
	// the /metrics handler (OBS-04).
	Enabled bool
	// Listen is the bind address for the telemetry mux. Default ":9090".
	Listen string
	// TenantLabelsEnabled is the resolved per-process tenant-labels switch.
	// Phase 5 startup hook (cmd/nornicdb/main.go) writes this from the
	// explicit YAML value (TenantLabelsExplicit) or K8s autodetect
	// (DefaultK8sProbe + ResolveTenantLabels) BEFORE any Phase 4 bag
	// constructor reads it. Bag constructors continue to read this bool
	// directly per Phase 4 D-08 plumbing — do NOT bypass them by reading
	// TenantLabelsExplicit. Default false (D-02c) until startup hook runs.
	TenantLabelsEnabled bool
	// TenantLabelsExplicit is the operator's YAML intent before defaulting.
	// nil = field omitted in YAML; non-nil = operator explicitly set true
	// or false. Phase 5 ResolveTenantLabels reads this to enforce
	// precedence (explicit YAML > K8s autodetect > default false).
	// R-02: this sentinel is the smallest blast-radius change to allow
	// YAML "explicit false" to win over autodetect "true on K8s".
	TenantLabelsExplicit *bool
}

MetricsConfig governs the :9090/metrics surface.

type ObservabilityConfig

type ObservabilityConfig struct {
	// Metrics controls the :9090 Prometheus surface.
	Metrics MetricsConfig
	// Tracing controls the OTLP trace exporter.
	Tracing TracingConfig
	// Pprof controls the optional :9091 pprof listener.
	Pprof PprofConfig
}

ObservabilityConfig is the root telemetry config block bound from nornicdb.yaml's `observability:` section and overlaid with NORNICDB_* env vars.

func DefaultConfig

func DefaultConfig() ObservabilityConfig

DefaultConfig returns the Phase-1 defaults. Operators may override any field via YAML or NORNICDB_* env vars.

func (*ObservabilityConfig) ApplyEnv

func (c *ObservabilityConfig) ApplyEnv()

ApplyEnv overlays NORNICDB_* env vars onto c. Env vars take precedence over YAML-set fields (env > YAML > default), per OBS-02.

OTEL_EXPORTER_OTLP_* env vars are intentionally NOT consumed here — they are read on-demand by OTLPEndpoint() at exporter-init time, otherwise the resolved value would be frozen at config-load and break OBS-12 precedence.

type ParentMode

type ParentMode string

ParentMode controls how the sampler treats upstream parent-span decisions.

ParentModeNone (the v1 default per TRC-05, KD-05): sampling is a standalone TraceIDRatioBased(ratio). The storage layer controls its own trace volume; upstream samplers cannot drive NornicDB to 100% volume.

ParentModeCapped (TRC-06): honors upstream sampled=true up to a QPS cap, then falls back to the ratio-based sampler for additional sampled-parent spans. Operators that want *some* upstream honor without unbounded volume.

ParentModeStrict (TRC-07): full upstream honor. Equivalent to OTel ParentBased + TraceIDRatioBased(ratio). Emits a WARN at startup about unbounded volume risk.

const (
	ParentModeNone   ParentMode = ""
	ParentModeCapped ParentMode = "capped"
	ParentModeStrict ParentMode = "strict"
)

type PeerConfigLike

type PeerConfigLike interface {
	GetID() string
	GetAddr() string
}

PeerConfigLike is the minimal accessor surface this package needs from pkg/replication.PeerConfig (D-02d boundary indirection). The replication package satisfies it with a tiny adapter over its concrete PeerConfig struct — keeps pkg/observability free of pkg/replication dependencies.

We use accessor methods rather than struct fields because Go's structural typing matches by method set (struct field equality across packages requires identical declarations, which would force a shared type and reintroduce the import cycle).

type PprofConfig

type PprofConfig struct {
	// Enabled gates whether the pprof listener is started.
	Enabled bool
	// Listen is the bind address. Default "127.0.0.1:9091" per ADR §A9 to
	// keep pprof off non-loopback interfaces by default.
	Listen string
}

PprofConfig governs the optional :9091 debug surface.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider is the entry point for all observability surfaces. Plan 03 listeners and Plan 04 main.go consume Provider via its accessors.

Provider is goroutine-safe for read; mutation after construction is forbidden.

func New

func New(ctx context.Context, cfg ObservabilityConfig, info ServiceInfo, logger *slog.Logger, writerRef io.Writer) (*Provider, error)

New constructs a *Provider following the OBS-03 init order:

  1. resource attributes (service.name/version/instance.id resolved via OBS-10 chain).
  2. Prometheus registry + OTel→Prom bridge (skipped when cfg.Metrics.Enabled=false — OBS-04).
  3. TracerProvider (SDK + BSP + OTLP exporter, OR noop on failure — OBS-11).

New NEVER returns a non-nil error from OTLP failure — telemetry init failure is logged at WARN and a noop tracer provider is installed. Process startup is unconditionally robust against observability misconfiguration.

The provided ctx bounds OTLP exporter dial. A context-with-timeout derived from cfg.Tracing.Timeout (default 5s) further bounds the dial so a misconfigured collector cannot hang startup (Pitfall 2).

Per D-08 two-phase bootstrap: the caller MUST call observability.NewLogger BEFORE this function so logger / writerRef can be threaded through. logger MAY be nil for legacy callers (provider falls back to a discard logger); writerRef MAY be nil (no Sync attempt during Shutdown).

func (*Provider) Config

func (p *Provider) Config() ObservabilityConfig

Config returns a copy of the construction-time config.

func (*Provider) InstanceID

func (p *Provider) InstanceID() string

InstanceID returns the resolved service.instance.id (OBS-10).

func (*Provider) InstanceIDSource

func (p *Provider) InstanceIDSource() string

InstanceIDSource returns the resolution leg that fired ("config", "POD_NAME", "hostname", or "fallback"). Useful for Plan 03 /version handler.

func (*Provider) Logger

func (p *Provider) Logger() *slog.Logger

Logger returns the production *slog.Logger that downstream business packages (pkg/server, pkg/cypher, pkg/storage, pkg/bolt) consume per the D-01 constructor-injection pattern. Returns nil if the Provider was constructed via a legacy code path that did not pass a logger; callers SHOULD nil-guard with `slog.New(slog.NewTextHandler(io.Discard, nil))` as a fallback.

func (*Provider) MeterProvider

func (p *Provider) MeterProvider() *sdkmetric.MeterProvider

MeterProvider returns the OTel meter provider. nil when metrics disabled.

func (*Provider) MetricsEnabled

func (p *Provider) MetricsEnabled() bool

MetricsEnabled mirrors cfg.Metrics.Enabled (OBS-04).

func (*Provider) Registry

func (p *Provider) Registry() *prometheus.Registry

Registry returns the Prometheus registry. nil when metrics disabled (OBS-04). Plan 03 listener uses this nil-ness to skip /metrics handler registration.

func (*Provider) Shutdown

func (p *Provider) Shutdown(ctx context.Context) error

Shutdown flushes the BSP and shuts down the meter provider. Idempotent in the sense that it can be called multiple times safely; the underlying SDK providers are themselves idempotent on Shutdown.

Called by the telemetry listener's Shutdown in Plan 03 (per Open Question 4 resolution — the lifecycle.Component owns the Provider's flush budget).

func (*Provider) TracerProvider

func (p *Provider) TracerProvider() trace.TracerProvider

TracerProvider returns the tracer provider. Always non-nil; may be a noop (when cfg.Tracing.Enabled=false OR OTLP exporter init failed — OBS-11).

type ReadyResult

type ReadyResult struct {
	OK     bool                   `json:"ok"`
	Checks map[string]CheckStatus `json:"checks"`
}

ReadyResult is the JSON response body for /readyz.

The shape is locked by D-03 (CONTEXT.md): top-level {ok, checks} where `checks` is a map keyed by check name. Phase 9 (K8S-06) will add an optional `progress` field to CheckStatus as an additive non-breaking change — Phase 1 must NOT emit it.

type ReplicationMetrics

type ReplicationMetrics struct {
	// Role is the current role gauge. Published as a numeric enum via
	// Set(RoleEnum(roleString)) at lifecycle transition log sites (D-15a).
	// Per-cluster, no labels — there is exactly one role per process.
	Role prometheus.Gauge

	// Term is the Raft term gauge. Set at term-change log sites (D-15a).
	Term prometheus.Gauge

	// CommitIndex is the Raft last-committed-log-index gauge. Set at
	// commit-advance log sites (D-15a).
	CommitIndex prometheus.Gauge

	// ApplyIndex is the Raft last-applied-log-index gauge. Set at
	// apply-advance log sites (D-15a).
	ApplyIndex prometheus.Gauge

	// LagBytes is the per-peer replication-lag-bytes gauge. Hot-path —
	// observed at every replicate/heartbeat call. peer label is stable
	// per PeerLabel (RISK-3 fix). Mode-aware ceiling stored in modeCeiling.
	LagBytes *prometheus.GaugeVec

	// LagEntries is the per-peer replication-lag-entries gauge. Same
	// observation cadence as LagBytes.
	LagEntries *prometheus.GaugeVec

	// ApplyDuration is the histogram for command-apply latency. No labels —
	// per-cluster distribution. Pre-bound observer cached in
	// pkg/replication.Replicator struct field per MET-25.
	ApplyDuration *LatencyHistogram

	// RTTSeconds is the per-peer round-trip-time histogram. Observed at
	// AppendEntries response chokepoint. Hot-path — pre-bound per peer.
	RTTSeconds *prometheus.HistogramVec

	// LeaderChangesTotal counts every role transition that crosses the
	// leader boundary (non-leader→leader OR leader→non-leader). The
	// keystone alert metric for cluster instability — increments at the
	// SAME sites that emit "became leader" / "stepped down" log lines.
	LeaderChangesTotal prometheus.Counter

	// LastContactSeconds is the GAP-1 gauge — wall-clock seconds since the
	// last successful AppendEntries from a peer (or sent to a peer for
	// leader perspective). Operators alert on
	// `time() - nornicdb_replication_last_contact_seconds{peer="..."} > N`
	// per Phase 9 Helm/Grafana plan. Per-peer; populated at heartbeat
	// observation sites; cleared by peer_metrics_gc.
	LastContactSeconds *prometheus.GaugeVec
	// contains filtered or unexported fields
}

ReplicationMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the Replication subsystem. One bag per Provider, constructed at cmd/nornicdb startup and injected into pkg/replication.Replicator implementations.

**Hot-path discipline (MET-25):** the per-peer GaugeVec families are pre-bound at peer-tracker mark time (replicator rebinds on connect; the peer_metrics_gc lifecycle.Component drops bindings on staleness — Pitfall 3 mitigation per RESEARCH §Q7). The per-cluster scalar gauges (Role, Term, CommitIndex, ApplyIndex, LeaderChanges) need no Bind — they're already singletons.

**Dual-access (D-02):** the raw *prometheus.GaugeVec / *Counter / Gauge is exposed so subsystem tests drive AssertCardinalityCeiling and edge cases. ApplyDuration uses the LatencyHistogram wrapper for MET-24 exemplar centralization (callers Bind() once at apply chokepoint).

func NewReplicationMetrics

func NewReplicationMetrics(reg *prometheus.Registry, mode string, tenantLabelsEnabled bool) *ReplicationMetrics

NewReplicationMetrics constructs the replication bag against reg.

Constructor signature per D-05a + D-08a:

mode (string): one of AllowedReplicationModes; stored for ceiling
  assertions. An unrecognized mode is permitted at construction (the
  bag still functions) but mode-aware ceiling lookups return 0 — tests
  must use a recognized mode.

tenantLabelsEnabled (bool): accepted for callsite uniformity across
  subsystem bags, but IGNORED — replication is per-cluster, not
  per-database. No `database` label is ever added to any family.
  Documented in CONTEXT D-08a.

Validation chain inherited from pkg/observability typed constructors:

  • subsystem "replication" must be in allowedSubsystems (metrics.go:59)
  • histogram name must end in _seconds (registration.go validateNameSuffix)
  • counter name must end in _total
  • labels rejected against ForbiddenLabels (path/query/user/user_id/ip/uuid/...)

Pitfall 8 / MustRegister precedent: validation failure panics — programming bug surfaces at startup before any traffic.

func (*ReplicationMetrics) Ceiling

func (r *ReplicationMetrics) Ceiling() int

Ceiling returns the mode-aware peer cardinality ceiling per D-05a. Returns 0 for unknown modes — the caller should treat that as a test bug.

func (*ReplicationMetrics) Mode

func (r *ReplicationMetrics) Mode() string

Mode returns the replication mode this bag was constructed for. Used by mode-aware cardinality ceiling assertions.

func (*ReplicationMetrics) TenantLabelsEnabled

func (r *ReplicationMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports the D-08a flag value. Diagnostic only — no replication family is tenant-labeled.

type RowCountHistogram

type RowCountHistogram struct {
	// contains filtered or unexported fields
}

RowCountHistogram wraps *prometheus.HistogramVec for result-set-size families (MET-03 RowCountBuckets). See LatencyHistogram for design notes.

func NewRowCountHistogram

func NewRowCountHistogram(reg *prometheus.Registry, opts MetricOpts, labels []string) *RowCountHistogram

func (*RowCountHistogram) Bind

func (*RowCountHistogram) Observe

func (h *RowCountHistogram) Observe(ctx context.Context, lvs []string, rows float64)

func (*RowCountHistogram) Vec

type SearchMetrics

type SearchMetrics struct {
	// Requests counts search requests by [database], mode, result.
	// Cardinality ceiling per RESEARCH §Q11 — 9 tenant-OFF (3 modes ×
	// 3 results); ceiling × max-databases when tenant-ON.
	Requests *prometheus.CounterVec

	// Duration is the per-stage latency histogram. Hot-path: pre-bound
	// via BindDuration([database], mode, stage) cached in
	// pkg/search.Service struct fields per MET-25. Phase-3-locked
	// LatencyBucketsSeconds (≤10s tail; embed stage's long-tail belongs
	// to nornicdb_embed_duration_seconds).
	Duration *LatencyHistogram

	// Candidates is the per-request candidates-count distribution
	// (count of results considered before the fuse stage trims to
	// SearchOptions.Limit). RowCountBuckets up to 1M.
	Candidates *RowCountHistogram

	// IndexSizeBytes is the per-kind size gauge. Populated by the
	// SearchProbe.IndexSizeBytes(kind) callback on every scrape. Closed
	// kind enum {hnsw, bm25}.
	IndexSizeBytes *prometheus.GaugeVec
	// contains filtered or unexported fields
}

SearchMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the search subsystem. One bag per Provider; constructed at cmd/nornicdb startup and injected into pkg/search.Service via AttachMetrics.

Hot-path discipline (MET-25): subsystem callers cache BoundLatencyObserver in struct fields per (database, mode, stage) tuple at constructor time. The Duration.Bind helper is the entry point.

Dual-access pattern (D-02): the bag exposes raw *prometheus.CounterVec / *RowCountHistogram / *prometheus.GaugeVec so subsystem tests can drive AssertCardinalityCeiling and edge cases.

func NewSearchMetrics

func NewSearchMetrics(reg *prometheus.Registry, tenantLabelsEnabled bool, probe SearchProbe) *SearchMetrics

NewSearchMetrics constructs the search bag against reg.

tenantLabelsEnabled (D-08) decides whether `database` is included in requests_total + duration_seconds. candidates is unlabeled (per-request distribution) and index_size_bytes uses only the kind label (process-wide bytes per kind).

probe is the SearchProbe accessor surface — typically a thin adapter over pkg/search.Service. nil is tolerated as a defensive fallback (returns 0 from each kind gauge); the GaugeFunc callbacks wrap defer-recover returning 0 on panic per RISK-8.

func (*SearchMetrics) BindDuration

func (s *SearchMetrics) BindDuration(database, mode, stage string) BoundLatencyObserver

BindDuration returns a pre-bound BoundLatencyObserver for the (database, mode, stage) tuple per MET-25. Tenant-flag-aware — drops database when the bag was constructed with tenantLabelsEnabled=false.

Subsystem callers cache this in struct fields at construction time; the per-request observation pays zero WithLabelValues lookup overhead.

func (*SearchMetrics) IncRequest

func (s *SearchMetrics) IncRequest(database, mode, result string)

IncRequest is the tenant-flag-aware helper to bump requests_total. Drops database when the bag was constructed with tenantLabelsEnabled=false.

func (*SearchMetrics) TenantLabelsEnabled

func (s *SearchMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled.

type SearchProbe

type SearchProbe interface {
	IndexSizeBytes(kind string) uint64
}

SearchProbe is the seam between pkg/search index-size accessors and the observability index_size_bytes GaugeFunc callback (D-02d leaf-package boundary — pkg/observability never imports pkg/search). pkg/search (or a thin adapter) satisfies this interface.

IndexSizeBytes returns the on-disk or in-memory size of the named index kind. Implementations should sum across all per-database indexes when a process hosts multiple databases. Defensive callers may return 0 if the size is not yet computable (e.g., index still building).

type ServiceInfo

type ServiceInfo struct {
	// Name is the OTel service.name. REQUIRED.
	Name string
	// Version is the OTel service.version. REQUIRED.
	Version string
	// Component is an optional sub-component label.
	Component string
	// NodeID feeds the service.instance.id resolution chain (OBS-10).
	NodeID string
	// ClusterMode is the deployment topology (e.g. "standalone", "cluster").
	// TRC-10: emitted as nornicdb.cluster.mode resource attribute.
	ClusterMode string
	// ReplicationRole is the node's role (e.g. "primary", "replica", "standalone").
	// TRC-10: emitted as nornicdb.replication.role resource attribute.
	ReplicationRole string
	// ExtraResourceAttrs are merged after semconv defaults; duplicate keys win.
	ExtraResourceAttrs []attribute.KeyValue
}

ServiceInfo identifies the running binary for telemetry resource attrs.

Multi-binary forward-compat (D-02b): future binaries (cmd/metrics-doc-gen, per-service binaries) override Name and Component without forking the resource construction code. Phase-1 callers pass Name="nornicdb".

ExtraResourceAttrs are merged AFTER semconv keys via resource.Merge, which is last-wins. A caller setting ExtraResourceAttrs={service.name: "x"} will override the semconv default.

type SizeHistogram

type SizeHistogram struct {
	// contains filtered or unexported fields
}

SizeHistogram wraps *prometheus.HistogramVec for payload-size families (MET-03 SizeBucketsBytes). See LatencyHistogram for design notes.

func NewSizeHistogram

func NewSizeHistogram(reg *prometheus.Registry, opts MetricOpts, labels []string) *SizeHistogram

func (*SizeHistogram) Bind

func (h *SizeHistogram) Bind(lvs ...string) BoundSizeObserver

func (*SizeHistogram) Observe

func (h *SizeHistogram) Observe(ctx context.Context, lvs []string, bytes float64)

func (*SizeHistogram) Vec

type StorageMetrics

type StorageMetrics struct {
	// Bytes is the storage size gauge per kind. Populated every 30s by
	// the bytes_metrics_sweeper lifecycle.Component (D-07).
	Bytes *prometheus.GaugeVec

	// OpDuration is the per-op latency histogram. Hot-path observation
	// uses pre-bound observers cached in BadgerEngine struct fields
	// (MET-25). Phase-3-locked LatencyBucketsSeconds.
	OpDuration *LatencyHistogram

	// CompactionsTotal counts BadgerDB compactions by level and result.
	// Cardinality ceiling = ~14 (7 levels × 2 results). Bounded by
	// BadgerDB's own compaction-level surface — no user input flows here.
	CompactionsTotal *prometheus.CounterVec

	// CompactionDuration histograms compaction wall-clock time per level.
	CompactionDuration *LatencyHistogram

	// WALLagBytes is a heuristic estimate of WAL backlog (vlog size
	// minus LSM size from badger.DB.Size()). Best-effort; alerting
	// should use 5-minute trends, not single scrapes. RESEARCH §Q3 /
	// RISK-6.
	WALLagBytes prometheus.Gauge

	// IndexRebuildTotal counts index-rebuild events by [database], index,
	// result. Closed `index` enum per D-13c. database label gated by
	// tenant flag (D-08).
	IndexRebuildTotal *prometheus.CounterVec
	// contains filtered or unexported fields
}

StorageMetrics is the typed handle-bag (CONTEXT D-02 / D-02a) for the storage subsystem. One bag per Provider; constructed at cmd/nornicdb startup and injected into *BadgerEngine via AttachMetrics(...).

Hot-path discipline (MET-25): the storage struct caches BoundLatencyObserver in struct fields at constructor time so per-op observation pays zero WithLabelValues lookup overhead.

Dual-access pattern (D-02): the bag exposes the raw *prometheus.GaugeVec / *prometheus.CounterVec / *LatencyHistogram so subsystem tests can drive AssertCardinalityCeiling and edge cases.

func NewStorageMetrics

func NewStorageMetrics(reg *prometheus.Registry, tenantLabelsEnabled bool, probe StorageProbe) *StorageMetrics

NewStorageMetrics constructs the storage bag against reg.

tenantLabelsEnabled (D-08) decides whether `database` is included in IndexRebuildTotal. The other families are tenant-flag-independent:

  • bytes/op_duration aggregate across databases at the storage layer (per-DB lives at Cypher subsystem, Plan 04-03).
  • nodes_total/edges_total are process-wide gauges.
  • compactions/wal are BadgerDB-level — single physical database.

probe is the StorageProbe accessor surface — typically *BadgerEngine. nil is tolerated as a defensive fallback (returns 0 from each gauge).

func (*StorageMetrics) BindIndexRebuild

func (s *StorageMetrics) BindIndexRebuild(database, index, result string) prometheus.Counter

BindIndexRebuild returns a pre-bound prometheus.Counter for the (database, index, result) tuple. Tenant-flag-aware — drops database when the bag was constructed with tenantLabelsEnabled=false.

func (*StorageMetrics) TenantLabelsEnabled

func (s *StorageMetrics) TenantLabelsEnabled() bool

TenantLabelsEnabled reports whether this bag was constructed with the D-08 tenant-flag enabled.

type StorageProbe

type StorageProbe interface {
	NodeCount() int64
	EdgeCount() int64
	IDDictCounterNodes() uint64
	IDDictCounterEdges() uint64
	IDDictFreelistNodes() int64
	IDDictFreelistEdges() int64
}

StorageProbe is the seam between pkg/storage stat accessors and the observability nodes_total / edges_total GaugeFunc callbacks (D-02d leaf-package boundary — pkg/observability never imports pkg/storage). *BadgerEngine's existing NodeCount() / EdgeCount() return (int64, error); callers wrap the error-discarding form via a thin adapter at the cmd/nornicdb wiring site (see Plan 04-04-07).

IDDictCounterNodes / IDDictCounterEdges expose the monotonic counters of allocated numIDs. IDDictFreelistNodes / IDDictFreelistEdges report the number of entries currently parked on the debounced freelist, awaiting TTL expiry before they can be reclaimed. Together these give operators visibility into (counter-freelist) = roughly the live-entity count and freelist pending work.

type TestEnv

type TestEnv struct {
	Registry *prometheus.Registry
	Exporter *tracetest.InMemoryExporter
	Logger   *slog.Logger
	Provider *Provider
	Health   *Health

	// Buffer is the lazily-allocated record-capture sink. Populated by the
	// first call to CaptureRecords(); nil otherwise. Per D-12 the discard
	// handler stays the default; tests opt-in to capture via CaptureRecords.
	Buffer *bytes.Buffer
	// contains filtered or unexported fields
}

TestEnv carries per-test isolated observability primitives. It is the canonical TEST-01 fixture (ADR §2.8.1 / A10b) — every Phase 3+ test package SHOULD construct one of these via NewTestEnv(t).

Each TestEnv has:

  • its own *prometheus.Registry (never DefaultRegisterer);
  • its own *tracetest.InMemoryExporter wired through SimpleSpanProcessor so emitted spans are visible synchronously (no BSP batching);
  • its own *slog.Logger using a discard handler (suppresses unless a test explicitly writes against a captured handler);
  • a *Provider built against those primitives (sampler: sdktrace.AlwaysSample so tests CAN observe spans they emit, unlike the production NeverSample default);
  • a fresh *Health registry.

Provider.Shutdown is registered on t.Cleanup automatically — callers don't need to call it explicitly.

func NewTestEnv

func NewTestEnv(t *testing.T) *TestEnv

NewTestEnv constructs an isolated observability environment for one test. Race-detector stable across `go test -race -count=10`.

The constructed *Provider uses SimpleSpanProcessor(exp) + AlwaysSample rather than the production BSP + NeverSample combination, so tests can observe spans synchronously via env.Exporter.GetSpans(). This is a test-only path; production code goes through observability.New.

func (*TestEnv) AssertCardinalityCeiling

func (te *TestEnv) AssertCardinalityCeiling(t CardinalityT, name string, ceiling int, drive func(tenant string))

AssertCardinalityCeiling exercises the named *Vec across 1000 deterministic synthetic tenant UUIDs concurrently across 8 goroutines, then asserts the test's isolated registry observes <= ceiling distinct series for `name`. Implements TEST-02 (CONTEXT D-04 / D-04a / D-04c).

Called once per *Vec from Phase 4 subsystem tests; the "1000 UUIDs + 8-goroutine drive" knowledge lives ONCE here per AGENTS.md §7 (DRY).

API choice: caller-supplied `drive` callback (Pattern A in RESEARCH §4). Keeps testenv.go agnostic of subsystem label shapes — a *Vec with labels []string{"database","op_type","result"} is driven via

te.AssertCardinalityCeiling(t, name, ceiling, func(tenant string) {
    cv.WithLabelValues(tenant, "read", "success").Inc()
})

— and a *Vec with labels []string{"database"} is driven via

te.AssertCardinalityCeiling(t, name, ceiling, func(tenant string) {
    cv.WithLabelValues(tenant).Inc()
})

Race-safety:

  • errgroup.SetLimit(8) caps fan-out so -race -count=N -parallel=M doesn't explode goroutine counts (D-04c).
  • client_golang.HistogramVec.WithLabelValues / CounterVec.WithLabelValues are documented race-safe (RESEARCH §1/§4 — internal sharded sync.Mutex per label-set hash).
  • The helper holds no state across calls; each invocation builds a fresh errgroup.

API note (RESEARCH §4 CRITICAL CORRECTION):

testutil.GatherAndCount takes a Gatherer (*prometheus.Registry implements).
DO NOT use testutil.CollectAndCount — that takes a Collector (it builds
its own pedantic registry internally) and will not compile against
*prometheus.Registry. CONTEXT.md draft shorthand `CollectAndCount(reg, …)`
was misleading; this helper uses the correct API.

Signature note (Rule 1 deviation from literal D-04 form):

The parameter is typed as the small CardinalityT interface rather than
the concrete *testing.T. Production callers pass *testing.T transparently
(it satisfies the interface). Negative-falsifiability sub-tests pass an
in-package fake to capture the helper's t.FailNow() call without Go's
t.Run-propagates-failure-to-parent semantics tripping the parent test.
(Go testing.go:962 c.Fail() unconditionally propagates via c.parent.Fail();
there is no way to assert "this helper called Fatalf" from within a
*testing.T-typed sub-test without parent contamination.)
Plan 03-04 acceptance criterion grep for the literal `*testing.T` form is
relaxed to the equivalent `CardinalityT` interface.

func (*TestEnv) CaptureRecords

func (te *TestEnv) CaptureRecords()

CaptureRecords rewires te.Logger to write JSON records into te.Buffer (D-12). Idempotent: subsequent calls are no-ops, preserving any records already written. The default discard handler is replaced only on the first call so tests can opt-in to capture without resetting state.

Concurrency: the underlying slog.JSONHandler serializes its writes via its own internal mutex; te.Buffer is therefore safe for concurrent loggers spawned after CaptureRecords returns. Use a sync.Mutex-guarded buffer wrapper if you need ordering guarantees across multiple goroutines.

func (*TestEnv) LoggedRecords

func (te *TestEnv) LoggedRecords() []map[string]any

LoggedRecords parses the captured buffer line-by-line into a slice of JSON-decoded maps. Tolerates an empty buffer (returns nil) and skips blank trailing lines. Each call re-parses the buffer so tests CAN call it multiple times if they wish to observe streaming.

type TracingConfig

type TracingConfig struct {
	// Enabled gates whether the SDK TracerProvider is built. When false a noop
	// provider is installed and no exporter is initialized.
	Enabled bool
	// Endpoint is the YAML-configured OTLP collector address. The actual
	// endpoint used at runtime is resolved by OTLPEndpoint() — env vars
	// override this value (OBS-12).
	Endpoint string
	// Protocol is "grpc" (default) or "http". Phase 1 wires gRPC; HTTP is the
	// fallback for Phase 6 hardening.
	Protocol string
	// Insecure allows a plaintext OTLP connection. When an env var sets an
	// http:// endpoint and Insecure is false, Phase 6 TRC-09 rejects the
	// configuration and installs a noop provider.
	Insecure bool
	// Timeout bounds exporter init. Default 5s. A misconfigured collector
	// MUST NOT hang process startup (OBS-11).
	Timeout time.Duration

	// SampleRatio is the TraceIDRatioBased sampler ratio (TRC-05). Defaults to
	// 0.01 (1%) when Enabled=true. Clamped to [0, 1] at sampler construction.
	SampleRatio float64
	// ParentMode selects the parent-honoring sampler policy (TRC-05/06/07).
	// Empty/"none" (default) = standalone TraceIDRatioBased (KD-05 default).
	// "capped"                = parentCappedSampler (TRC-06).
	// "strict"                = ParentBased (TRC-07; WARN at startup).
	ParentMode string
	// ParentMaxQPS caps the number of sampled-parent spans honored per second
	// when ParentMode="capped" (TRC-06). Default 100. Clamped to >=1.
	ParentMaxQPS int
}

TracingConfig governs OTLP/gRPC trace egress.

func (TracingConfig) OTLPEndpoint

func (c TracingConfig) OTLPEndpoint() (endpoint string, fromEnv bool)

OTLPEndpoint resolves the OBS-12 precedence chain:

OTEL_EXPORTER_OTLP_ENDPOINT > OTEL_EXPORTER_OTLP_TRACES_ENDPOINT >
c.Endpoint (YAML) > "" (default — caller installs noop or relies on SDK
default).

The fromEnv return tells the caller whether the value came from an env var. When fromEnv is true, the caller MUST NOT pass otlptracegrpc.WithEndpoint (the SDK reads the env var itself; passing the option overrides the env — see Pitfall 9).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL