Skip to content

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).



The absent case is the important one. With no export: block at all:

  • no metrics recorder is installed, /metrics is 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.


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 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 completions

They 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.

StreamWhat it is
metricsAggregate 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.
logsThe per-request operational record.
tracesThe per-request span tree.
costsPer-request tokens and spend.
decisionsPer-request routing provenance: candidates, exclusions and their reasons, the selection, failovers, hook verdicts. The stream that answers “why did this go there”.
eventsEngine lifecycle, hash-chained: admin mutations, config applies, plugin loads and refusals, boot, shutdown. The only non-aggregate stream that is not per-request.
identityPer-request who. The pseudonymization switch: a sink without it gets the same records with nobody’s name attached.
promptsRequest content. Highest sensitivity.
completionsResponse 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.

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:

StreamProducer in this release
metricsFull. The recorder and its emit sites, rendered by the prometheus module.
logsPartial. The request-finish path builds ts, ingress_protocol, pool, outcome, latency_ms. That is the whole line today.
tracesFull. The OpenTelemetry span pipeline, exported by otlp. Spans carry pool, ingress, op, lane, provider, and model.
eventsPartial. 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:

ModuleCarries
prometheusmetrics
otlptraces
request-log-webhook, request-log-filelogs

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.

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 events stream is the hash-chained one, and if something has to be provably recorded, that is where the chain proves it was not altered.

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.

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.

HooksExporters
Timingreal time, mid-requestafter the fact (delayed, by milliseconds or seconds)
Powerpreventative and modifying: can block, reject, restrict, rewritenever preventative; observe and emit only
Purposechange what happensrecord what happened
Scopebound to pools, fire at declared phasesengine-wide
Seesrequests that reach themeverything the engine knows, including events that are not requests

A few concrete calls:

What you wantWhich kindWhy
Prompt compressionHookmust happen before the request leaves
PII redactionHookthe entire point is that the PII does not go
Alert when someone sends credentials to a modelHookan 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 didExporterafter the fact, must be complete, must never block a request
Dashboards, metrics, tracesExporterobservation, 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 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: 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: prometheus with streams: [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 those
records yet, so subscribing would deliver nothing while reporting success. It arrives in a
later 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:

  • metrics has no per-record fields. Its unit is the documented metric catalog, families rather than records, so fields: has nothing to apply to and saying otherwise would be a filter that silently matches nothing.
  • traces has no per-record fields core can project either. Spans are emitted by the tracing layer, not built as records by the engine.
  • logs is refused for a sharper reason. Every stream has pinned fields that fields: may never omit, because they are what make records joinable: logs pins correlation_id. That producer has not landed yet, so a fields: list on logs would 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: 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.

Each of these is a boot error at busbar --validate and at startup, never a config that loads and quietly does nothing:

You wroteWhat happens
streams: [costs] (or decisions, identity, prompts, completions)Refused: no producer in this release, named as such
streams: [events] on a built-in moduleRefused: no built-in module carries events
streams: [logs] on module: prometheusRefused: 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 tracesRefused: see above. Omit fields:
fields: []Refused: an exhaustive override of nothing is a record with no fields
durable: trueRefused: 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.


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-log and req-siem are 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.


ModuleStreamShapeRequired settings
prometheusMetricsPULL: serves GET /metricsbuffer_seconds
request-log-webhookLogsPUSH: POSTs one line per requesturl
request-log-fileLogsPUSH: appends one JSONL line per requestpath
otlpTracesPUSH: OTLP/HTTP span exporturl

Every one of them is compiled into the core binary. You do not download an exporter, you name one.

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
SettingRequiredDefaultWhat it does
buffer_secondsyesRetention 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_limitno2000Cap 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.

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
SettingRequiredDefaultWhat it does
urlyesThe webhook target. https://-only and SSRF-guarded, so a sink cannot be pointed at cloud metadata or a private host.
auth_headernoAn 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_deliveriesno64Max 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_secsno2Per-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.

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
SettingRequiredDefaultWhat it does
pathyesThe JSONL file each request-log line is appended to.
rotate_mbnoSize 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"
SettingRequiredDefaultWhat it does
urlyesThe 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.


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.

Retired keyWhere it went
metrics.buffer_secondsexport.<name>.settings.buffer_seconds on a module: prometheus instance
metrics.key_gauge_limitexport.<name>.settings.key_gauge_limit on a module: prometheus instance
observability.request_log_webhook_urlexport.<name>.settings.url on a module: request-log-webhook instance
observability.max_inflight_webhook_deliveriesexport.<name>.settings.max_inflight_deliveries on that instance
observability.webhook_delivery_timeout_secsexport.<name>.settings.delivery_timeout_secs on that instance
observability.otlp_urlexport.<name>.settings.url on a module: otlp instance
observability.otlp_endpoint (1.4.x spelling)the same: module: otlp, settings.url
observability.emit_server_timingadvanced.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.

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: true

The 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.

You do not have to move these by hand:

Terminal window
busbar --migrate-config config.yaml

The 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.


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.