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/corecarries 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 anyrecordCall(...). A secondinit(...)call throws — the SDK is singleton-per-process, so call it exactly once at boot rather than relying on a last-write-wins replacement. recordCallis 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 withinit({ 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.
costUSDon 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. samplingis validated atinittime. AsamplingRatiooutside[0, 1](orNaN, 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, theObserveRowschema.Audit ledger— the row shape and theAuditableCallv13 record this SDK writes a subset of.Adoption paths— when the brownfield SDK is the right entry point and when@pleach/coreis.OTel observability— the OTel destination's envelope shape and semantic conventions.observableChatbot— the matching recipe for greenfield consumers building on@pleach/core.
compliantChatbot
Chatbot with PII / PHI scrubbing at the message-content boundary and, when a Supabase client is supplied, at the durable EventLogWriter boundary. Four named profiles map to curated scrubber bundles from @pleach/compliance.
instrumentedCodingAgent
Coding-agent runtime whose executeStep(...) calls inherit a @pleach/observe sub-agent attribution scope. Every recordCall(...) downstream of the step carries [subagent, ...] without threading a recorder argument.