Production
Going to production means three changes from the local setup: a real database, locked-down access, and — if you’re on serverless — making sure spans flush before the runtime freezes. This page is the deploy checklist.
Use Postgres
Switch the adapter to Postgres, and pass your app’s existing pool so Breadcrumb shares connections rather than opening its own:
import { postgres } from "@breadcrumb-sh/core/adapters";
import { pool } from "./db";
export const bc = breadcrumb({
database: postgres(pool),
basePath: "/api/breadcrumb",
authorize: (req) => isAdmin(req),
});
Own your migrations
Breadcrumb migrates lazily on first use, which is convenient in development but a poor fit for production — it needs schema privileges at runtime and changes the database outside your deploy. For production we recommend generating migration files you commit and apply with your own tooling:
npx breadcrumb generate --database $DATABASE_URL
Commit the generated .sql file, apply it in your deploy step, and turn off
runtime migrations so the app never runs DDL:
breadcrumb({ migrations: "manual" });
If you’d rather apply the schema directly in a deploy step instead of committing
files, npx breadcrumb migrate --database $DATABASE_URL also works. See
Migrations for both workflows.
Lock down access
There are two doors, and you have to close both.
The API. Query routes are unauthenticated by default. Guard them with
authorize, which runs on every query request:
breadcrumb({
authorize: (req) => isAdmin(req), // true to allow, false for 401, or a Response
});
The page. The route you render the dashboard at is an ordinary page in your app, so it is guarded by your app’s own auth like any other admin page. Breadcrumb has no say in it.
Guarding only the page is not enough. The dashboard reads everything over the API, so an unguarded API hands your traces to anyone who requests the URL directly, whether or not they can load the page.
// app/admin/traces/[[...slug]]/page.tsx
import { redirect } from "next/navigation";
export default async function TracesPage() {
const session = await auth();
if (!session?.user.isAdmin) redirect("/login");
return <BreadcrumbDashboard api="/api/breadcrumb" basePath="/admin/traces" />;
}
Ingest routes are separate: they’re closed unless you set an ingest.apiKey,
and they authenticate with that key rather than authorize. The
MCP endpoint works the same way, with its own keys.
authorize also decides who can connect a coding agent. MCP keys are minted
through the API, so it is what stands between a stranger and a key that reads
your traces.
WARNING
If you guard the mount with your own middleware instead of authorize, scope
it to exclude /api/ingest/* and /api/mcp. Middleware runs before Breadcrumb
sees the request, and neither an exporter nor a coding agent has a browser
session to present, so blanket middleware silently breaks both.
Flush on serverless
On a long-running server, spans export in the background and you don’t have to do anything. On serverless or edge, the runtime can freeze right after the response, dropping any spans still buffered. Two settings prevent that.
Set flushMode: "sync" so each span exports as it ends:
breadcrumb({ flushMode: "sync" });
And flush before the function returns. Use waitUntil so it doesn’t block the
response:
import { waitUntil } from "@vercel/functions";
waitUntil(bc.flush());
WARNING
Without a flush, the last spans of a serverless invocation can be lost when
the runtime freezes. Always call bc.flush() (or waitUntil(bc.flush()))
before returning.
Set retention
By default, traces are kept for 90 days, and 7 days in development. Override the windows per environment. Sweeps are bounded batches that run at most every 15 minutes and coordinate across instances through the database, so no cron is needed.
breadcrumb({
retention: {
default: "90d",
environments: { development: "7d" },
},
});
Guard sensitive payloads
Prompts and outputs are stored as captured. To scrub PII or trim large payloads
before they’re written, use redact and maxPayloadChars. Both run on every
span from every path. Capping shortens the strings inside a payload rather than
flattening the whole thing, so a trimmed conversation still reads as a
conversation in the dashboard.
breadcrumb({
redact: (span) => {
if (typeof span.input === "string") {
span.input = span.input.replace(/sk-\w+/g, "[redacted]");
}
},
maxPayloadChars: 8192,
});
Deploy checklist
- Point
databaseat Postgres, ideally your existing pool. - Run
breadcrumb migratein your deploy step. - Set
authorizeto protect the API, which also gates who can create MCP keys. - Guard the dashboard route with your app’s own auth.
- On serverless, set
flushMode: "sync"and flush before returning. - Set
retentionwindows for your environments. - Add
redactormaxPayloadCharsif traces may contain sensitive data.
Next steps
- Configuration: the full option reference.
- Dashboard: mounting the UI at your own route.
- Querying your data: build your own views.