Observability¶
Four views of the same events, with one instrumentation point behind them.
| View | What it is for | Where |
|---|---|---|
| Audit trail | What happened, per request, forensically | stdout, JSON |
| Metrics | Rates and trends, alerting | Prometheus, or OTLP |
| Events | "who has been deploying here", per cell | kubectl describe cluster |
| Traces | Where the time in one request went | OTLP, once configured |
One record, three views¶
Every placement and every mint produces one audit.Record. The JSON trail, the
Kubernetes Events, the Prometheus counters and the attributes on a trace's spans
are all views over it.
A metric incremented at its own call site drifts from the log line next to it the first time somebody adds an early return, and the drift is invisible: both look fine, they just describe different things. Deriving all three from one record means a refusal that is audited is also counted.
The consequence for contributors: new hub instrumentation goes on the audit record, not on a new call site.
The audit trail¶
One JSON line per placement and per mint, tied together by request_id:
{"time":"2026-09-08T12:31:50Z","level":"INFO","msg":"audit",
"event":"placement","outcome":"granted","request_id":"03c2fb846fa6f746",
"issuer":"https://token.actions.githubusercontent.com",
"subject":"repo:acme/checkout:ref:refs/heads/main",
"workload":"checkout-api","cell":"prod-euw1","policy":"app-prod",
"strategy":"LeastLoaded","confidence":"high",
"candidates":[{"cell":"dev-euw1","admitted":false,"stage":"permission",
"reason":"cell is not permitted by this caller's policy"}]}
Three properties of the trail:
- It is written through its own handler at a fixed level.
--log-level=errorsuppresses the hub's chatter and never the trail. Sharing one logger would make the audit record a debugging convenience rather than a record. - It cannot carry token material. A reflection test enumerates every field
on the record and fails on any new one that has not been classified. Mints
record
token_sha256, a digest for correlation. - A refusal records what was considered. The candidate table travels on the
refusal, so "why was my deploy refused" is answerable from the log rather than
only from a client that happened to pass
--explain.
Worked queries are in the audit trail reference.
Metrics¶
The full surface, the cardinality argument, and the four metrics whose definitions are not obvious are in the metrics reference. The short version:
# Is cellcast adding time to every deploy?
histogram_quantile(0.99, sum by (le) (rate(cellcast_placement_duration_seconds_bucket[5m])))
# What share of placements is being refused, and why?
sum by (reason) (rate(cellcast_placement_requests_total{outcome="refused"}[5m]))
# Can every replica actually place?
min(cellcast_hub_warm)
# Which cells has the hub stopped hearing from?
cellcast_capacity_staleness_seconds > ignoring(cell) group_left() (2 * cellcast_capacity_staleness_window_seconds)
A dashboard ships in dashboards/cellcast.json and five alerting rules in
config/prometheus/prometheusrule.yaml. Both are templated by the hub chart:
helm upgrade cellcast oci://ghcr.io/ethan-kane-ops/charts/cellcast --reuse-values \
--set metrics.serviceMonitor.enabled=true \
--set metrics.prometheusRule.enabled=true
A contract test asserts the dashboard, the alerts and the documentation agree with what the registry actually emits, in both directions: a metric nobody plots fails the build, and so does a dashboard panel querying a metric that no longer exists.
The metrics endpoint is unauthenticated
It discloses cell names, policy names and refusal counts, which an
authenticated caller could already list through the API. It carries no
caller identity, no workload name and no credential. Reaching it should
still be a NetworkPolicy decision: networkPolicy.enabled in the chart.
Kubernetes Events¶
The audit trail's second view, hung on the Cluster each record concerns, so
the question "who has been deploying here" is answerable with kubectl:
$ kubectl -n cellcast-system describe cluster prod-euw1
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Placed 2m cellcast-hub placed checkout-api for repo:acme/checkout:ref:refs/heads/main under policy app-prod
Normal CredentialIssued 2m cellcast-hub issued a 15m0s credential to repo:acme/checkout:ref:refs/heads/main, scoped to apps/deployer
Events are lossy by design: rate-limited, and they expire. They are a convenience on top of the trail rather than a substitute for it. Dry runs produce no event, because a dry run is not a deploy.
Only the leader emits them, which is why they appear once rather than once per replica.
OTLP export¶
The hub exports traces and metrics over OTLP, and the agent exports traces, to any collector: an OpenTelemetry Collector, Grafana Alloy or a Datadog agent. Nothing is exported until an endpoint is set.
helm upgrade cellcast oci://ghcr.io/ethan-kane-ops/charts/cellcast --reuse-values \
--set observability.otlp.endpoint=http://otel-collector.observability:4318
| Flag | Chart value | Default |
|---|---|---|
--otlp-endpoint |
observability.otlp.endpoint |
none, so nothing is exported |
--otlp-protocol |
observability.otlp.protocol |
http/protobuf |
--trace-sample-ratio |
observability.otlp.traceSampleRatio |
1 |
The endpoint is a base URL, as OTEL_EXPORTER_OTLP_ENDPOINT is: traces go to
/v1/traces under it and metrics to /v1/metrics. Its scheme decides TLS. A
backend that needs an API key gets it from a Secret named in
observability.otlp.headersSecret, which reaches the process as
OTEL_EXPORTER_OTLP_HEADERS and never as an argument.
The standard environment variables work with none of the above:
OTEL_EXPORTER_OTLP_ENDPOINT and its per-signal forms, _PROTOCOL, _HEADERS,
_CERTIFICATE, _COMPRESSION and _TIMEOUT, plus OTEL_SERVICE_NAME,
OTEL_RESOURCE_ATTRIBUTES, OTEL_METRIC_EXPORT_INTERVAL,
OTEL_TRACES_EXPORTER=none, OTEL_METRICS_EXPORTER=none and
OTEL_SDK_DISABLED. A flag wins over its variable. A collector that is down is
logged at most once a minute and never stops the hub.
Traces¶
One placement is one trace, under the caller's own span when the request
carries a traceparent header:
POST /api/v1/placement
├── authenticate issuer discovery and key fetches
├── place
│ ├── select policy
│ ├── filter
│ └── score
└── mint the TokenRequest round trip to the spoke
Each heartbeat is one trace too: POST /api/v1/clusters/{name}/capacity in the
agent, with the hub's span for the report under it. The mint sends its trace
context on to the spoke, so a spoke API server with tracing enabled joins the
trace. A trace that arrives sampled stays sampled whatever the ratio says.
Spans carry the audit record and nothing else. Apart from the method, route,
status code and request id, every attribute is an audit field under the name
the trail uses, prefixed cellcast.: cellcast.subject on a span is subject
in the audit line. Four fields stay off spans, because a trace backend is
usually read by more people than the audit trail: the claims, the candidate
table, token_sha256 and the internal error. cellcast.request_id leads from
a span to the full record, and the request log line carries trace_id for the
way back.
The classification that decides this is the one that keeps token material out of the trail, and the build fails if any file other than the tracing files puts anything on a span.
Metrics¶
The OTLP metrics are the Prometheus registry, bridged as it stands: the names, labels and buckets a scrape of the metrics endpoint reports, so the dashboard, the alerts and the metrics reference apply to either path. That includes the controller-runtime and Go runtime metrics the endpoint also serves; drop them in the collector if the backend bills per series.
Datadog¶
Point OTLP at the Datadog agent rather than linking a vendor SDK into the binary that mints credentials. With the Datadog agent's OTLP receiver enabled on every node, in the Datadog chart's values:
then point cellcast at the agent on its own node:
helm upgrade cellcast oci://ghcr.io/ethan-kane-ops/charts/cellcast --reuse-values \
--set 'observability.otlp.endpoint=http://$(HOST_IP):4318'
HOST_IP is set from the node's address whenever an endpoint is. Tag the
service through extraEnv, for example
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=prod. If the Datadog
agent already scrapes the metrics endpoint, set OTEL_METRICS_EXPORTER=none to
keep one copy.
What to alert on¶
The five shipped rules, and what each catches that a dashboard panel would not:
| Alert | Catches |
|---|---|
CellcastCapacityStale |
A cell whose agent stopped reporting, so deploys silently concentrate elsewhere |
CellcastCellNeverReported |
A registered LIVE cell that has never reported, so it can never be placed on |
CellcastReplicaCannotPlace |
A replica in the Service that still has no capacity for the fleet |
CellcastAuthRejectionRatioHigh |
A broken pipeline, or somebody probing |
CellcastNoAuthenticator |
A hub refusing everything because no OIDC issuer is configured, while passing its own health checks |
The last is the misconfiguration that stops every deploy in the estate while looking healthy from the outside.