C4 - Observability contract

Pinned identifiers for metrics, log events, spans and scrape wiring. Peers: C1, C2, C3, C5.

4.1 Scope and the three-layer rule

Alert on metrics. Diagnose with logs. Explain with traces. Each layer has a different cost curve: metrics are cheap per data point and expensive per series; logs are cheap per field and expensive per line; traces are cheap per span and expensive per percentage sampled. Put each fact where its cost is lowest.

A corollary worth stating plainly: an alert that fires from a log query is usually a metric nobody created yet. A log query as an alert source is fragile (§4.7); the correct fix is to name the signal, emit it as a counter, and alert on that.

This file pins identifiers and schemas only. SLO targets, alert thresholds, burn-rate windows and dashboards live in docs/06-observability-and-slo.md. If a number appears here it is a shape (a unit, a suffix, an interval), not a target.

4.2 Metrics catalogue

Micrometer + Spring Boot Actuator, exported at /actuator/prometheus. The exported name column is the string every SLO filter, PromQL query and Cloud Monitoring alert condition must use. Micrometer lower-cases and replaces . with _; counters gain _total; timers publish _seconds_count / _seconds_sum / _seconds_bucket; gauges are bare. Google Managed Service for Prometheus additionally exposes the descriptor as prometheus.googleapis.com/<name>/counter or /histogram. Getting this string wrong is the single most common reason a metrics query silently returns nothing - a wrong metric name is not an error, it is an empty result, which on a < threshold condition looks exactly like health.

Micrometer name Type Unit Tag keys → allowed values Emitter Exported name Incident question
lock.acquire Timer seconds backend=pg|etcd; outcome=granted|contended|error lock-server lock_acquire_seconds_{count,sum,bucket}.../lock_acquire/histogram Is the lock slow, or just busy?
lock.lease.expired Counter events backend=pg|etcd lock-server lock_lease_expired_total.../counter Are holders dying or just pausing?
lock.fenced.out Counter events resource=account|ledger|rail payment-resource, rail-proxy lock_fenced_out_total.../counter Did a zombie holder reach a writer?
lock.held.current Gauge locks backend=pg|etcd lock-server lock_held_current How many locks exist right now?
lock.waiters.current Gauge waiters backend=pg|etcd lock-server lock_waiters_current Is contention queueing or churning?
lock.session.lost Counter events backend=pg|etcd lock-client lock_session_lost_total.../counter Did the client lose its keepalive?
payout.execute Timer seconds outcome=posted|failed|abandoned|ambiguous payout-executor payout_execute_seconds_{count,sum,bucket}.../histogram Is the business outcome healthy?
rail.submission Counter events outcome=acked|rejected|timeout|fenced rail-proxy rail_submission_total.../counter Is the external rail the problem?
rail.duplicate.attempted Counter events (none) rail-proxy rail_duplicate_attempted_total.../counter Did we nearly pay twice?
payout.backlog.age.seconds Gauge seconds (none) payout-executor payout_backlog_age_seconds Is the oldest pending payout aging?

Rules: (a) backend is the configured backend of the emitting process, never a per-request value; (b) every tag key listed must be present on every data point - Micrometer treats a missing tag as a different series, which splits a rate in half without any error; (c) no tag key or value not in this table may be added without editing this table first.

4.3 Cardinality: a hard constraint

Never tag a metric with the lock key, the account id, the payout id, an idempotency key, a fencing token, or a URL path containing any of them.

Ten million account ids means ten million time series per metric, times every other tag on it. Both Cloud Monitoring and Prometheus price and perform on active time-series count, not on data volume: the bill, ingestion latency, query latency and the head block’s memory all scale with series, so one careless tag can make a dashboard un-loadable and a cost line unexplainable. Unbounded tags also never stop growing - each new account permanently adds a series.

The one-line reviewer test: a tag value is allowed only if you can write the complete set of its possible values on one line of this document. backend (2), outcome (≤4), resource (3) pass. accountId fails. If you cannot enumerate it, it is a log field, where the same value costs one key in one line and is queryable and indexable for free.

Getting this backwards - identifiers in tags, aggregate counts in log text - is the single most expensive mistake in practical observability, and it is expensive in both directions: the bill goes up and the queries you actually need stop working.

4.4 The two counters whose healthy value is exactly zero

lock.fenced.out and rail.duplicate.attempted are invariant-violation counters. Correct operation produces no data points at all.

Counter Non-zero means (both things, always)
lock.fenced.out The fence worked: a stale token was rejected at the Postgres row (UPDATE ... WHERE fence < :token) or at the rail-proxy high-water mark. It also means a holder that believed it held the lock reached a writer - so somewhere there was a GC pause, a container freeze, a network partition, or a client-side lease bug.
rail.duplicate.attempted The rail-proxy blocked a second submission of one payout to a non-idempotent rail. It also means an executor retried or re-claimed work it should have owned exclusively - a lease/claim boundary defect, not a rail defect.

Because the expected value is exactly zero, a threshold of > 0 with no duration window and no for: clause is correct, not noisy: there is no baseline to distinguish signal from, and a single increment is already a completed safety-net catch. This is the one legitimate exception to “never alert on a single data point” - the rule exists because one sample of a noisy signal is meaningless, and these signals have no noise floor.

Standing interpretation: a fence firing is never nothing. Do not close the alert on “the safety net held.” The safety net holding is the evidence that a liveness assumption in the client broke; close it on the explanation of why a zombie existed, with the fenced_out log line (§4.5) as the starting point.

4.5 Structured log event schema

JSON to stdout, ingested by Cloud Logging. Common fields on every event: timestamp (RFC 3339, string), severity (string), service (string, from §4.2 emitters), event (string, from this table), traceId (string, 32 hex), spanId (string, 16 hex), ownerId (string, present on every lock-scoped event). No secrets, no full request bodies.

event Level Emitter Event-specific fields (type - meaning)
lock_granted INFO lock-server lockKey str - resource being locked; token long - fencing token issued; backend str; leaseMillis long - lease length granted; waitMillis long - time spent queued
lock_released INFO lock-server lockKey str; token long; heldMillis long - actual hold duration; reason str - normal|abandoned
lease_expired WARN lock-server lockKey str; token long - token of the expired holder; expiredAtMillis long; overdueMillis long - how far past the deadline the sweep found it
lock_revoked WARN lock-server lockKey str; token long; revokedBy str - operator or sweeper identity; reason str
fenced_out ERROR payment-resource, rail-proxy resource str - account|ledger|rail; resourceId str - account/ledger/payout id rejected; presentedToken long - token the caller offered; highestToken long - token already recorded at the writer; ownerId str - the rejected caller
session_lost WARN lock-client lockKey str; token long; backend str; lastRenewMillis long - age of the last successful renew; cause str
payout_claimed INFO payout-executor payoutId str; accountId str; token long - fence acquired with the claim; attempt int
rail_submitted INFO rail-proxy payoutId str; idempotencyKey str; presentedToken long; railRef str|null - rail-side reference if acked
rail_ambiguous ERROR rail-proxy payoutId str; idempotencyKey str; presentedToken long; elapsedMillis long - elapsed time at which the submission timed out with no ack (per C3 §3.7); railRef str|null
payout_posted INFO payout-executor payoutId str; accountId str; token long; ledgerEntryIds str[] - the double-entry rows written; railRef str
duplicate_rail_submission_attempted ERROR rail-proxy payoutId str; idempotencyKey str; presentedToken long; highestToken long; firstSubmittedAt str - timestamp of the accepted submission

fenced_out and duplicate_rail_submission_attempted are ERROR because each is a caught invariant violation; rail_ambiguous is ERROR because it may have moved real money with no confirmation and requires reconciliation, not a retry.

4.6 Field promotion to log-based-metric labels

May be promoted (bounded) Must NEVER be promoted (unbounded)
event, service, severity, resource, outcome, backend, reason token, presentedToken, highestToken, lockKey, accountId, payoutId, resourceId, idempotencyKey, railRef, ledgerEntryIds, traceId, spanId

The right-hand column is not a style preference: a log-based metric label is a time-series dimension, so promoting payoutId recreates exactly the explosion §4.3 forbids - with the added trap that it happens in a console UI far from code review. Same one-line test as §4.3. Keep the forbidden fields queryable in the log payload; that is what they are for.

4.7 Log-based metrics to derive

Derived metric Source filter (Cloud Logging) Labels Purpose
lock_zombie_write_attempts jsonPayload.event="fenced_out" service, resource Independent confirmation of lock.fenced.out from a different pipeline
rail_ambiguous_outcomes jsonPayload.event="rail_ambiguous" service Reconciliation queue depth driver
rail_duplicate_blocks jsonPayload.event="duplicate_rail_submission_attempted" service Cross-check against rail.duplicate.attempted
lock_revocations jsonPayload.event="lock_revoked" service, reason Operator/sweeper intervention rate
client_session_losses jsonPayload.event="session_lost" service, backend Client-side keepalive health where no server metric exists

Why convert rather than alert on the query: the log line’s wording will change, the metric name will not. A query alert is coupled to a message string that any refactor can edit without failing a build, and it silently stops matching. The indirection also forces the useful decision - what is the signal? - instead of what is the message? Where a first-class Micrometer counter already exists (§4.2), the log-based twin is a cross-check from an independent pipeline, not the alert source.

4.8 Traces

OpenTelemetry, exported to Cloud Trace. One payout attempt is one trace.

Span Parent Service Required attributes
payout.execute root payout-executor payout.id, account.id, payout.attempt, lock.backend
lock.acquire payout.execute lock-server lock.key, lock.backend, lock.token, lock.outcome, lock.wait_ms, lock.lease_ms
payment.claim payout.execute payment-resource payout.id, lock.token, fence.result=accepted|fenced
rail.submit payout.execute rail-proxy payout.id, idempotency.key, lock.token, fence.highest_token, rail.outcome
rail.stub.process rail.submit rail-stub idempotency.key, rail.injected_fault, rail.latency_ms
ledger.post payout.execute payment-resource payout.id, lock.token, ledger.entry_count, fence.result
lock.release payout.execute lock-server lock.key, lock.token, lock.held_ms, lock.release_reason

lock.token on every span is the load-bearing attribute. Span attributes are per-span and not subject to the §4.3 cardinality budget, so the token is free here and priceless: filtering traces by one token yields the complete story of one lock’s life across four processes - when it was issued, which writers accepted it, which rejected it, and whether a lower token showed up afterwards. Without it, a fencing incident is four unrelated traces.

Sampling: low rate in steady state (parent-based, head sampling, rate configured in docs/06-observability-and-slo.md), 100 % retained on error paths - any trace containing a span with error status, or a fenced_out / rail_ambiguous / duplicate_rail_submission_attempted event. Rare-and-critical is exactly the population uniform sampling loses.

4.9 Scrape contract

Item Value
Path /actuator/prometheus (Actuator; management.endpoints.web.exposure.include must list prometheus, health)
Container port number 8080 (all services)
Container port name http-metrics - the PodMonitoring spec.endpoints[].port selects on this name
Scrape interval 30s
Cluster / project / region dlock-gke / dlock-lab / europe-central2
Collector Google Managed Service for Prometheus, one PodMonitoring CRD per namespace, label-selecting the project services

The named-port trap: PodMonitoring may reference either a port name or a number, and a name that does not exist on the container matches nothing. There is no error - the target simply never appears, and every query returns empty (§4.2). If a service’s containerPort is unnamed, or named http while the CRD says http-metrics, that is the bug, and it is by far the most common reason “no metrics appear.”

Verification step, in order, after any deploy: (1) curl the pod’s /actuator/prometheus and grep for lock_acquire_seconds_count; (2) confirm the target is listed as up for the PodMonitoring; (3) query the exported name in Cloud Monitoring / Metrics Explorer and confirm a data point within two scrape intervals. Passing (1) but failing (2) is always wiring - port name, label selector, or namespace.

4.10 Health and readiness

Endpoint Semantics Backend dependency
/actuator/health/liveness Process is alive; restart if failing none - must not touch the backend
/actuator/health/readiness Safe to receive traffic must fail when the configured lock backend (lockdb or etcd) is unreachable
/actuator/health Aggregate, for humans includes both groups

Custom indicators: lockBackend (connectivity + a trivial round trip against the configured backend), payDb (payment-resource only), railStub (rail-proxy only).

This service fails closed by design. With no reachable lock backend, no lock can be granted, no fencing token minted, and no token verified - so serving traffic would mean either refusing everything loudly or, far worse, proceeding unfenced against a non-idempotent rail. Readiness failure removes the pod from endpoints, so callers see a fast connection-level failure and retry elsewhere instead of accumulating timeouts on a pod that cannot succeed. Liveness must not depend on the backend: a backend outage would otherwise restart-loop the whole fleet and destroy the diagnostic evidence.

4.11 Deliberately not specified here

Not here Where
SLO targets, error budgets, burn-rate windows, alert thresholds and durations, dashboard layout docs/06-observability-and-slo.md
The numeric steady-state trace sampling rate docs/06-observability-and-slo.md
Histogram bucket boundaries and percentile publication settings Micrometer config, docs/06-observability-and-slo.md
Log retention, sinks, exclusion filters, log-router config Terraform under deploy/terraform/ (C5 §5.5) - there is no infra/ tree
Config key defaults and the kill switches payment.fencing.enabled / rail.proxy.fencing.enabled C5 §5.1-§5.2
HTTP status and error codes that produce these outcomes C3 §3.2
Table and column names behind the fenced writes C1 §1.6
Alerting/notification channels, on-call routing, runbook text out of scope for the project

Back to top

Apache-2.0. The domain modelled here is fictional; every number is a labelled assumption. Not production-ready — see SECURITY.md.