Sinks
A sink is the consumer side of the contract whose producer side is the SDK. A sink receives EvaluationRecord values from the SDK and gets them to a destination (a file, an OTel collector, an object store, a streaming bus). Sinks are pure transport — they do NOT inspect, transform, sample, or filter records beyond what the destination's encoding requires.
This is the telemetry analogue of the manifest spec's loader-is-pure-transport invariant.
The sink contract
Every sink, regardless of language or destination, satisfies the same observable contract:
- Accept records as-is. The sink MUST accept any
EvaluationRecordvalid under the schema in evaluation-record. It MUST NOT reject records based on content. - Be non-blocking on the eval hot path. The
record(...)operation MUST complete in time bounded only by local in-memory work. I/O happens asynchronously. - Survive transient downstream failure. Network errors, rate limits, and downstream unavailability MUST NOT propagate as panics or eval-time errors. The sink's failure policy (drop, retry, buffer) is a sink-implementation choice.
- Best-effort flush on shutdown. The sink MUST attempt to deliver buffered records when the SDK is gracefully closed (
ExdClient::close().awaiton the Rust SDK; equivalent in others). - No record mutation. A sink MUST NOT modify a record's fields before delivery. Encoding (JSON → Avro, JSON → OTel attribute set) is permitted; field-value rewriting is not.
A sink MAY:
- Batch records before transmission.
- Compress its output.
- Emit telemetry about its own health (queue depth, drop count, last error) over a separate channel.
- Drop records when its buffer is full, provided the drop is observable (counted and surfaced via the SDK's diagnostic channel).
Per-SDK surfaces
The contract above is language-agnostic. Every exd SDK implementation provides a sink-attachment surface that satisfies it; the shape of that surface is idiomatic to the language.
Rust SDK
The reference Rust SDK exposes two equivalent forms:
pub trait EvaluationSink: Send + Sync + 'static {
fn record(&self, record: EvaluationRecord);
fn flush(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}
impl ExdClientBuilder {
/// Wire a structured sink implementation.
pub fn sink(self, sink: impl EvaluationSink) -> Self;
/// Convenience: wrap a closure as a sink. Closure form has no flush;
/// records are delivered synchronously to the closure on the eval thread.
pub fn on_evaluation(self, f: impl Fn(EvaluationRecord) + Send + Sync + 'static) -> Self;
}
The closure form is intended for development, tests, and lightweight integrations where the user accepts the eval-thread delivery model. The trait form is the production interface.
TypeScript SDK
@exd/client exposes equivalent surfaces idiomatic to TypeScript: a callback option on the client builder plus optional sink modules under subpath imports (@exd/client/telemetry). The set of reference sinks shipped per-language tracks the Rust set but is not required to be identical — language-specific destinations (e.g., browser console, IndexedDB) MAY appear only in the SDK where they make sense. Refresh transport (poll vs. SSE / closure-delta) does NOT change the sink contract — records are emitted identically regardless of how the manifest reached the SDK.
Multi-namespace clients (ExdClientGroup)
ExdClientGroup (Rust) and @exd/client/group (TypeScript) attach a single sink at the group level. Records produced by any member flag namespace flow through that one sink. Each record carries its own namespace field, so a sink that needs to fan out by flag namespace can do so by reading the field — the SDK does not pre-partition.
A group MAY also accept per-member sinks for cases where a single backing store does not work (e.g., per-namespace S3 buckets). When both group-level and member-level sinks are configured, both receive each record.
Reference sinks
exd-client ships the following reference sinks. Each is gated on a Cargo feature to keep the default build small.
| Sink | Feature flag | Destination | Encoding |
|---|---|---|---|
StdoutSink | sinks-stdout | Process stdout | NDJSON |
FileSink | sinks-file | Local filesystem path with rolling | NDJSON or Parquet |
OtlpSink | sinks-otlp | OTel collector (gRPC or HTTP/protobuf) | OTel feature_flag semantic convention |
ObjectStoreSink | sinks-object-store | S3, GCS, Azure Blob | Parquet, batched |
StdoutSink
Writes one NDJSON record per line to process stdout. Intended for development. No batching, no buffering.
FileSink
Writes to a local file path. Configurable parameters:
| Parameter | Default | Description |
|---|---|---|
path | — (required) | Output path. May contain strftime-style date tokens for rolling. |
format | ndjson | One of ndjson or parquet. Parquet uses Snappy compression and a row-group size of 64K records. |
roll_interval | 1h | Rotate the file at this cadence. 0 disables rotation. |
roll_max_size | 256MiB | Rotate when file size exceeds this. |
flush_interval | 1s | Maximum time between fsync calls. |
OtlpSink
Emits each evaluation as an OTel span event using the feature_flag semantic convention. When an active OTel span context exists at evaluation time, the event attaches to that span. When no active context exists, the sink falls back to emitting an OTel log record.
The OTLP sink intentionally narrows the record to the OTel-defined attribute set:
EvaluationRecord field | OTel attribute |
|---|---|
flag_key | feature_flag.key |
variant_key | feature_flag.variant |
namespace | feature_flag.set.id |
evaluation_reason | feature_flag.evaluation.reason |
sdk_name | feature_flag.provider_name |
Other fields are dropped. Customers requiring full-fidelity export MUST also configure a non-OTLP sink.
ObjectStoreSink
Batches records to local memory, then writes Parquet to an object store on a configurable cadence. Configurable parameters:
| Parameter | Default | Description |
|---|---|---|
uri | — (required) | An s3://, gs://, or az:// URI prefix. |
partition | date={YYYY-MM-DD} | Object key partition pattern. |
batch_size | 10000 | Records per Parquet file. |
flush_interval | 5min | Maximum time between writes. |
auth | environment-derived | Credentials. Mirrors loader::https::UriAuth. |
The sink respects the configured HttpBackend selector — InProcess (the default, reqwest-based) or Curl (subprocess) — mirroring the loader and the manifest client. Selection follows the same precedence rules: explicit builder override, then EXD_HTTP_BACKEND env var, then the compiled-in default.
Remote-eval clients
Some SDK shapes — notably the TypeScript @exd/client/remote module — perform no local evaluation and therefore produce no client-side records. They make a POST /api/v1/tenants/{tenant}/namespaces/{ns}/evaluate{,/all} request against exd-server, which evaluates and (subject to its own telemetry configuration) emits records with sdk_name = "exd-server".
For these clients:
- Sinks attached to the local client surface MUST NOT receive records — there are none to emit.
- Telemetry for remote-evaluated requests is governed by the server's sink configuration.
- The client MAY still ship its own non-evaluation telemetry (request latency, server errors) over the same transport stack, but those are not
EvaluationRecordinstances and are out of scope.
The opt-out controls below apply on the server side for remote eval (X-Exd-Dry-Run, namespace.telemetry_enabled). The SDK-side dry_run and explicit-sink controls have no effect on remote-eval calls.
Default behavior when no sink is configured
A Namespace or ExdClient constructed without a sink MUST evaluate flags normally and discard evaluation events. No telemetry is silently enabled. This corresponds to Rung 0 of the adoption ladder.
Multiple sinks
A client MAY be configured with multiple sinks. Each sink receives every record. Sinks operate independently — failure or backpressure in one sink MUST NOT delay or drop records destined for another.
ExdClientBuilder::new()
.sink(StdoutSink::new())
.sink(ObjectStoreSink::new("s3://bucket/exd/"))
.build()
Opt-out controls
Three opt-out controls, checked before sink delivery:
X-Exd-Dry-Run: trueHTTP header (server-side evaluate). The server MUST evaluate the request normally but emit no telemetry for it. The server MUST echo the header on the response.dry_run = trueSDK config. The SDK MUST evaluate flags normally and emit no records to any sink. A singleINFOlog line on startup confirms the mode.namespace.toml: telemetry_enabled = false. The SDK and server MUST suppress all telemetry for evaluations of any flag in this flag namespace. This is the only manifest-level switch in schema 1.0; per-flag opt-out is intentionally not provided (operators who need to suppress one flag in an otherwise-instrumented flag namespace either fragment the namespace or filter at the sink layer).
A suppressed evaluation produces no record on any sink, regardless of sink configuration.
Sampling
This reference deliberately omits a producer-side sampling primitive. Sampling at the record-emission boundary distorts experimental analysis (the SRM diagnostic and lift estimation both assume complete exposure data), and the dedup / batching at the sink layer is the correct control point for volume control.
A sink MAY apply destination-specific sampling (e.g., the OtlpSink MAY honor an OTel sampler), but this is a sink-implementation concern. The SDK MUST NOT pre-sample records before sink delivery.
The exd-server ingest off-ramp
Whether exd-server accepts evaluation records as a built-in sink destination is deferred to a future spec revision. The current contract leaves all sinking to customer-controlled infrastructure, consistent with the manifest reference's "loader is pure transport / exd-server is purely a config service" invariant.
A future revision may introduce an optional POST /api/v1/tenants/{tenant}/namespaces/{namespace}/telemetry/ingest endpoint paired with an ExdServerSink. If introduced, it MUST be opt-in at the server level and MUST NOT alter the evaluation-record schema or any other sink contract defined here.
See also
- evaluation-record — the record shape every sink consumes.
reference/cli/exd/telemetry/tail— live tail of a sink for debugging.- namespace §
telemetry_enabled— the manifest opt-out switch. - evaluation §
X-Exd-Dry-Run— the server-side opt-out header.