pleach
Cookbook

BYOK observability

How to drop @pleach/observe into an existing OpenAI / LangChain / hand-rolled agent loop and write one auditable row per LLM call to your own Postgres, Supabase, or OpenTelemetry collector. No @pleach/core commitment required.

@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

  • 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

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

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

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

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

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.

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

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.

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

  • 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

  • @pleach/observe — full surface reference: init, recordCall, subagent, the four destinations, the ObserveRow schema.
  • Audit ledger — the row shape and the AuditableCall v13 record this SDK writes a subset of.
  • Adoption paths — when the brownfield SDK is the right entry point and when @pleach/core is.
  • OTel observability — the OTel destination's envelope shape and semantic conventions.
  • observableChatbot — the matching recipe for greenfield consumers building on @pleach/core.

On this page