feat(observability): Datadog observability — metrics, readiness, log export, dashboards and alerts (CHOO-2807) - #491
Open
christian-mcdermott wants to merge 6 commits into
Open
christian-mcdermott wants to merge 6 commits into
christian-mcdermott wants to merge 6 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
/healthalways answeredokwithout checking anything, and Helm used it for both probes. A pod with an unreachable database reported healthy.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 answers200either way.Everything is off until
OTLP_ENDPOINTnames 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, andsend_event's arbitrary event type is classified into four values rather than passed through.Readiness, deliberately narrow.
/healthstays 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/readyis new, and only the database gates it.switch-coreis pinned to one replica withRecreate, 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 logskeeps 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 carrytraceId/spanIdwhere 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
ruffclean;mypyunchanged from main (36 pre-existing).Review found two bugs, both fixed
Both the same mistake — a prefix match without a boundary — and both silent when they fire.
route_labeldropped the mount prefix for an inner route whose name merely starts with the mount's string; reproduced/gatewayishunder/gatewaybeing 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.
httpcorelogs 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.mdis the orientation;observability/catalogue.pyis the reference for what exists.SELECT 1is deliberately unbound — the database's health is not a question about one customer's rows — so it is allowlisted with that reasoning.🤖 Generated with Claude Code