Observability
What is specified, what is instrumented, and what is deployed are three different lists, and this page keeps them apart. Most of it is a contract you can code against; the part a buyer needs is the per-service table below, and the honest part of that table is its right-hand column.
The three states
Section titled “The three states”| State | What it means | Who has it |
|---|---|---|
| Spec’d | core owns the contract and a rule exists, enforced by a test |
every service, and the collector |
| Instrumented | the service wires an SDK and emits the signal from its own code, to an endpoint you supply | courier, billing, identity, muse (traces) |
| Deployed | a collector is running and spans are landing somewhere you can read | nobody. Not in any cafaye-operated environment |
These are not three ways of saying “less done”. A service can be fully instrumented with nothing to export to — which is the normal state of a self-hoster’s first day, and the state of every cafaye environment today — and the work to close the gap is one variable and, for the stack itself, one command.
Per service
Section titled “Per service”Read core’s
fleet.yml for the
machine-readable version of this table, and
Running the gates for the tier that checks this page
against it.
| Service | Spec’d | Instrumented | Deployed | What it emits |
|---|---|---|---|---|
courier |
yes | yes | no | traces |
billing |
yes | yes | no | traces |
identity |
yes | yes | no | traces |
muse |
yes | yes | no | traces |
darkroom |
yes | no | no | correlated logs only — see below |
gateway |
yes | no | no | nothing |
parlor |
yes | no | no | nothing |
Four services export traces and none of them exports metrics or logs.
That is not modesty, it is what the code says: none of the four installs a
meter.
courier cannot — the Erlang SDK has no metrics API at all, which is why its
lib/courier/telemetry.ex argues the point from a directory listing rather than
from taste.
Metrics exist in the stack anyway: kit’s collector derives them from spans with
the spanmetrics connector, after the redaction processor, so a metric
cannot carry a dimension the boundary would have stripped.
darkroom is a fourth state, and it is worth naming rather than rounding to
“no”.
It has no opentelemetry dependency and no exporter; it uses the Rust tracing
facade with a JSON subscriber and keeps its own trace_id in a task-local, so
the response header, the problem body, and every log line under the request read
the same value.
That is a real correlation story and it is not an OTLP span story.
Its log records do reach the stack, indirectly: the collector’s syslog/crash
receiver reads every container’s stderr, so a tracing line becomes a log
record in Loki with no per-language SDK at all.
gateway and parlor emit nothing.
They owe the probe contract — /healthz and /readyz — and that is the whole
of their telemetry surface.
The decision that is settled: on by default, and exercised in development. A developer working on cafaye sees real traces, real metrics and a real error view with nothing switched on, because the default endpoint points at the collector that ships with the local stack. Two escape hatches are first-class rather than an afterthought:
- Bring your own backend. A self-hoster already running Datadog, Honeycomb or
Grafana Cloud points
<SERVICE>_OTEL_ENDPOINTat it, and the shipped stack goes quiet. That is a supported deployment, not a degraded mode. - One variable to turn it off. Unset the variable and the service gets a
genuine no-op.
coreasserts that path rather than trusting it: the schema declaresrequiredasconst: false, and a test named for the no-op path fails if unsetting the endpoint stops being declared as free.OTEL_SDK_DISABLEDand<SIGNAL>_EXPORTER=noneare the OpenTelemetry specification’s own kill switches, and the services read those rather than reimplementing “disabled” in four languages.
The specs live in cafaye/core and are manager-owned. This page summarizes and links; it does not define.
The seven schemas
Section titled “The seven schemas”A rule that is not in schemas/ is not a cafaye rule. Every rule below is a
JSON Schema constraint, and each has an invalid example under
examples/invalid/ naming the exact keyword it exists to reject.
| Schema | What it decides |
|---|---|
span-naming.schema.json |
one span-name scheme, low-cardinality by construction |
traces.schema.json |
the trace attribute allowlist |
metrics.schema.json |
the measurement allowlist, and the ban on unbounded identifiers |
logs.schema.json |
the log attribute allowlist |
redaction.schema.json |
the redaction boundary, and where it is enforced |
otel-endpoint.schema.json |
the <SERVICE>_OTEL_ENDPOINT contract and the no-op path |
probes.schema.json |
/healthz unconditional, /readyz really checking |
Span names
Section titled “Span names”span-name := <service> "." <operation> [ "." <target> [ "." <qualifier] ] ]service := cafaye namespace name — kebab-case, may contain a dashoperation := lowercase word, optional internal underscore, at most 15 charactersGood: muse.request · muse.route · muse.provider.call ·
identity.db.query · courier.email.deliver · gateway.request.authorize
Never: GET /users/:id · get_user · users.GET ·
muse.user.usr_01J9Z8QK5M4N7P2R3T6V8W9X0A
The prefix is mandatory and it is the emitting service’s own name, the same
rule as an event type’s <service>.<entity>.<action>. The form is deliberately
the same shape, so the fleet has one dotted-lowercase grammar rather than two.
The 15-character segment bound is what does the work. A name that interpolates a value is rejected not because the grammar knows about cafaye id formats but because no legal segment is twenty-six characters long. A trace backend is a search engine, and the index becomes unusable long before anyone notices.
The name is the operation; everything else is an attribute. An attribute
in the name is an attribute that cannot be filtered out. kind — internal,
server, client, producer, consumer — is a span field, never part of
the name.
Attributes are allowlisted, per signal
Section titled “Attributes are allowlisted, per signal”Default-deny: an attribute that is not on its signal’s list is not emitted, so something added in a hurry is dropped rather than shipped.
| Attribute | traces | metrics | logs |
|---|---|---|---|
http.request.method |
✓ | ✓ | |
http.response.status_code |
✓ | ||
http.response.status_code_class |
✓ | ||
http.route |
✓ | ✓ | |
db.system · db.operation |
✓ | ✓ | |
messaging.system |
✓ | ✓ | |
messaging.operation |
✓ | ||
otel.status_code |
✓ | ||
error.type |
✓ | ✓ | ✓ |
log.severity |
✓ | ||
service.name |
✓ |
Two of those rows carry the design:
http.routeis the template./v1/users/{id}is one value per endpoint./v1/users/usr_01J9Z8QK5M4N7P2R3T6V8W9X0Ais one per request, which is whyurl.pathandurl.fullare on no list at all.- Metrics are coarser than traces on purpose. Traces carry the status code;
metrics carry the status class. A metric multiplied by 500 codes and every
route is how a service hits a series cap inside a week, and
4xxanswers the question a dashboard actually asks.
The prohibition: no unbounded identifier on a measurement
Section titled “The prohibition: no unbounded identifier on a measurement”A tenant id, a user id, a request id, an event envelope id — none of them may be
a metric label. It is the failure that looks harmless while you have one
tenant and costs you the whole time series after you have a thousand.
error.type × service.name is what you aggregate on instead.
The redaction boundary
Section titled “The redaction boundary”Prompt and completion content must never appear in a telemetry span. muse
is an LLM gateway, so its spans will naturally want to record the prompt —
“which model, which prompt shape, how long” is the obvious thing to instrument,
and it is the obvious thing that writes every customer’s content into a
searchable, retained, widely-readable store.
Encoding “don’t log secrets” in prose has been tried across this fleet and it does not hold, so the spec encodes the allowlist instead. What may be recorded about an LLM call is a fact about how it was served:
| Attribute | Fact it is about |
|---|---|
llm.model |
which model served the call |
llm.tokens_in / llm.tokens_out |
the count, never the text it counted |
llm.latency_ms |
how long |
llm.finish_reason |
the class — stop, length, tool_call |
llm.provider · llm.cost_micros · llm.candidates_tried · llm.breaker_state |
the routing decision |
May never be recorded, anywhere: the prompt, the completion, message
content, tool arguments, system instructions, the transcript, anything a caller
typed, a credential, error.message, or a stack trace.
error.type
Section titled “error.type”A low-cardinality class: snake_case, at most 64 characters, drawn from a vocabulary the whole fleet shares. Never a message, never a stack trace, never an interpolated value.
/healthz and /readyz
Section titled “/healthz and /readyz”Liveness and readiness are the two halves of “what does green mean”, and
darkroom is the pattern. Both are specified as schemas, which means both
failure directions are machine-checked:
/healthzis unconditional. Its dependency list is constrained to be empty, so a liveness probe that starts checking the database cannot validate. A liveness probe that fails on a dependency tells the orchestrator to restart a process that is fine, which turns a database outage into a fleet-wide crash-restart loop and destroys the evidence you need to diagnose it./readyzreally checks. Its check list has a minimum of one, so a service with areadyzthat checks nothing fails the schema. That failure is invisible in practice — the endpoint returns200and looks perfect in every dashboard — and a load balancer will cheerfully route traffic into a service whose database is gone.- Both are exempt from authentication, by an explicit path allow-list rather
than by route order.
axum’sRouter::layerapplies to every route the router holds, so registering the probes “before” the auth layer does not exempt them: a/healthzbehind the auth middleware returns401, every instance is marked unhealthy, and the deployment rolls back with nothing in the logs saying why.
What a service does with this
Section titled “What a service does with this”cafe contract lint reads these seven schemas alongside the event schemas, so a
service’s telemetry declaration is checked against core the same way its
cafaye.yml is.
kit carries the stack, and it is complete rather than a sketch. Its compose
templates are Postgres, NATS+JetStream, Redis, the OpenTelemetry Collector, and
the four LGTM services behind it — Grafana, Loki, Tempo, Mimir — plus the
six-language OTel snippets. The collector’s three pipelines fan out for real:
traces to Tempo, metrics to Mimir, logs to Loki, and debug alongside them as a
local escape hatch that writes to the collector’s own stdout. Every address is a
${KIT_*} substitution, so pointing one at a backend you already run bypasses
the shipped stack rather than extending it — which is the supported way to bring
your own.
The redaction boundary is enforced in that collector, and the allowlist is
derived rather than transcribed. Every attribute core allows on a signal is
in the collector’s allowed_keys for that signal, and nothing is in it that
core does not allow; kit’s gate reads core’s schemas off disk and compares
both ways, printing the core commit it compared against. A metric cannot escape
the boundary either, because spanmetrics runs after the redaction processor.
How you get it running. Copy kit’s bin/dev into a service repository and
run it: it fetches the stack from a pinned ref, brings it up with --wait,
migrates, and prints the URLs. It is in courier, billing and identity today.
kit proves the fetched stack actually runs rather than merely parsing —
tests/stack_live_test.sh brings the whole thing up, sends real OTLP, and reads
a trace back out of Tempo and a metric out of Mimir, asserting the redaction
canary is in neither.
Known drift, recorded rather than papered over. muse reads
MUSE_OTEL_EXPORTER_OTLP_ENDPOINT — the OpenTelemetry standard spelling —
while this spec and core’s fleet.yml say MUSE_OTEL_ENDPOINT. Three places,
three spellings, and muse agrees with neither. The rename is one string in
muse’s main module and one line in its config test, and it is cheaper today
than after three more services have copied the spelling out of muse’s code.
What does not exist yet
Section titled “What does not exist yet”Stated so a reader can tell the difference between “not built yet” and “not planned”:
- No collector is deployed in any environment. The stack is generatable and
kithas a test that runs it, but nothing in a cafaye-operated deployment receives a span. This is the one line on this page that has no code behind it, and it is the whole of what “not deployed” means. - The shipped dashboards are provisioned, not curated, and they are new.
kitships two: cafaye — every error in the fleet (twelve panels, grouped on span status rather thanerror.type) and cafaye — local stack. They come up on first load with no configuration, and they are the reason a cross-service error view is possible today rather than a thing you would have to write. What does not exist is a dashboard maintained against a real incident. museis not migrated to the boundederror.typevocabulary, andbillingis on a divergent list of its own.courierandidentityare on core’s thirteen. Seeerror.typeabove for what that means for a panel.darkroom,gatewayandparlorexport no OTel signal.darkroomhas a real correlation story ontracingand reaches Loki through the collector’s stderr receiver; the other two owe the probes and nothing more.coreowns the contract only. There is deliberately no collector, exporter or per-language SDK incore— that iskit’s job and each service’s. A rule added tocorethat no service implements is a rule that lies.
See also
Section titled “See also”- Topology — the probes table, and the variables each service actually reads.
- Contracts — the rest of the spec surface.
- Running the gates — how each repository’s suite is run, including the tiers that do not run by default.