# BYOK observability (/docs/recipes/byok-observability)



`@pleach/observe` is the brownfield entry point to the Pleach
audit ledger. You add roughly 15 lines around your existing
LLM calls and get one typed audit row per call, written to a
backend you pick. This page walks the BYOK (bring-your-own
backend) path: no `@pleach/core` runtime, no migration, no
hosted control plane.

The row written through the SDK is a strict subset of
`@pleach/core`'s `AuditableCall` v13 record plus the
`TokenCostRecord` field set, so the audit history stays
forward-compatible if you later adopt the runtime.

## When to reach for this [#when-to-reach-for-this]

* You already run an agent loop — Vercel AI SDK, LangChain,
  the OpenAI or Anthropic SDK called directly, an in-house
  orchestrator — and rewriting it isn't on the roadmap.
* You want one auditable row per LLM call, written to
  infrastructure you already operate (Postgres, Supabase,
  your OpenTelemetry collector).
* You don't need replay determinism, family-locked routing,
  reactive channels, or checkpoint / restore. Those are
  runtime properties — `@pleach/core` carries them. The SDK
  does not, by design.

## Quickstart — Postgres [#quickstart--postgres]

Wire `@pleach/observe` against your existing `pg` pool. No
new dependency is added to the SDK side; the SDK uses
structural types only.

```ts
import { Pool } from "pg";
import { init, recordCall } from "@pleach/observe";
import { postgres } from "@pleach/observe/destinations";

const pgClient = new Pool({ connectionString: process.env.DATABASE_URL });

init({
  destination: postgres({
    pgClient,
    tableName: "audit_rows",
  }),
});

// Inside your existing agent loop, after each LLM call:
recordCall({
  turnId: "turn_001",
  providerId: "openai",
  callClass: "synthesize",
  family: "openai",
  model: "gpt-5",
  inputTokens: 850,
  outputTokens: 320,
  costUSD: 0.0091,
  startedAt: Date.now() - 900,
  completedAt: Date.now(),
});
```

The `CREATE TABLE` shape (column names, types, indexes)
ships in the package's `docs/postgres.md`. The
parameterized `INSERT` targets exactly that column set; the
SDK never alters your schema.

## Quickstart — Supabase [#quickstart--supabase]

For managed Postgres with RLS. Same row shape; the round
trip goes through PostgREST.

```ts
import { createClient } from "@supabase/supabase-js";
import { init, recordCall } from "@pleach/observe";
import { supabase } from "@pleach/observe/destinations";

const client = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_KEY!,
);

init({
  destination: supabase({ client, tableName: "audit_rows" }),
});

recordCall({
  turnId: "turn_supabase_001",
  providerId: "anthropic",
  callClass: "synthesize",
  family: "anthropic",
  model: "claude-sonnet-4-6",
  inputTokens: 1500,
  outputTokens: 600,
  costUSD: 0.0228,
  startedAt: Date.now() - 1700,
  completedAt: Date.now(),
});
```

An RLS template policy ships in the package's
`docs/supabase.md`. At >100 rows/s sustained, switch to the
Postgres destination directly and keep Supabase Auth +
Storage as separate concerns.

## Quickstart — OpenTelemetry [#quickstart--opentelemetry]

For shops already running a collector (Honeycomb, Datadog,
Grafana, your own OTLP receiver). Buyer-callback only — your
OTel SDK stays on your side; the SDK hands you a GenAI
semantic-convention envelope and your callback ships it.

```ts
import { init, recordCall } from "@pleach/observe";
import { otel } from "@pleach/observe/destinations";

init({
  destination: otel({
    serviceName: "my-agent",
    exportSpan: (envelope) => {
      myExporter.export(envelope);
    },
  }),
});

recordCall({
  turnId: "turn_otel_001",
  providerId: "anthropic",
  callClass: "synthesize",
  family: "anthropic",
  model: "claude-sonnet-4-5",
  inputTokens: 1500,
  outputTokens: 600,
  costUSD: 0.0228,
  startedAt: Date.now() - 1700,
  completedAt: Date.now(),
});
```

Zero `@opentelemetry/*` packages added on the SDK side. The
envelope follows GenAI semantic conventions so it lands in
the same tables as the rest of your AI telemetry.

## Wiring inside an existing loop [#wiring-inside-an-existing-loop]

`init` is called once per process. `recordCall`, `subagent`,
and the destination factories are bound to the module
singleton that `init` configures. Reach for `subagent` when
you need per-tenant or per-step attribution paths inside one
turn.

```ts
import { subagent, recordCall } from "@pleach/observe";

await subagent("tenant-abc").run(async () => {
  await subagent("planner").run(async () => {
    const start = Date.now();
    const reply = await myLLM.complete(prompt);
    recordCall({
      turnId,
      providerId: "openai",
      callClass: "reasoning",
      family: "openai",
      model: "o4-mini",
      inputTokens: reply.usage.prompt_tokens,
      outputTokens: reply.usage.completion_tokens,
      costUSD: estimateCost(reply.usage),
      startedAt: start,
      completedAt: Date.now(),
    });
    return reply;
  });
});
// Row carries attributionPath: ["tenant-abc", "planner"]
```

Attribution paths are stored on the row directly — joinable
to your billing schema with one `GROUP BY tenant_id` or one
`GROUP BY (tenant_id, subagent_path[1])`.

## Common gotchas [#common-gotchas]

* **The SDK is a process-singleton.** `init({destination})`
  is called once at app boot, before any `recordCall(...)`.
  A second `init(...)` call throws — the SDK is
  singleton-per-process, so call it exactly once at boot rather
  than relying on a last-write-wins replacement.
* **`recordCall` is sync-fire, async-write.** It enqueues to
  the destination and returns synchronously. The destination
  decides whether the actual write is sync (memory) or async
  (Postgres, Supabase, OTel). By default both synchronous
  destination throws and async rejections are swallowed
  (soft-fail) so a destination failure never aborts the turn.
  To surface them, opt in with
  `init({ destination, throwOnDestinationError: true })`.
* **No retry, no batching at the SDK boundary.** That's the
  destination's job. The Postgres destination uses your
  pool's connection management; the Supabase destination
  inherits PostgREST behavior; the OTel destination is
  whatever your callback does.
* **Costs are buyer-computed.** `costUSD` on the row is the
  cost as YOU computed it — the SDK is provider-agnostic and
  does not maintain a price catalog. Compute from the
  provider's per-call usage report and ship.
* **`sampling` is validated at `init` time.** A `samplingRatio`
  outside `[0, 1]` (or `NaN`, non-finite) throws at boot, not
  silently at write time. Default is no sampling — every
  call writes.

## See also [#see-also]

* [`@pleach/observe`](/docs/observe) — full surface
  reference: `init`, `recordCall`, `subagent`, the four
  destinations, the `ObserveRow` schema.
* [`Audit ledger`](/docs/audit-ledger) — the row shape and
  the `AuditableCall` v13 record this SDK writes a subset
  of.
* [`Adoption paths`](/docs/adoption-paths) — when the
  brownfield SDK is the right entry point and when
  `@pleach/core` is.
* [`OTel observability`](/docs/otel-observability) — the
  OTel destination's envelope shape and semantic
  conventions.
* [`observableChatbot`](/docs/recipes-pleach-recipes#observablechatbot)
  — the matching recipe for greenfield consumers building
  on `@pleach/core`.
