# Telemetry
## What Crosswake Telemetry Is
`Crosswake.Telemetry` is the canonical public API for the `:telemetry` events Crosswake emits across its companion, doctor, threadline, sigra, and chimeway subsystems.
Call `Crosswake.Telemetry.events/0` at runtime to retrieve a self-describing catalog of every event Crosswake declares. Each entry carries the event name prefix, a tier (`:active` for events that fire today, `:reserved` for events declared but not yet emitted), a description, and the exact measurement and metadata keys the event carries. This catalog is the source of truth for host observability pipelines, dashboards, and contract tests.
Telemetry in Crosswake is diagnostic-only. The library emits standard `:telemetry` events and exposes a typed, low-cardinality, PII-free catalog. It coexists with any host-side observability pipeline without requiring changes to existing instrumentation. No new dependencies are introduced — only `:telemetry`, which is already a project dependency.
## What Crosswake Telemetry Is NOT
These are non-goals by design, not deferred features.
- **Not an APM / observability platform.** `Crosswake.Telemetry` emits events and catalogs them. It does not collect, ingest, sample, or aggregate telemetry. Host teams choose their own pipeline.
- **Not a distributed tracing framework.** Crosswake does not create or manage trace context, spans in the OTel sense, or baggage. If your host uses OpenTelemetry, attach a host-owned OTel handler to bridge the `:telemetry` events into your OTel pipeline.
- **Not a generic event bus.** The event catalog is typed, low-cardinality, and scoped to Crosswake subsystem activity. It is not an open subscription API for arbitrary host events.
- **Not a source of PII.** All metadata passes through a forbidden-key denylist before emission. PII-bearing fields are silently dropped. See [Security and PII](#security-and-pii).
## Semver Contract
Telemetry events are **public API**.
- **Additions** (new event prefixes, new measurement keys, new metadata keys) are non-breaking minor releases.
- **Removals or renames** of existing event prefixes, measurement keys, or metadata keys are breaking changes requiring a semver major bump.
Host code that attaches handlers to Crosswake events may depend on this stability guarantee.
## Events
The table below lists every `:active` event from `Crosswake.Telemetry.events/0` — events that fire today in production code. Event names follow the Keathley span convention: a prefix atom list expanded to `:start`, `:stop`, and `:exception` suffixes.
For span-based events (emitted via `:telemetry.span/3`), the `:start` event carries `system_time` in measurements and the `:stop`/`:exception` events carry `duration`. For the four companion spans, `companion_id` and `route_id` are passed as the span context map (the second argument to `:telemetry.span/3`), which `:telemetry` places in **metadata**, not measurements — this is the Keathley span convention.
**Stop metadata is a superset of start metadata** for span-based events. See the threadline `:exception` caveat below.
### Companion: dependency\_check
Emitted when `RouteGate` checks a companion's optional dependency presence.
| Suffix | Measurements | Metadata |
|--------|-------------|----------|
| `:start` | `system_time` | `companion_id`, `route_id` |
| `:stop` | `duration` | `companion_id`, `route_id` |
| `:exception` | `duration` | `companion_id`, `route_id` |
Event names:
- `` `[:crosswake, :companion, :dependency_check, :start]` ``
- `` `[:crosswake, :companion, :dependency_check, :stop]` ``
- `` `[:crosswake, :companion, :dependency_check, :exception]` ``
### Companion: kill\_switch
Emitted when `RouteGate` evaluates a companion's kill switch. Short-circuits ahead of route\_gate evaluation.
| Suffix | Measurements | Metadata |
|--------|-------------|----------|
| `:start` | `system_time` | `companion_id`, `route_id` |
| `:stop` | `duration` | `companion_id`, `route_id` |
| `:exception` | `duration` | `companion_id`, `route_id` |
Event names:
- `` `[:crosswake, :companion, :kill_switch, :start]` ``
- `` `[:crosswake, :companion, :kill_switch, :stop]` ``
- `` `[:crosswake, :companion, :kill_switch, :exception]` ``
### Companion: route\_gate
Emitted when `RouteGate` evaluates a companion's route policy.
| Suffix | Measurements | Metadata |
|--------|-------------|----------|
| `:start` | `system_time` | `companion_id`, `route_id` |
| `:stop` | `duration` | `companion_id`, `route_id` |
| `:exception` | `duration` | `companion_id`, `route_id` |
Event names:
- `` `[:crosswake, :companion, :route_gate, :start]` ``
- `` `[:crosswake, :companion, :route_gate, :stop]` ``
- `` `[:crosswake, :companion, :route_gate, :exception]` ``
### Companion: validate\_dependency
Emitted when Doctor runs `validate_dependency/0` for each registered companion. The `:stop` event carries an additional `result` metadata key.
| Suffix | Measurements | Metadata |
|--------|-------------|----------|
| `:start` | `system_time` | `companion_id`, `route_id` |
| `:stop` | `duration` | `companion_id`, `route_id`, `result` |
| `:exception` | `duration` | `companion_id`, `route_id` |
Event names:
- `` `[:crosswake, :companion, :validate_dependency, :start]` ``
- `` `[:crosswake, :companion, :validate_dependency, :stop]` ``
- `` `[:crosswake, :companion, :validate_dependency, :exception]` ``
### Threadline: request
Emitted by `Crosswake.Plug.Threadline` for each incoming HTTP request correlation span. Unlike the companion spans above, this event is emitted via discrete `:telemetry.execute` calls (not `:telemetry.span/3`), so measurements and metadata are set explicitly per suffix.
| Suffix | Measurements | Metadata |
|--------|-------------|----------|
| `:start` | `system_time` | `thread_id`, `correlation_id`, `route_id`, `source` |
| `:stop` | `duration` | `thread_id`, `correlation_id`, `route_id`, `source` |
| `:exception` | `duration` | (see caveat below) |
**Threadline `:exception` metadata caveat.** The `:exception` event is emitted when a plug exception occurs before the correlation context is fully established. In this case, `kind` and `reason` are passed as metadata, but they are not in the threadline metadata allowlist (`thread_id`, `correlation_id`, `route_id`, `source`) and are dropped by the PII guard. The exception event carries timing measurements (`duration`) but the metadata map may be effectively empty. Do not assert that `thread_id` is present in a threadline `:exception` event — the stop ⊇ start metadata guarantee holds for `:stop` but not for `:exception` in the threadline case.
Event names:
- `` `[:crosswake, :threadline, :request, :start]` ``
- `` `[:crosswake, :threadline, :request, :stop]` ``
- `` `[:crosswake, :threadline, :request, :exception]` ``
## Reserved Events
Sigra and Chimeway each declare event catalogs (via `event_names/0`) that are not yet emitted from production code. These appear in `Crosswake.Telemetry.events/0` with `tier: :reserved`.
Reserved events are:
- **Included** in `events/0` so adopters can see what is planned and attach handlers speculatively.
- **Excluded** from the declared⇒emitted contract test (they do not fire yet).
- **Included** in the emitted⇒declared contract test (if code accidentally starts firing them, it will be caught).
Sigra declares 14 reserved events; Chimeway declares 10. Their event names follow the `` `[:crosswake, :sigra, ...]` `` and `` `[:crosswake, :chimeway, ...]` `` prefixes respectively. Filter by `tier: :reserved` in `events/0` to enumerate them.
`Crosswake.Offline.Telemetry` is a metadata-contract module only; it has no event catalog and contributes no events to `events/0`.
## Attaching the Default Logger
Core never attaches a telemetry handler automatically. Attachment is always the host's explicit decision.
To attach the default structured logger to all `:active` Crosswake events, call `Crosswake.Telemetry.attach_default_logger/1` from your application's supervision tree or `Application.start/2`:
```elixir
# In your Application.start/2 or a startup module:
:ok = Crosswake.Telemetry.attach_default_logger()
```
The function accepts a log level atom or a keyword list:
```elixir
# Attach with a specific log level
Crosswake.Telemetry.attach_default_logger(:debug)
# Attach with keyword options
Crosswake.Telemetry.attach_default_logger(level: :info, encode: false)
```
**Options:**
| Option | Default | Description |
|--------|---------|-------------|
| `:level` | `:info` | Log level for non-exception events. `:exception` events are always logged at `:error` regardless of this setting. |
| `:encode` | `false` | When `false`, the event map is placed in `Logger` metadata under the `:crosswake_telemetry` key so host formatters can handle JSON encoding. When `true`, the map is JSON-encoded into the log message string. |
The handler is registered under the id `"crosswake-default-logger"`. Log messages are prefixed with `[crosswake]`.
To remove the handler:
```elixir
Crosswake.Telemetry.detach_default_logger()
```
## Failure Modes
**No companions configured.** `events/0` returns core and in-tree events only. It never raises. This is a valid state, not a misconfiguration.
**Double-attach.** Calling `attach_default_logger/1` when a handler with id `"crosswake-default-logger"` is already registered returns `{:error, :already_exists}`. This is the standard `:telemetry` behavior — Crosswake does not add a custom guard. Check the return value and decide whether to treat it as an error in your startup logic.
**Companion module not loaded.** `events/0` probes companions via `function_exported?/3`. If a companion module is not yet loaded when `events/0` is called, its events are excluded silently. Call `events/0` after companions are started in your supervision tree.
**Handler raises.** If an attached telemetry handler raises an exception, `:telemetry` catches it and detaches the handler. The `[crosswake]` default logger handles this per standard `:telemetry` semantics.
## Security and PII
Crosswake telemetry is PII-free by construction.
**Forbidden metadata keys.** All metadata passes through a forbidden-key denylist before emission or logging. The denylist is the union of `forbidden_metadata_keys/0` from all subsystem telemetry modules:
- `Crosswake.Threadline.Telemetry.forbidden_metadata_keys/0` — 20 keys including `access_token`, `actor_id`, `actor_ref`, `authorization_code`, `device_id`, `email`, `id_token`, `ip`, `refresh_token`, `session_ref`, and others.
- `Crosswake.Companions.Sigra.Telemetry.forbidden_metadata_keys/0` — additional keys from the Sigra subsystem.
- `Crosswake.Companions.Chimeway.Telemetry.forbidden_metadata_keys/0` — additional keys from the Chimeway subsystem.
Forbidden keys are silently dropped before metadata reaches any telemetry handler. They are never logged, even with the default logger attached.
**High-cardinality values.** A `safe_value?/1` guard in each subsystem telemetry module rejects metadata values longer than 128 characters. This prevents high-cardinality strings (e.g., full request bodies) from leaking into telemetry metadata.
**Host responsibility.** Companions and host code that add metadata to events must not include PII. The denylist covers known PII field names, but domain-specific PII with non-standard field names must be excluded by the host.
Do not add identity tokens, user emails, IP addresses, or other PII to telemetry metadata — even in development. Telemetry events are forwarded to external systems (APM, log aggregators, dashboards) where PII retention policies apply.
## Testing
Use `:telemetry_test.attach_event_handlers/2` in host test suites to assert that expected events fire:
```elixir
defmodule MyApp.TelemetryTest do
use ExUnit.Case, async: false
test "companion route gate emits telemetry" do
event_names = [
[:crosswake, :companion, :route_gate, :start],
[:crosswake, :companion, :route_gate, :stop]
]
ref = :telemetry_test.attach_event_handlers(self(), event_names)
on_exit(fn -> :telemetry.detach(ref) end)
# Drive the code path that triggers RouteGate evaluation
# ...
assert_received {[:crosswake, :companion, :route_gate, :start], ^ref, measurements, _metadata}
assert Map.has_key?(measurements, :system_time)
assert_received {[:crosswake, :companion, :route_gate, :stop], ^ref, measurements, _metadata}
assert Map.has_key?(measurements, :duration)
end
end
```
`attach_event_handlers/2` returns a reference used for `^ref` pattern matching, ensuring received messages originated from this handler. Always detach in `on_exit` to avoid handler leakage between tests.
Use `async: false` for any test that modifies `Application.get_env(:crosswake, :companions, ...)` — the companions config key is shared globally.
To derive the full event list from the catalog rather than hardcoding it:
```elixir
active_event_names =
Crosswake.Telemetry.events()
|> Enum.filter(fn e -> e.tier == :active end)
|> Enum.flat_map(fn %{event: prefix} ->
[prefix ++ [:start], prefix ++ [:stop], prefix ++ [:exception]]
end)
ref = :telemetry_test.attach_event_handlers(self(), active_event_names)
```
## Related
- [Companion Contract](companion_contract.md) — the behaviour contract for companions, including the optional `telemetry_events/0` callback.
- [Threadline](threadline.md) — the HTTP correlation thread, PII-free metadata, and the ledger schema.