Skip to content

feat(observability): Datadog observability — metrics, readiness, log export, dashboards and alerts (CHOO-2807) - #491

Open
christian-mcdermott wants to merge 6 commits into
mainfrom
work/datadog-observability
Open

christian-mcdermott wants to merge 6 commits into
mainfrom
work/datadog-observability

Conversation

@christian-mcdermott

@christian-mcdermott christian-mcdermott commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Operational observability for switch-core: what it measures, how that leaves the deployment, and what to look at when something breaks.

Jira: CHOO-2807

What was already there, and what wasn't

Structured logging landed earlier — JSON keyed for Datadog, with tenant, request, agent and user stamped on every line. That part needed nothing.

Everything else was missing, and two things were worse than missing:

  • /health always answered ok without checking anything, and Helm used it for both probes. A pod with an unreachable database reported healthy.
  • Nothing collects the container's output in this cluster. There is no log agent, so the Datadog-shaped JSON reached the container and stopped.

Approach

Hand-built OTLP over httpx, following Switch Console's telemetry client rather than pulling in the OpenTelemetry SDK. Everything a deployment sends is visible in one file, which matters for a product self-hosted by people entitled to read exactly that. The cost is owning the wire format, so protobuf JSON's 64-bit-integers-as-strings rule is tested directly — a strict receiver rejects a payload that gets it wrong and the relay answers 200 either way.

Everything is off until OTLP_ENDPOINT names a collector. Readiness is the exception: it runs regardless, because how Kubernetes routes traffic cannot depend on whether anyone is collecting metrics.

The parts

A metric catalogue. Nothing is emitted that is not declared in observability/catalogue.py, attributes included, and the registry rejects the rest. On a multi-tenant server an unchecked label is two problems at once: a cardinality bill, and a disclosure — dashboards are read by people who are not entitled to a given tenant's rows. Every attribute value comes from a set the code controls; the HTTP route is a template, the platform comes off the bridge, and send_event's arbitrary event type is classified into four values rather than passed through.

Readiness, deliberately narrow. /health stays the cheap always-ok reply — besides liveness, the gateway Deployment and the setup Job wait on it at boot, so anything it checked would become a boot-ordering dependency for them. /health/ready is new, and only the database gates it. switch-core is pinned to one replica with Recreate, so failing readiness does not shift traffic to a healthy pod — it empties the Service. A crashed Slack adapter is reported and alerted on, and must not take Switch off the air.

Instrumentation at the choke points that already exist rather than per handler: the bridge's inbound tracer, the transport's send and deliver funnels, one ASGI middleware. Failures that were previously invisible are now counted — the delivery loop swallows a room's error by design so one bad room cannot stop the others, which is also what made a room that had stopped delivering leave nothing but a log line.

Delivery lag measured per message, as the age of a row when it reached a handler, rather than inferred from a queue depth.

The process reports on itself — memory, CPU, descriptors, GC — because there is no infrastructure agent here. Event-loop lag is the one no agent could produce: the server is single-threaded, so one blocking call stalls every room and bridge at once. The connection sweep already measured its own oversleep and threw it away.

Logs shipped as well as written. stderr is never replaced, so kubectl logs keeps working and a collector outage costs a copy rather than the record. Off by default — a second copy over the network is a volume decision that belongs to whoever pays for it.

Dashboard and nine monitors as JSON in deploy/observability/, so a change to a metric and a change to the panel reading it land in the same review. No account, key or handle in them. The alert messages say what the alert means for Switch rather than restating the threshold; three things are deliberately not alerted on and the README says why, latency first — Switch holds long polls open on purpose, so a duration threshold pages on healthy traffic.

What is not here

Tracing. The relay Switch reports to does not serve /v1/traces — I probed it, it 404s. The flag exists and defaults off, because enabling it today means every export failing. Two things have to happen: the collector accepts the signal, and the server produces spans. Log records already carry traceId/spanId where set, so correlation needs no further change to the log path. Flagged in the room; awaiting a decision on whether to build it dark.

Pod and node metrics. Container restarts, OOM kills, evictions, Datadog's Kubernetes views — the process cannot see those. That needs the Datadog agent or the k8s integration, and is infrastructure work nothing here depends on.

Verification

  • Full suite green: 3371 passed. ruff clean; mypy unchanged from main (36 pre-existing).
  • Helm rendered across every documented combination, including all three invalid ones, which abort the release rather than producing a pod that reports nothing.
  • End to end against the real relay: a metrics payload carrying a sum, a gauge and a histogram is accepted, and so is a log batch carrying an exception with its stack.

Review found two bugs, both fixed

Both the same mistake — a prefix match without a boundary — and both silent when they fire.

route_label dropped the mount prefix for an inner route whose name merely starts with the mount's string; reproduced /gatewayish under /gateway being labelled /gatewayish, which is exactly the collision the label exists to prevent. Nothing routes that way today.

The log handler's self-exclusion did nothing about the HTTP client underneath the exporter. httpcore logs per connection at DEBUG, so with log shipping on at that level, each export manufactured the records the next export had to send. Now anything logged inside an export's own window is dropped, scoped by context variable so a concurrent request still ships.

Notes for the reviewer

  • docs/old/observability.md is the orientation; observability/catalogue.py is the reference for what exists.
  • One guardrail in this repo earned its keep: the tenant-exemption allowlist failed the build the moment the readiness check opened an unbound session. The SELECT 1 is deliberately unbound — the database's health is not a question about one customer's rows — so it is allowlisted with that reasoning.
  • No version numbers touched.

🤖 Generated with Claude Code

christian-mcdermott and others added 6 commits September 16, 2026 14:53
…HOO-2807)

Structured logging already reported in Datadog's shape; nothing reported a
measurement. This adds the path one leaves by, and the rules about what may.

Hand-built OTLP over httpx rather than the OpenTelemetry SDK, following
Switch Console's telemetry client: everything a deployment sends is visible in
one file, and Switch is self-hosted by people entitled to read exactly that.
The cost is owning the wire format, which is why protobuf JSON's 64-bit-
integers-as-strings rule is tested directly — a receiver that parses strictly
rejects a payload that gets it wrong, and the relay answers 200 either way.

Metrics are declared in a catalogue, attributes included, and the registry
refuses anything undeclared. On a multi-tenant server an unchecked label is
both a cardinality bill and a disclosure: metrics are read by whoever can see
the dashboards, who are not the people entitled to a given tenant's rows.

Aggregation is delta, so a restart loses one interval rather than reading
downstream as a counter rollback. Gauges are pulled at collection rather than
pushed, so a crashed bridge stops reporting instead of reporting forever the
count it had when it died.

Off unless OTLP_ENDPOINT names a collector. With one named, DEPLOYMENT_ID is
required and must be a UUID: the collector drops unidentified payloads in
silence with a 200, so a deployment without it would look configured, log
nothing wrong, and appear on no dashboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HOO-2807)

`/health` answered `{"status":"ok"}` without checking anything, and Helm used
it for both probes — a pod with an unreachable database reported healthy. It
could not simply be tightened: the gateway Deployment and the setup Job both
wait on it at boot, so anything it checked would become a boot-ordering
dependency for them. It stays as liveness; `/health/ready` is new.

What gates readiness is deliberately narrow. switch-core is pinned to one
replica with a Recreate strategy, so a failing readiness probe does not shift
traffic to a healthy pod — it empties the Service. Only the database gates.
A crashed collaboration bridge is reported and alertable but must not take
Switch off the air to punish one dead adapter.

The checks run on their own schedule and both readers — the kubelet every ten
seconds, the metrics exporter every minute — read one cached answer. The cache
carries its own age, and a cache nobody is refreshing reports itself as a
failure, which is also how a blocked event loop surfaces.

Wired in: HTTP requests and latency by route template (never the path, which
is unbounded and attacker-chosen on a 404), pool usage, live agents, running
clients and bridges, and the process's own memory, CPU, descriptors and
event-loop lag — the last from the connection sweep, which already measured
its own oversleep and threw it away. That covers most of what an
infrastructure agent would report, and the deployment has none.

Two small public accessors were added rather than reaching into privates:
bridges and clients both drop a crashed member from their registry, so
"configured but no longer running" was not answerable from outside.

The tenant-exemption allowlist gains the readiness check. Its `SELECT 1` binds
no tenant on purpose: the database's health is not a question about one
customer's rows, and a check that read a scoped table would be the one that
passes in development and raises under the restricted runtime role.

Verified end to end against the relay: a payload carrying all three metric
kinds is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2807)

Instrumented at the choke points that already exist rather than per adapter or
per handler.

Inbound bridge events at `BridgeCore._traced`, which every platform event
already passes through for its log id and tenant binding. Outbound at the relay
call rather than the top of the handler: a handler returns early for a puppet's
own echo and for a room with no channel mapping, and neither is something
anybody sent outwards.

Failures are counted on both sides and re-raised unchanged. An inbound handler
that raises is a message a person sent and nobody received, which from the
platform's side is indistinguishable from a message nobody answered.

Sends and deliveries at the transport's own funnels. Delivery lag is measured
per message, as the age of the row when it reached a handler, rather than
inferred from a queue depth — the queue was also per client and could not be
aggregated into one series. The delivery loop's swallowed exception is now
counted: swallowing is what keeps one room's failure from stopping the others,
and it is also what made a room that had stopped delivering invisible.

The labels are bounded by the code, not by callers. `send_event` takes any
event type its caller passes, so it is classified into four values rather than
used directly; the platform comes off the bridge; the HTTP route is the
template. An attribute a caller could choose is a series a caller could mint.

Several bridge tests assemble a BridgeCore out of `object.__new__` and set only
the attributes the path they exercise touches, so they gain the bridge type and
the outbound helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…OO-2807)

The server has written Datadog-shaped JSON since tenant context landed, and in
this deployment nothing collects it — there is no log agent in the cluster, so
those lines reach the container and stop. This is the other end.

stderr is not replaced. The handler is additional: `kubectl logs` keeps
working, a log agent added later reads the same stream, and a collector outage
costs a copy rather than the record. Off by default for the same reason —
shipping a second copy over the network is a volume decision that belongs to
whoever pays for it.

Three properties a log exporter needs that a metrics one does not:

It never blocks. `logger.info` is called from anywhere, including the OS thread
the Mattermost adapter dispatches on, so the handler only enqueues and a task
drains.

It never grows without bound. A collector that stops answering while the server
keeps logging would be a memory leak in the observability of the thing being
observed. The queue is capped, drops the oldest, and reports how many — a gap
that announces itself rather than one nobody can see.

It does not feed itself. Export failures are logged, and queueing those would
make a failing collector generate exactly the traffic that is failing, so
records from this package are written to stderr and never shipped.

The handler carries the same log-context filter the stderr handler does.
Without it a shipped record has no tenant, request or agent on it, which is the
whole reason shipping logs beats counting them.

Verified end to end against the relay: a batch carrying an exception with its
stack is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s (CHOO-2807)

The chart gains `switchCore.observability`, and refuses to render an endpoint
without a deployment id — the same thing switch-core refuses to start on, moved
to `helm upgrade` so an operator finds out before the pod does.

The readiness probe moves to `/health/ready`, with six failures allowed at ten
seconds: long enough to ride out a database failover, short enough that a real
outage stops being served. Liveness stays on `/health`, which is also what the
gateway Deployment and the setup Job wait on at boot.

`deploy/observability/` carries the Datadog dashboard and nine monitors as
JSON, so a change to a metric and a change to the panel reading it land in the
same review. No account, key, handle or URL is in them; the notification
destination is a placeholder to fill in at import.

The monitor messages say what the alert means for Switch rather than restating
the threshold — that the listener being down looks healthy from outside while
no message reaches anybody, that pool exhaustion presents as the database being
down when the database is fine, that an event-loop stall is everything at once
rather than one slow thing. Three things are deliberately not alerted on and
the README says why, latency first: Switch holds long polls open on purpose, so
a duration threshold pages on healthy traffic.

The README is also honest that the histogram panels depend on how the collector
maps OTLP histograms, and will be empty rather than quietly wrong if it does
not use distributions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HOO-2807)

Both found by review, both the same mistake in different places, and both
silent when they fire.

`route_label` concatenated the mount prefix only when the route path did not
already start with it. A route's path is always relative to its mount, so the
check never had anything to catch — but an inner route whose name merely
*starts with* the mount's own string passes it and loses its prefix.
Reproduced: `/gatewayish` under the `/gateway` mount was labelled
`/gatewayish`, which is exactly the collision the label exists to prevent.
Nothing routed that way today; the next route added under the gateway could.
Now concatenated unconditionally.

The log handler's self-exclusion was a bare `startswith`, which would also
swallow a future sibling package, and — more to the point — did nothing about
the HTTP client underneath the exporter. `httpcore` logs a line per connection
at DEBUG, and DEBUG is a level a deployment may legitimately run at, so with
log shipping on, every export manufactured the records the next export had to
send. The name match is now anchored on a dotted boundary, and anything logged
inside an export's own window is dropped: a context variable rather than a
flag, so it is exactly that task's window and a concurrent request logging at
the same moment is still shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant