Export
Busbar has one place telemetry leaves the process: the top-level export: block. It is a named-definition map (the same shape hooks:, identity-providers:, and store: use) where the map key is the exporter instance you are naming and module: says which exporter backs it.
export: metrics: { module: prometheus, settings: { buffer_seconds: 60 } } req-log: { module: request-log-webhook, settings: { url: "https://logs.example.com/busbar" } } req-siem: { module: request-log-webhook, settings: { url: "https://siem.internal/ingest" } } traces: { module: otlp, settings: { url: "http://localhost:4318/v1/traces" } }This replaced live configuration in 1.5.3. If you are carrying a observability: block or a top-level metrics: block, jump straight to Migrating from observability: and metrics:. Those keys are hard boot errors now, not warnings.
Cross-references: Observability (what each metric and endpoint means) · Configuration (full field reference) · Config at a glance (the whole canonical config on one page) · Plugin Store (every exporter as a card).
Table of contents
Section titled “Table of contents”- Nothing is emitted until you name it
- The data boundary
- The projection:
streams:andfields: - Why a named map
- The exporter modules
- Delivery never touches the request path
- Migrating from
observability:andmetrics: - What ships today
Nothing is emitted until you name it
Section titled “Nothing is emitted until you name it”The absent case is the important one. With no export: block at all:
- no metrics recorder is installed,
/metricsis not mounted, and every emit site inside Busbar is a genuine no-op, not a counter you pay to maintain and never read; - no tracer is installed and no spans are produced;
- the request-log payload is never even built: the request-finish path skips the JSON construction entirely when no log sink is configured.
Telemetry is opt-in by construction, not by a boolean you can forget to set to false. This is the same posture the plugin loader takes: off until you ask.
The data boundary
Section titled “The data boundary”An export sink is a place telemetry leaves your network, and it is routinely a third-party collector you do not run. So the first question to ask of it is: what, exactly, can it see?
This section answers that for what ships today. It is a description of the current streams, not a promise about every future release; check it against the release you are running.
Exporters carry operational metadata. Hooks carry payload.
That is the split today. The line it draws: does this field reveal what a user asked, or who they are? If it does, it is not in a telemetry stream.
The streams, and what each carries
Section titled “The streams, and what each carries”The export contract carries a frozen vocabulary of streams. These are the words, and they are the only words:
metrics logs traces costs decisions events identity prompts completionsThey are listed in ascending sensitivity, and each is a data type, not a use case. A sink subscribes to the ones it wants and is routed only those.
| Stream | What it is |
|---|---|
metrics | Aggregate counters, gauges, and histograms: request counts, durations, and the per-key and per-budget-group token and spend gauges (busbar_key_spend_cents, busbar_key_tokens_total, busbar_bucket_tokens, and the rest of the metrics table). No individual attribution. Lowest sensitivity. |
logs | The per-request operational record. |
traces | The per-request span tree. |
costs | Per-request tokens and spend. |
decisions | Per-request routing provenance: candidates, exclusions and their reasons, the selection, failovers, hook verdicts. The stream that answers “why did this go there”. |
events | Engine lifecycle, hash-chained: admin mutations, config applies, plugin loads and refusals, boot, shutdown. The only non-aggregate stream that is not per-request. |
identity | Per-request who. The pseudonymization switch: a sink without it gets the same records with nobody’s name attached. |
prompts | Request content. Highest sensitivity. |
completions | Response content. Highest sensitivity. |
There is no audit stream, deliberately. Auditing was a use case masquerading as a data type. An auditor is a sink whose projection names the streams an audit actually needs (streams: [logs, identity, decisions, events, costs]), which is strictly more expressive than one opaque firehose, and lets two auditors with different mandates receive different things. Writing audit in a config is a named boot error that says exactly this.
Which streams this release produces
Section titled “Which streams this release produces”Naming a stream is not the same as receiving one. The streams this release has a real producer for are metrics, logs, traces, and events:
| Stream | Producer in this release |
|---|---|
metrics | Full. The recorder and its emit sites, rendered by the prometheus module. |
logs | Partial. The request-finish path builds ts, ingress_protocol, pool, outcome, latency_ms. That is the whole line today. |
traces | Full. The OpenTelemetry span pipeline, exported by otlp. Spans carry pool, ingress, op, lane, provider, and model. |
events | Partial. The hash-chained admin records: seq, ts, prev_hash, kind, actor, resource, outcome. Admin mutations only; config applies, plugin loads and refusals, boot and shutdown are a later release. |
costs, decisions, identity, prompts, and completions have no producer yet. Subscribing to one is not a quiet no-op that fills your SIEM with empty batches: it is a boot error that names the stream and says it arrives in a later release. Busbar would rather refuse the config than validate it and deliver nothing while reporting success.
And which module carries which:
| Module | Carries |
|---|---|
prometheus | metrics |
otlp | traces |
request-log-webhook, request-log-file | logs |
Nothing built in carries events. The stream is produced, and there is no module to route it out of, so today the hash-chained record is read over the admin API. Pointing a built-in module at a stream it cannot carry (module: request-log-webhook with streams: [events]) is likewise a boot error, not a subscription that quietly receives nothing.
What no stream carries today
Section titled “What no stream carries today”Prompt text. Completion text. Message arrays. Request and response bodies. Headers. Keys. Tool arguments.
Not redacted, absent. prompts and completions are named in the frozen vocabulary precisely so that content, when it arrives, is something an operator grants by name rather than something that appears inside a stream a sink already subscribes to. Neither has a producer in this release, so no configuration reaches request or response bodies through export: at all. A span is where request data most easily leaks by accident, so every hot-path span in the engine is instrumented with skip_all, and no function argument is recorded merely by being an argument. Every field a trace carries is one somebody named on purpose.
Two properties of telemetry make content a poor fit for these streams regardless of anything else:
- Cardinality. Prompt text as a metric label or a span attribute is unbounded-cardinality data, and it destroys the backend receiving it. This is the same reason metric labels come from your configured pool and lane names and fixed enumerations rather than from raw model strings off a client request.
- Delivery is best-effort and lossy by design. Every PUSH sink sheds beyond its own in-flight cap rather than queueing without bound, which is how a stalled sink is kept from starving the process. A stream that is allowed to drop lines is the wrong carrier for anything that must not be lost. The
eventsstream is the hash-chained one, and if something has to be provably recorded, that is where the chain proves it was not altered.
What that buys you
Section titled “What that buys you”You can point a telemetry sink at a third party without sending your users’ prompts there. Adding a collector is an observability decision, and today it stays one.
Prompt and completion content reaches a plugin through hooks, under an explicit per-hook grant an operator sets and can revoke: prompt: no | ro | rw, defaulting to no, immutable after registration, and requiring full admin scope to raise above no. That mechanism, its exact projection, and what a hook sees without it are documented in Access grants: what a hook is trusted to see.
When content becomes available over export, it arrives the same way: as streams: [prompts] written by an operator on one specific sink, off unless named, refusable, and visible in the config diff. Never as a widening of something a sink already subscribes to.
Hooks or exporters?
Section titled “Hooks or exporters?”The data boundary above answers what an exporter can see. This answers when you reach for the other kind instead.
Hooks can stop the request. Exporters tell you what happened.
| Hooks | Exporters | |
|---|---|---|
| Timing | real time, mid-request | after the fact (delayed, by milliseconds or seconds) |
| Power | preventative and modifying: can block, reject, restrict, rewrite | never preventative; observe and emit only |
| Purpose | change what happens | record what happened |
| Scope | bound to pools, fire at declared phases | engine-wide |
| Sees | requests that reach them | everything the engine knows, including events that are not requests |
A few concrete calls:
| What you want | Which kind | Why |
|---|---|---|
| Prompt compression | Hook | must happen before the request leaves |
| PII redaction | Hook | the entire point is that the PII does not go |
| Alert when someone sends credentials to a model | Hook | an exporter alerting 200ms later is too late: the credential has already reached the provider. Alerting without the power to stop is not a control, it is notification of a breach. |
| A record of what everyone did | Exporter | after the fact, must be complete, must never block a request |
| Dashboards, metrics, traces | Exporter | observation, never on the critical path |
An auditor written as a hook is structurally incomplete. Hooks fire at request phases and are bound to pools, so a hook-based auditor cannot see requests refused before it ran (admission shedding, auth failure at the door, budget exhaustion), admin/config changes, or any pool it is not bound to. “Show me every attempt, including the denied ones” is a question only an exporter can answer.
Same decision, laid out on the Plugin Store. The mechanics of a hook that can actually act (access grants, the tap/gate split, the reply arms) are in the hooks docs.
The projection: streams: and fields:
Section titled “The projection: streams: and fields:”The engine holds everything. Each export: instance is a projection of it, and the plugin does not choose the projection: the operator does, per instance, in config.
export: req-log: module: request-log-webhook streams: [logs] # WHAT this sink subscribes to settings: { url: "https://logs.example.com/busbar" }That is not a filter applied on the way out. Busbar builds each sink’s payload to its projection, so a field the instance was not granted is never serialized and never crosses the plugin boundary. It is the difference between “the sink is not supposed to look at that” and “there is nothing there to look at”, and it is what makes “an exporter may use everything it is given” a safe sentence.
streams:, what this sink subscribes to
Section titled “streams:, what this sink subscribes to”streams: is a list of tokens from the frozen vocabulary above.
Omit it and the instance takes the streams its own module carries, which is what every configuration written before this grammar existed already means. It never means “nothing”: a sink subscribed to nothing would validate cleanly and receive nothing forever, which is the exact shape this whole surface exists to refuse. Writing streams: [] explicitly is a boot error that says so.
Three things get you a named refusal rather than a silent nothing:
- a stream this release has no producer for (
costs,decisions,identity,prompts,completions); - a stream the instance’s module cannot carry (
module: prometheuswithstreams: [logs]); audit, which is not a stream at all, and whose error tells you which real streams an auditor wants.
The errors name the instance, the token, and the fix. From the real validator:
export.req-log.streams: `costs` HAS NO PRODUCER in this release — nothing generates thoserecords yet, so subscribing would deliver nothing while reporting success. It arrives in alater release. The streams this release produces are: metrics | logs | traces | events.Practical guidance: write streams: explicitly on every instance. It costs one line, it documents at the config what each sink is entitled to, and it turns a future stream added to a module into something you opt into rather than something you inherit.
fields:, an exhaustive override, and not usable yet
Section titled “fields:, an exhaustive override, and not usable yet”fields: narrows a subscription below its stream’s default field set. It is real and it is enforced, and in this release you cannot reach it with the built-in modules. Set streams:, omit fields:.
It is out of reach for the same rule that holds everywhere else on this page:
metricshas no per-record fields. Its unit is the documented metric catalog, families rather than records, sofields:has nothing to apply to and saying otherwise would be a filter that silently matches nothing.traceshas no per-record fields core can project either. Spans are emitted by the tracing layer, not built as records by the engine.logsis refused for a sharper reason. Every stream has pinned fields thatfields:may never omit, because they are what make records joinable:logspinscorrelation_id. That producer has not landed yet, so afields:list onlogswould have to demand a field nothing can supply. Busbar refuses once, with both halves of the reason, rather than enforcing the pin against a field that cannot exist.
So fields: becomes usable when those producers land, and until then the config that works is the one without it. Omitting fields: gives each subscribed stream its produced default set, which is the whole of what this release can fill in.
When it does become usable: fields: OVERRIDES, it never adds. This is deliberately the opposite of hooks:, where lists combine additively. Hooks compose behaviour, so additive is right there. Projections bound disclosure, so override is right here: if fields: were additive, a later Busbar release that added a field to a stream’s defaults would silently widen what every sink configured a year earlier receives. Override means your list is the last word.
durable:, reserved and refused
Section titled “durable:, reserved and refused”durable: true would ask core to spool a sink’s records so the sink can drain them late and retry, buying a completeness guarantee that fire-and-forget delivery does not offer. The key is part of the frozen surface. The spool behind it is not in this release, so durable: true is a loud error rather than a promise nothing keeps. Omit it, or set it to false.
Every refusal in one table
Section titled “Every refusal in one table”Each of these is a boot error at busbar --validate and at startup, never a config that loads and quietly does nothing:
| You wrote | What happens |
|---|---|
streams: [costs] (or decisions, identity, prompts, completions) | Refused: no producer in this release, named as such |
streams: [events] on a built-in module | Refused: no built-in module carries events |
streams: [logs] on module: prometheus | Refused: that module carries metrics |
streams: [audit] | Refused: audit is a use case, not a data type; the error suggests the real streams |
streams: [] | Refused: a sink that subscribes to nothing receives nothing |
streams: [logs, logs] | Refused: listed twice |
fields: [...] on logs, metrics, or traces | Refused: see above. Omit fields: |
fields: [] | Refused: an exhaustive override of nothing is a record with no fields |
durable: true | Refused: reserved, the spool is a later release |
An unknown module: | Refused, naming the built-in modules |
Validate before you deploy: busbar --validate runs the exact boot pipeline with no side effects, so a clean validate is a clean boot.
Why a named map
Section titled “Why a named map”The retired shape was type-keyed: one fixed field per exporter type. That could not express a configuration operators actually run: two request-log webhooks, one to the application log pipeline and one to a SIEM, each with its own credential and its own timeout.
The 1.5.3 shape is a named map, so:
- the name is the instance.
req-logandreq-siemare two names you chose; they carry no meaning to Busbar beyond identifying the instance in errors and diagnostics. module:is which exporter backs it. The same module can back as many instances as you name.settings:is that module’s own bag, typed per module. A typo inside it is a boot error; the bag is opaque only to the outer layer.
Two modules are the exception, and deliberately so. prometheus owns the single well-known /metrics route, and otlp installs the one process-global tracer subscriber. A second instance of either could do nothing except silently lose, so it is a loud boot error naming the instance that already claimed it.
An unknown module: is likewise a boot error that names the built-in modules, never a silently ignored sink.
The exporter modules
Section titled “The exporter modules”| Module | Stream | Shape | Required settings |
|---|---|---|---|
prometheus | Metrics | PULL: serves GET /metrics | buffer_seconds |
request-log-webhook | Logs | PUSH: POSTs one line per request | url |
request-log-file | Logs | PUSH: appends one JSONL line per request | path |
otlp | Traces | PUSH: OTLP/HTTP span export | url |
Every one of them is compiled into the core binary. You do not download an exporter, you name one.
prometheus
Section titled “prometheus”Installs the metrics recorder and mounts GET /metrics in the Prometheus exposition format.
export: metrics: module: prometheus streams: [metrics] # the module's own stream; explicit is better than inherited settings: buffer_seconds: 60 # REQUIRED: rolling quantile-summary window, in seconds key_gauge_limit: 2000 # optional, default 2000| Setting | Required | Default | What it does |
|---|---|---|---|
buffer_seconds | yes | — | Retention window in seconds for the rolling quantile summary. Required on purpose: turning metrics on is a deliberate choice and a memory cost you should name, exactly as the retired metrics.buffer_seconds was. |
key_gauge_limit | no | 2000 | Cap on per-key gauges rendered at scrape time, so a large key population cannot grow the scrape payload without bound. |
Label cardinality is bounded by design: labels come from your configured pool and lane names and from fixed enumerations, never from raw model strings off a client request. A hostile caller cannot blow up your time-series database.
/metrics is not auth-exempt (telemetry is itself a fingerprinting surface), but it is only as protected as your configured auth.chain. With an empty chain it admits unconditionally, so restrict it at the network layer if you run one. See Observability.
request-log-webhook
Section titled “request-log-webhook”POSTs one JSON line per finished request to an HTTPS endpoint you run.
export: req-log: module: request-log-webhook streams: [logs] settings: url: "https://logs.example.com/busbar" req-siem: module: request-log-webhook streams: [logs] settings: url: "https://siem.internal/ingest" delivery_timeout_secs: 2 max_inflight_deliveries: 128| Setting | Required | Default | What it does |
|---|---|---|---|
url | yes | — | The webhook target. https://-only and SSRF-guarded, so a sink cannot be pointed at cloud metadata or a private host. |
auth_header | no | — | An optional { name, value } header applied to every delivery from this instance. The value rides the config’s ${VAR} env interpolation, so a token is never stored literally. |
max_inflight_deliveries | no | 64 | Max concurrent deliveries for this instance. Beyond it, lines are shed and counted rather than queued; a stalled collector must not grow memory without bound. |
delivery_timeout_secs | no | 2 | Per-delivery timeout, applied per instance, so a slow SIEM and a fast local collector can carry different deadlines. |
This module also absorbed the retired generic-webhook exporter. Its only extra was an auth header, which is now just a setting here, and its other reason to exist (a second webhook target) is what naming a second instance gives you.
request-log-file
Section titled “request-log-file”Appends the same per-request JSON line to a file as JSONL, one object per line.
export: req-log-file: module: request-log-file streams: [logs] settings: path: /var/log/busbar/requests.jsonl rotate_mb: 512| Setting | Required | Default | What it does |
|---|---|---|---|
path | yes | — | The JSONL file each request-log line is appended to. |
rotate_mb | no | — | Size in MiB at which the file is rotated, best-effort. Absent means Busbar never rotates it; hand the file to logrotate if that is what you already run. |
Appends are serialized per instance, so two concurrent requests never interleave a line. Name two instances (a local tail file and an audit mount, say) and each carries its own path, its own rotation setting, and its own in-flight cap: a stalled audit mount cannot shed the local file’s lines.
Installs an OpenTelemetry tracer and exports spans over OTLP/HTTP.
export: traces: module: otlp streams: [traces] settings: url: "http://localhost:4318/v1/traces"| Setting | Required | Default | What it does |
|---|---|---|---|
url | yes | — | The OTLP/HTTP traces endpoint. Loopback is permitted for a local collector; a remote endpoint must be https:// and is SSRF-guarded (it cannot resolve to metadata or private hosts). |
Credentials are handled cleanly: put them in the URL userinfo and Busbar moves them into an Authorization header and strips them from the URL before anything is logged. Traces flush on graceful shutdown.
Delivery never touches the request path
Section titled “Delivery never touches the request path”Every PUSH sink is fire-and-forget. A request’s outcome, latency, and body are decided before any exporter sees the line, and no error from a sink can change what a caller gets back: telemetry must not affect serving.
The bounding is explicit rather than implicit. Each named instance owns its own admission gate: when a collector stalls or a disk fills, deliveries are shed and counted, never queued without limit. That per-instance ownership is why one stalled sink cannot starve another.
Migrating from observability: and metrics:
Section titled “Migrating from observability: and metrics:”This is the user-facing part of 1.5.3. The top-level observability: block is deleted and the top-level metrics: block is retired; all telemetry egress moved to export:. A config carrying either fails boot, by design (a silently dropped telemetry sink is exactly the failure mode that makes an outage invisible), and the error names the old key, its new home, and the migrator.
The key-by-key table
Section titled “The key-by-key table”| Retired key | Where it went |
|---|---|
metrics.buffer_seconds | export.<name>.settings.buffer_seconds on a module: prometheus instance |
metrics.key_gauge_limit | export.<name>.settings.key_gauge_limit on a module: prometheus instance |
observability.request_log_webhook_url | export.<name>.settings.url on a module: request-log-webhook instance |
observability.max_inflight_webhook_deliveries | export.<name>.settings.max_inflight_deliveries on that instance |
observability.webhook_delivery_timeout_secs | export.<name>.settings.delivery_timeout_secs on that instance |
observability.otlp_url | export.<name>.settings.url on a module: otlp instance |
observability.otlp_endpoint (1.4.x spelling) | the same: module: otlp, settings.url |
observability.emit_server_timing | advanced.response_headers.server_timing |
Two more retirements land in the same release, in case your config carries them: the type-keyed export: block (export.prometheus: { settings: … }) becomes a named instance (export.metrics: { module: prometheus, settings: … }), and the generic-webhook exporter folds into request-log-webhook.
Before and after
Section titled “Before and after”The 1.4.x shape:
metrics: buffer_seconds: 60 key_gauge_limit: 2000
observability: request_log_webhook_url: "https://logs.example.com/busbar" webhook_delivery_timeout_secs: 2 max_inflight_webhook_deliveries: 128 otlp_url: "http://localhost:4318/v1/traces" emit_server_timing: trueThe 1.5.3 shape, the same sinks (prometheus, otlp, webhook, file), each now a named instance:
export: metrics: module: prometheus settings: { buffer_seconds: 60, key_gauge_limit: 2000 } req-log: module: request-log-webhook settings: url: "https://logs.example.com/busbar" delivery_timeout_secs: 2 max_inflight_deliveries: 128 traces: module: otlp settings: { url: "http://localhost:4318/v1/traces" }
advanced: response_headers: { server_timing: true }Note what the new shape buys you at no cost: adding a second request-log webhook is now one more named instance rather than an impossibility.
Let the migrator do it
Section titled “Let the migrator do it”You do not have to move these by hand:
busbar --migrate-config config.yamlThe rewrite is mechanical, not advisory (the sink is preserved, not printed as a TODO), and it is idempotent, so running it twice is a no-op. It prints a ledger of every change it made, including deletions, so nothing moves invisibly. Check the result with busbar --validate config.yaml, which runs the exact path a real boot uses.
observability.otlp_url: null was how the shipped example spelled “tracing off”. Folding a null URL into an instance would turn an off sink into a boot error (settings.url is required), so the migrator drops it instead of creating a broken instance.
What ships today
Section titled “What ships today”The exporters described on this page are compiled into the core binary. You turn one on by naming it under export:; there is no tarball to fetch, no signature to allowlist, and no plugins.enabled to flip for telemetry.
export is nonetheless a real plugin kind, alongside store, secret, auth, and hook. It exports the same six neutral C symbols as every other kind at the same transport version, and its payload schema adds exactly two operations: streams, asked once at load so the engine knows what this sink carries, and deliver, which hands over one already-serialized batch. The loader verifies, trusts, and loads a kind: export tarball today on the same pipeline as the others.
What has not shipped is the config seam: export.<name>.module: accepts the built-in modules only, and any other name is a boot error that lists them. So today the exporters available to you are exactly the modules above. When that changes it changes without touching your config, because export: { name: { module: … } } reads identically whether a module is compiled in or loaded from disk, and streams: already says what that sink is entitled to either way.