# Gateway · OpenTelemetry integration (/docs/gateway/otel)



OpenTelemetry support in `@pleach/gateway` is **partial today**. The
cost-event substrate ships an OTel adapter that records two counters
per call; a dedicated tracer that emits an `llm.invocation` span per
call is roadmap.

This page documents what's actually shipped — be explicit about the
gap so consumers wire their own span context until the tracer rung
lands.

## What ships today: OTel cost counters [#what-ships-today-otel-cost-counters]

The OTel surface is delivered via the cost-event adapter at
`@pleach/gateway/cost`. It emits two counters per `CostEvent`:

```
pleach_gateway_cost_usd        # Counter, double, USD
pleach_gateway_tokens_total    # Counter, long,   {token}
```

`pleach_gateway_tokens_total` carries an extra dimension
`token.kind = "prompt" | "completion"` so the prompt-vs-completion
breakdown is preserved as a separate dimension rather than collapsed
into a single sum.

### Wiring [#wiring]

`routeChatCompletion`'s output carries `{ providerInvoked, modelInvoked,
content, usage, costUsd, routingDecisionId }` — not `tenantId`,
`family`, `callClass`, `byokActive`, or the full `routingDecision`.
Merge those onto the observation from the request `input` plus your
routing context, then read them in `computeCost` (see
[Cost events](/docs/gateway/cost-events#costmiddleware-factory) for
the full mapper):

```typescript
import { metrics } from "@opentelemetry/api"
import { createOtelCostEmitter, createCostMiddleware } from "@pleach/gateway/cost"
import { asTenantId } from "@pleach/gateway"
import type { RouteChatCompletionInput, RouteChatCompletionOutput } from "@pleach/gateway"

const meter = metrics.getMeter("@pleach/gateway")

const emitter = createOtelCostEmitter({ meter })

type CostObservation = RouteChatCompletionOutput & {
  tenantId: string
  family: RouteChatCompletionInput["family"]
  callClass: "synthesize" | "reasoning" | "converse" | "utility"
  byokActive: boolean
}

const cost = createCostMiddleware<CostObservation>({
  emitter,
  computeCost: (route, response) => ({
    type: "domain.gateway.cost.recorded",
    tenantId: asTenantId(response.tenantId),   // non-empty: sourced from input.tenantId
    family: response.family,
    callClass: response.callClass,
    modelInvoked: response.modelInvoked,
    costUsd: response.costUsd,
    promptTokens: response.usage.promptTokens,
    completionTokens: response.usage.completionTokens,
    byokActive: response.byokActive,
    routingDecision: {
      family: response.family,
      call_class: response.callClass,
      selected_model: response.modelInvoked,
      transport: "openrouter",
      byok_active: response.byokActive,
      upgrade_path_invoked: false,
      fallback_chain: [],
      raw_provider_cost_usd: response.costUsd,
      markup_pct: 0,
    },
    timestamp: new Date().toISOString(),
  }),
})

const output = await runtime.routeChatCompletion(input)
void cost({
  route: "chat.completion",
  response: {
    ...output,
    tenantId: input.tenantId,
    family: input.family,
    callClass: "converse",   // host routing context — not on the route output
    byokActive: false,       // host routing context — not on the route output
  },
})
```

`routeChatCompletion` is the Pack-221 slice-2 stub today: it returns
`costUsd: 0` and a stub `content` until the real transport lands.

### Attributes recorded on every measurement [#attributes-recorded-on-every-measurement]

| Attribute         | Source                           |
| ----------------- | -------------------------------- |
| `tenant.id`       | `event.tenantId`                 |
| `provider.family` | `event.family`                   |
| `call.class`      | `event.callClass`                |
| `model.invoked`   | `event.modelInvoked`             |
| `byok.active`     | `event.byokActive`               |
| `cost.event.type` | `"domain.gateway.cost.recorded"` |

`token.kind` (`"prompt"` / `"completion"`) is added to
`pleach_gateway_tokens_total` measurements only.

### The OTel SDK is an optional peer [#the-otel-sdk-is-an-optional-peer]

`createOtelCostEmitter` accepts the `meter` as a duck-typed argument
mirroring `@opentelemetry/api`'s `Meter` shape. The adapter never
imports `@opentelemetry/api` at module-eval time — the duck-typed
shape is lifted to a local `OtelMeter` interface so callers without
an `@opentelemetry/api` type ambient can still satisfy it (a stub in
tests, a custom in-house meter facade).

The factory validates only that the supplied `meter` exposes
`createCounter(name, options?)`:

```typescript
if (!opts.meter || typeof opts.meter.createCounter !== "function") {
  throw new TypeError(
    "@pleach/gateway/cost/adapters/otel: options.meter must implement createCounter(name, options?)",
  )
}
```

### Override counter names [#override-counter-names]

For deployments that prefix metrics by team or service:

```typescript
const emitter = createOtelCostEmitter({
  meter,
  costCounterName: "myco.pleach.gateway.cost.usd",
  tokensCounterName: "myco.pleach.gateway.tokens.total",
})
```

### Soft-fail semantics [#soft-fail-semantics]

Per the cost-adapter contract, `emit()` swallows internal exceptions
— a metric backend hiccup MUST NOT propagate up the gateway call:

```typescript
try {
  costCounter.add(event.costUsd, baseAttrs)
  // ... tokensCounter.add(...) ...
} catch {
  // Soft-fail per cost-adapter contract — never propagate.
}
```

## What's roadmap: dedicated tracer + `llm.invocation` spans [#whats-roadmap-dedicated-tracer--llminvocation-spans]

A full OTel tracer integration is `v1.x` work. The C7 telemetry rung
that records every gateway call as an OTel span lands once
`runtime.otel` is consumable from `@pleach/core`'s exported surface.

Until then, callers thread their own span context if they need
distributed tracing across the gateway boundary:

```typescript
import { trace, context, SpanKind } from "@opentelemetry/api"

const tracer = trace.getTracer("my-app")

await tracer.startActiveSpan(
  "llm.invocation",
  { kind: SpanKind.CLIENT, attributes: {
    "tenant.id": tenantId,
    "provider.family": family,
    "call.class": callClass,
    "model.requested": model,
  }},
  async (span) => {
    try {
      const response = await runtime.routeChatCompletion(input)
      span.setAttribute("model.invoked", response.modelInvoked)
      span.setAttribute("cost.usd", response.costUsd)
      span.setAttribute("tokens.prompt", response.usage.promptTokens)
      span.setAttribute("tokens.completion", response.usage.completionTokens)
      // byok.active is not on the route output — set it from your
      // routing context (input / BYOK resolver), not from response.
      return response
    } catch (err) {
      span.recordException(err as Error)
      throw err
    } finally {
      span.end()
    }
  },
)
```

This shim is **not** what we want long-term — span attributes are
inferred from response shape rather than emitted at the routing
decision point, cascade walks are not captured per-rung, and BYOK
hash provenance is missing. The dedicated `Tracer` SKU will own all
of this. Track the work under the gateway punch list.

## Cited source [#cited-source]

* `packages/gateway/src/cost/adapters/otel.ts` — `OtelMeter` /
  `OtelCounter` duck-typed interfaces and `createOtelCostEmitter`
  factory.
* `packages/gateway/src/cost/CostMiddleware.ts` — middleware that
  composes the OTel adapter with any route response shape.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Cost events" href="/docs/gateway/cost-events" description="The full CostEmitter contract + the other three adapters (Postgres, Datadog, Honeycomb)." />

  <Card title="OTel observability" href="/docs/otel-observability" description="The platform-wide OTel posture — telemetry boundaries, span conventions, and the `runtime.otel` facet roadmap." />

  <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." />
</Cards>
