tidaldb/docs/ops/observability.md
jordan fe8d0c87e7 harden: restore CI verification, remove four wire-level fabrications, instrument the 401 path
Implements tmp/tidaldb-fleet-hardening (20 planned tasks + 2 found by measurement).

Ring 0 — restore verification. .woodpecker.yaml step pods ran at the namespace
default of 1500m/2Gi, which OOMKilled a prior pipeline and starved the release
gate past its budget. Both push-path steps now declare
backend_options.kubernetes.resources as two YAML anchors declared once on their
first consuming step. The values are CALIBRATED against measured free node
capacity, not against the LimitRange max: `requests: cpu 2` (this roadmap's
original figure) fits on NO node and would sit Pending forever, because
`ci-build-bounds` grants permission and the nodes supply capacity, and those are
not the same thing.

The `nightly` cron described in this file for 216 days was never created, so
tier-3 chaos, the fault classes, mTLS and the PITR test produced exactly zero
signal while reading like standing coverage. nightly-chaos and
nightly-security-ops now alias the anchors and have budgets matching the gate
(their 120/90 were TIGHTER on the same runner, so they would have failed
nightly for a budget reason, not a correctness one). nightly-soak is REMOVED,
not scheduled: it drives 1000 rps for 600s gating on p99 <= 250ms, and the best
node has 1700m free CPU, so it would fail on starvation rather than regression —
manufacturing a nightly false alarm. Its commands move verbatim to
docs/runbooks/nightly-soak.md.

Ring 1 — four fabrications removed from the wire.
- scatter_merge sorted and truncated without re-stamping rank, so /feed and
  /search returned 1,1,2 under full placement. Reuses merge_cross_shard's
  existing stamp; asserted on BOTH the multi-group merge path and the
  single-group [only] fast path that bypasses it.
- aggregate_region_row's None arm invented `applied_events: 0` plus a deficit
  derived from it. applied_events/lag_events are now Option<u64>, null on the
  wire. leader_last_seq was also unwrap_or(0), so a node that could not reach
  the LEADER computed 0 - applied = 0 for every region and reported a converged
  cluster it had never measured — a fabrication pointing the dangerous way.
- tidalctl inferred NO REPORT from `applied == 0 && lag > 0`. That heuristic was
  actively hiding the PVC-wipe shape: a measured zero with a real deficit
  rendered as "no report" instead of BEHIND. Now read off the wire; converged
  exits 0, partitioned still exits nonzero.
- /sharded/* answered 201/204 for single-copy writes with nothing anywhere
  saying so. Now requires `x-tidal-ack: local`, rejecting with 400 via the
  existing invalid_input path. Six call sites migrated, not the two this
  roadmap predicted — including docs/runbooks/cluster.md §16.3, which told
  operators to run a quorum-write probe via POST /sharded/items. That probe
  cannot verify quorum: the surface applies locally with no WAL append. It was
  used as the safety check between every step of a staged deploy earlier today.

Ring 2 — observability. JSON_LOGS was already implemented and the deployment
simply never asked for it; the StatefulSet now sets it, plus
TIDAL_SERVICE_NAME=tidaldb because enabling it silently renames the
VictoriaLogs `service` stream field and would have blinded every query keyed on
it. Adds tidaldb_usearch_replicated_vectors_total, incremented on BOTH the
origin (wal_blob_first -> Ok(Some)) and the follower apply path — counting only
the origin would mean each vector lands on exactly one node, replicas never
agree, and the alert built on it pages forever.

Found by measurement, not planned: the 401 path discarded every fact about
every rejection. Traefik has served 101,858 rejected requests to the public
ingress — 87.6% of all its traffic — with no record of who or why anywhere.
unauthorized_response now emits reason (missing_token vs invalid_token, the
distinction that separates a scanner from a rotation that missed a consumer)
and the forwarded client. The token is never logged.

Also: scripts/restore-fleet.sh --cluster started the soak monitor while
deliberately leaving its gate suspended, orphaning a watcher that has reported
"0/30 green nights" for 13 days. The pair now moves together. Doc-guard's
three-warning backlog is cleared with real backfill for M4/M6/M12.

Verified: fmt clean; clippy 5 crates 0 new warnings (74 vs 74 baseline,
counted in a detached worktree at HEAD); lib 2110 passed; cluster_sharding 5;
cluster_runbook 10; tidalctl 38; doc-guard 0 warnings. Playwright 32/34 with
the two remaining failures asserting the rank fix against the not-yet-rolled
image — they are the post-deploy proof.
2026-08-30 20:55:58 -06:00

11 KiB

Observability: usage, errors, and live debugging

Four surfaces, in the order you reach for them during an incident.

Question Surface
How much traffic, and how much of it is failing? Grafana → tidalDB — usage, errors, and cluster health
What exactly failed, for which request? VictoriaLogs, level:error, correlate on request_idonly after the JSON_LOGS roll; see §2
Is the cluster converged right now? tidalctl cluster-status / tidalctl watch
What does a query actually return? tidalctl search / tidalctl feed

1. Metrics

Every node serves Prometheus text on its --metrics address (:9091 in the shipped manifests). Never expose that port externally — it is unauthenticated. In-cluster it is reachable only from the observability namespace (k8s/cluster/networkpolicy.yaml).

Two families answer the usage/error questions:

tidaldb_http_requests_total{route,method,status}   counter
tidaldb_http_request_duration_us{route}            histogram

route is the matched route template (/cluster/shards/{id}/transfer), not the concrete path, so cardinality is bounded by the router rather than by traffic. Unmatched requests collapse into a single <unmatched> bucket — a 404 flood cannot mint series.

These are published by tidal-server onto the engine's existing listener (via MetricsState::set_extra_renderer), so a node remains one scrape target.

Useful queries:

# request rate by route
sum by (route) (rate(tidaldb_http_requests_total[5m]))

# server-fault ratio
  sum(rate(tidaldb_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(tidaldb_http_requests_total[5m]))

# who is being refused, and where
sum by (status, route) (rate(tidaldb_http_requests_total{status=~"401|403"}[5m]))

# client-observed p99, in ms
histogram_quantile(0.99,
  sum by (route, le) (rate(tidaldb_http_request_duration_us_bucket[5m]))) / 1000

Read 403 precisely: it means a valid data bearer was used against an operator verb (/cluster/*). A sustained 403 rate is a client holding the wrong key, or someone probing — not a server fault.

Dashboard

docs/ops/grafana-tidaldb.json is the source of truth, mirrored into the fleet as the tidaldb-overview.json key of the grafana-database-dashboards ConfigMap — the same convention docs/ops/prometheus-alerts.yaml follows. Edit here, then mirror. It lands in Grafana's Databases folder as tidaldb-overview.


2. Logs

Set JSON_LOGS=1 (or TIDAL_LOG_FORMAT=json) to emit one JSON object per line in the collector's wire format:

{"ts":"2026-08-23T15:57:57.090256Z","level":"info","service":"tidal-server",
 "env":"prod","msg":"committing 5","target":"tantivy::indexer","request_id":"418"}

Why it matters: the fleet's Vector collector parses each line as JSON and, on success, replaces the event with that object; on failure it keeps the text and stamps level = "info". Without JSON, every tidalDB line was recorded as info and level:error matched nothing — errors were collected but invisible.

  • level is lowercase, matching the collector's enum (critical|error|warn|info|debug).
  • request_id from the per-request span appears on every line emitted inside it, so one request's work is correlatable rather than grep-adjacent.
  • target is the real module even for dependencies logging through the log crate (their bridge metadata is pruned rather than indexed forever).
  • ANSI colour is off in both formats. It used to leak escape codes into stored logs.

Tunables: TIDAL_SERVER_LOG (env-filter, e.g. tidal_server=debug,tantivy=warn,info to quiet a noisy dependency), TIDAL_SERVICE_NAME, TIDAL_ENV.

TIDAL_SERVICE_NAME is not cosmetic here. service is one of the collector's four _stream_fields, so its value is index identity. Plain-text lines carry no service and Vector substitutes the container name, tidaldb; the JSON emitter sets its own, defaulting to tidal-server. The cluster StatefulSet therefore pins TIDAL_SERVICE_NAME=tidaldb so the stream does not rename itself the moment the format changes. TIDAL_ENV is unset in the cluster, so env is absent from its lines rather than guessed — do not filter on it.

Querying the log store

Two things silently break a LogsQL query against tidalDB, and both fail as an empty result — which reads exactly like a healthy cluster.

1. The namespace field is unit, not kubernetes.pod_namespace. Vector's normalize transform does del(.kubernetes) and writes the namespace to unit (and the node to host, the pod and container to k8s_pod / k8s_container). A query keyed on the Kubernetes field matches nothing, forever. This document shipped that broken selector; measured against the live store on 2026-08-31T02:24Z, same 15-minute window, same cluster:

_time:15m AND kubernetes.pod_namespace:tidaldb-cluster | stats count() n
  -> {"n":"0"}        # the selector is wrong, not the cluster

_time:15m AND unit:tidaldb-cluster | stats count() n
  -> {"n":"248"}      # same window, correct field

So always run the unfiltered count first. Zero total lines means a broken selector. Only once that number is non-zero does a zero error count mean anything.

2. level, request_id, target and every other structured field exist only once JSON_LOGS is on. Until the pods restart with it, Vector cannot parse the line, keeps the text as msg, and stamps level = "info" — so level:error matches nothing no matter how healthy or unhealthy the cluster is, and every WARN is filed as info. Pre-roll reading, same window:

_time:15m AND unit:tidaldb-cluster | stats by (level) count() n
  -> {"level":"info","n":"248"}     # ONE bucket: nothing is being classified

_time:15m AND unit:tidaldb-cluster AND level:error | stats count() n
  -> {"n":"0"}                      # meaningless pre-roll, not reassuring

_time:1h AND unit:tidaldb-cluster AND request_id:* | stats count() n
  -> {"n":"0"}                      # the field does not exist yet

(The counts move — _time:15m is a sliding window, and two runs a minute apart returned 247 and 248. The load-bearing observation is the number of buckets, not the number of lines: one bucket means the classifier never ran.)

The store itself is fine — level:error already works for services that emit JSON: _time:15m AND level:error | stats by (service) count() n returns {"service":"relay","n":"189"}, {"service":"external-secrets","n":"2"}. tidalDB was simply not speaking the format. Deploy status is tracked in docs/runbooks/deploy-verification.md §9.3.

Post-roll, these are the queries. Each pairs with its own discriminator so a zero is readable:

# is anything arriving at all?  (run this first, every time)
_time:15m AND unit:tidaldb-cluster | stats count() n

# how is it being classified?  more than one bucket == JSON_LOGS is live
_time:15m AND unit:tidaldb-cluster | stats by (level) count() n

# errors, last 15 minutes
_time:15m AND unit:tidaldb-cluster AND level:error

# one request end to end
_time:1h AND unit:tidaldb-cluster AND request_id:418

# one pod
_time:15m AND unit:tidaldb-cluster AND k8s_pod:tidaldb-0 AND level:error

# search the message text — the field is _msg, NOT msg (see below)
_time:15m AND unit:tidaldb-cluster AND _msg:rejected AND _msg:request

Run them with:

kubectl -n observability exec deploy/vmagent -- sh -c \
  'wget -qO- --timeout=25 --post-data="query=<QUERY>" \
   http://victoria-logs:9428/select/logsql/query'

The message field is _msg, not msg. Vector's sink declares _msg_field: msg, so VictoriaLogs renames it on ingest. msg:rejected parses fine and matches nothing, forever — the same silent-zero failure as the namespace field above. Verified 2026-08-31: _msg:compaction returns {"n":"229"} while msg:compaction in the same position returns empty.

Keep double quotes out of the query. The runner above already wraps the query in a double-quoted --post-data="query=…", so a quoted phrase like _msg:"rejected request" terminates the shell quoting and wget dies with bad address 'request | stats…'. Bare tokens (request_id:418) and ANDed word filters (_msg:rejected AND _msg:request) need no quotes and cannot break.


3. Live debugging with tidalctl

Every other tidalctl command reads a data directory at rest — some require a stopped node. These take --url and talk to a running server.

export TIDAL_API_KEY=...          # or pass --key

# Is it converged? Exits 2 when not, so it gates a deploy.
tidalctl cluster-status --url http://127.0.0.1:9500

# Watch convergence (Ctrl-C to stop, or bound it with --count)
tidalctl watch --url http://127.0.0.1:9500 --interval 5 --count 12

# What does a query actually return?
tidalctl search --url http://127.0.0.1:9500 --query "cold brew" --limit 5
tidalctl feed   --url http://127.0.0.1:9500 --profile trending --user-id 42

TLS. A cluster node's client port is served with the internal cluster CA, issued for in-cluster DNS names, so a port-forward fails both CA and hostname verification. Pin the CA with --ca ca.pem, or use --insecure for a local forward:

kubectl -n tidaldb-cluster port-forward svc/tidaldb 9500:9500 &
tidalctl cluster-status --url https://127.0.0.1:9500 --insecure

Exit codes follow the crate contract: 0 ok, 1 usage error, 2 degraded — unreachable, non-2xx, or not converged. A 403 response prints the hint to use the admin key.


4. Known caveat: the aggregated /cluster/status under-reports peers

The aggregated view can report a peer it holds no frontier report for as applied_events: 0, and derive lag_events against that zero — so a fully converged peer appears to be the leader's entire history behind, sometimes with reachable: false / partitioned: true alongside.

Observed 2026-08-23: two healthy nodes shown as UNREACHABLE PARTITIONED at 13.3M lag, while every node's own /cluster/status/local reported lag=0, reseed_required=false, and identical per-group frontiers. Pod-to-pod connectivity was open and no error was logged.

tidalctl cluster-status detects that signature (applied == 0 with non-zero lag) and prints NO REPORT (aggregated view; query the node directly) rather than repeating it as replication lag. A genuine lag report — non-zero applied with non-zero lag — still reports BEHIND.

When you see NO REPORT, confirm against the nodes themselves before treating it as an incident:

for i in 0 1 2; do
  kubectl -n tidaldb-cluster port-forward tidaldb-$i 1970$i:9500 >/dev/null 2>&1 &
  sleep 8
  curl -sk -H "Authorization: Bearer $TIDAL_API_KEY" \
    "https://127.0.0.1:1970$i/cluster/status/local" | jq '{region, reseed_required, shards}'
  kill %1
done

The underlying reporting gap in the aggregated surface is open work; it is a reporting defect, not a replication one.