HOLONOGRAPH / The Guide · Sidecar / OTel integration v0.26.0 · client 0.3.0 all chapters

Chapter 08

Sidecar / OTel integration

Reflects Holonograph v0.26.0 · @holonograph/client 0.3.0 · ~9 min read

The lens sees every call that goes through it. Real systems have work that doesn't go through it: a job on a queue, a background worker, a webhook fanning out to three services. This chapter is about carrying the two things the lens needs (run mode, correlation id) across those boundaries via OpenTelemetry — and, when the call path gets hot enough that synchronous delivery starts to matter, how to export evaluation events as OTLP spans with counted-loss delivery-completeness accounting.

What it is

Holonograph composes on top of OpenTelemetry rather than replacing it. Two pieces of the OTel spec do the work:

  • Baggage carries key-value context alongside a trace. Holonograph reads and writes two Baggage keys (holonograph.runmode, holonograph.correlation-id) so the run mode and correlation id survive async boundaries automatically.
  • Auto-instrumentation covers your framework's normal request/response cycle, so HTTP calls, queue publishes, and pub-sub messages carry the OTel context without you writing propagation code by hand.

Where auto-instrumentation doesn't reach, there's a manual fallback: encode the same values as message attributes on the payload and reconstruct them on the receiving side.

Why it matters

The four-source attribution downstream only works if the events on both sides of an async hop can be tied back to the same call. If the queue drops your run mode, the worker's call defaults to whatever the worker's client was constructed with, often the wrong thing. Baggage keeps that from happening quietly.

The rule of thumb If work happens outside the request that made it, propagate. If work happens inside the same request (same process, same call stack), the SDK's correlationId is enough. You don't need Baggage.

How to use it

Enable OTel in your service

Use any OTel SDK for your language. Enable auto-instrumentation for the transports you use (HTTP, gRPC, your queue library, your pub-sub client). Nothing Holonograph-specific here; the standard OTel setup is enough.

Set Baggage on the originating side

At the top of the request that started the work, put the run mode and correlation id into Baggage:

import { propagation, context } from "@opentelemetry/api";

const baggage = propagation.getBaggage(context.active())
  ?? propagation.createBaggage();

const withHolonograph = baggage
  .setEntry("holonograph.runmode",       { value: "production" })
  .setEntry("holonograph.correlation-id",{ value: correlationId });

const ctx = propagation.setBaggage(context.active(), withHolonograph);

// run the rest of the request under `ctx`
context.with(ctx, async () => {
  await enqueueBillingReview({ userId });
});

Auto-instrumentation on the queue publish serializes Baggage into the message; no message-body changes required.

Read Baggage on the receiving side

The worker or subscriber picks up the Baggage after auto-instrumentation extracts it. Use it to construct the HolonographClient:

import { propagation, context } from "@opentelemetry/api";

async function handleJob(job) {
  const baggage = propagation.getBaggage(context.active());
  const runMode = baggage?.getEntry("holonograph.runmode")?.value ?? "local_dev";
  const correlationId = baggage?.getEntry("holonograph.correlation-id")?.value;

  const client = new HolonographClient({
    endpoint: "http://127.0.0.1:8080",
    runMode,
    lensVersion: "billing-lens/v14",
    substrate: { /* ... */ }
  });

  const handle = await client.messages.create({
    surfaceId: "review-flagged-charge",
    messages: [ /* ... */ ],
    correlationId
  });
  // ...
}

The call in the worker now shares a correlationId with the request that produced the job, and it inherits the originator's run mode, so a test request that enqueued a job doesn't turn into a production call on the worker.

Message-attribute fallback

For transports where Baggage propagation isn't automatic (some managed queues, some pub-sub platforms), encode the two values as explicit message attributes on the payload, and reconstruct them on the receiving side before setting Baggage manually.

await queue.publish({
  body: { userId },
  attributes: {
    "holonograph.runmode": "production",
    "holonograph.correlation-id": correlationId
  }
});

On the receiver, read the attributes and set them into Baggage the same way you would in a fully-instrumented setup. The rest of your code doesn't need to know which path they took.

Reading the output

Nothing changes at read time. Events written from the worker come back through GET /lens/events with the same correlationId as the originating request, and their run_mode column matches the originator's. That's what makes them queryable as one evaluation across the async boundary.

Bounded emit buffer and OTLP export

Baggage carries the run mode and correlation id across async hops. Delivery of the evaluation event itself is a separate concern. The @holonograph/client 0.3.0 release adds a bounded emit buffer with three drain modes — direct HTTP, OpenTelemetry OTLP spans, or both — so operators can move evaluation events off the request path when synchronous POST /lens/events is too expensive to await.

The buffer is bounded, drops the oldest events when full, and counts every drop. Delivery completeness is a first-class read: coverage is a floor, not an assumption. See the SDK reference for the full emit option shape; this section is about the wire.

The four delivery modes

ModeDrain channelCaller latency
syncPOST /lens/events, awaitedOne HTTP round-trip per reportOutcome.
bufferedPOST /lens/events, background drainEnqueue-only; caller resolves immediately.
otlpOTLP/HTTP traces endpointEnqueue-only.
dualBoth, in parallel, per-channel accountingEnqueue-only.

sync is the default. The three buffered modes share the same ring buffer, the same accounting window, and the same drop semantics; only the drain differs.

What lands on the OTLP wire

Under otlp and dual, each evaluation becomes one span. Its dimensions become span events. Every attribute is namespaced under holonograph.*, so nothing collides with the rest of your OTel signal.

SpanName
Evaluationholonograph.evaluation
Session liveness sentinelholonograph.session.sentinel
Per-dimension span eventholonograph.dimension

Attributes on the evaluation span

AttributeNotes
holonograph.event.idThe event's canonical id. Under dual, this is the join key back to the HTTP payload.
holonograph.correlation_idBinds calls into one evaluation across async hops.
holonograph.surface.idThe surface this evaluation belongs to.
holonograph.surface.fixture_idPresent when the call satisfied a fixture.
holonograph.surface.passedOverall pass/fail after all dimensions.
holonograph.surface.cohort_tagsCohort tags attached at reportOutcome.
holonograph.lens.versionWhich lens version graded this event.
holonograph.light_source.idCanonical light-source identifier.
holonograph.run_modeWhich run mode the call was made under.
holonograph.provenanceOrigin category of the run.
holonograph.light.latency_msModel call latency.
holonograph.light.tokens.input, holonograph.light.tokens.outputToken counts from the model call.
holonograph.light.okWhether the model call itself succeeded.
holonograph.raw_response.locationWhere the bulk payload lives: store or http-channel. Under dual, spans are the index; the body lives in the HTTP channel.
holonograph.emission.streamStream identifier for this client instance.
holonograph.emission.seqMonotonic ordinal within the stream. Gaps signal drops; consumers detect all loss (including crash loss) from the ordinal alone.
holonograph.emission.finaltrue on graceful shutdown(). Distinguishes clean stop from connection death.
holonograph.substrate.*Substrate columns, namespaced under this prefix.

Per-dimension span events

Each graded dimension is a span event named holonograph.dimension with:

AttributeNotes
holonograph.dimension.idMust match a dimension on the surface's contract.
holonograph.dimension.passedPass/fail for this dimension.
holonograph.dimension.scoreOptional numeric score, when the dimension is scored.

Session sentinels: gap detection

Every emit.sentinelIntervalMs (default 30s), the client emits a holonograph.session.sentinel span on span-carrying channels. Sentinels carry emission.stream and emission.seq just like evaluation spans. Their purpose is gap detection: a consumer that sees an ordinal jump knows an event was dropped, even if the client crashed before it could tell anyone. The emission.final attribute goes true on the last event emitted by a graceful shutdown(), so a consumer can distinguish "the client stopped cleanly" from "the connection went dead."

Delivery completeness: the read

A bounded buffer with drop-on-overflow only produces trustworthy analysis if the drops are visible. The client publishes an accounting window every emit.windowMs (default 10s), and exposes it two ways:

  • Pull: client.completenessWindow() returns the current window snapshot.
  • Push: emit.onWindow(window) fires once per completed window.

The window carries offered / dropped / delivered / failed counters, a per-channel breakdown (so http loss and otlp loss are distinguishable), a completenessLowerBound ratio, and a biasFlag that goes true whenever drops occurred. Downstream analyses read biasFlag and treat the affected interval as potentially non-random. See SDK reference § Delivery modes for the full CompletenessWindow shape.

Extreme sustained load

The buffer is designed for occasional overflow, not sustained overrun. When the drop rate exceeds emit.extremeSustainedLoad.dropRateThreshold (default 1%) for longer than emit.extremeSustainedLoad.sustainedForMs (default 60s), the client fires onExtremeSustainedLoad with a summary event. Route it to your alerting; that is the signal to raise the bufferSize, add drain workers, or step down evaluation coverage rather than let the sample bias grow silently.

Graceful shutdown

Under buffered modes, call client.shutdown() before the process exits. It flushes any remaining buffered events, stops the drain, and marks the last-emitted span as emission.final = true so downstream consumers know the stream ended cleanly. No-op under sync.

process.on("SIGTERM", async () => {
  await client.shutdown();
  process.exit(0);
});
The guarantee Buffered delivery is at-most-once. No reordering, no retries, no hidden growth. Drop-on-overflow is the only bound; drops are counted, per-channel, and surfaced as biasFlag. What leaves the buffer either lands or is failed — nothing gets duplicated to make up for loss.

Reference

Baggage keys

KeyValue
holonograph.runmodeOne of the canonical five run modes (see Run modes).
holonograph.correlation-idThe correlation id binding calls into one evaluation.

Message-attribute keys

Same keys as above, used verbatim as message attributes when Baggage propagation isn't available on the transport.