🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@breadcrumb-sh/core

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@breadcrumb-sh/core - npm Package Compare versions

Comparing version
0.0.1
to
0.1.0
+646
dist/ddl-C4JrQPLV.mjs
//#region src/db/rows.ts
/** Page size, defaulted and bounded so a caller can't request the whole table. */
function clampLimit(limit) {
return Math.min(Math.max(limit ?? 50, 1), 500);
}
/**
* Trace-selecting predicate for the WHERE clause, ANDing one `trace_id IN (…)`
* subquery per active filter. Kept as subqueries (not a flat row WHERE) because
* dimensions live on different spans of a trace — the root carries userId, a
* child carries model — so a single row rarely satisfies two at once. Returns
* "" when no filter is set.
*/
function traceFilterSql(table, filter, ph) {
const preds = [];
const scope = [];
if (filter.environment !== void 0) scope.push(`environment = ${ph(filter.environment)}`);
if (filter.since !== void 0) scope.push(`start_time >= ${ph(filter.since)}`);
if (filter.until !== void 0) scope.push(`start_time <= ${ph(filter.until)}`);
if (scope.length) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE ${scope.join(" AND ")})`);
if (filter.userId !== void 0) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE user_id = ${ph(filter.userId)})`);
if (filter.model !== void 0) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE model = ${ph(filter.model)})`);
if (filter.status === "error") preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE status = 'error')`);
if (filter.status === "ok") preds.push(`trace_id NOT IN (SELECT trace_id FROM ${table} WHERE status = 'error')`);
return preds.join(" AND ");
}
/**
* Ranks each span within its trace so `root_rank = 1` marks the run's root.
*
* A root is a span with no parent — except the parent may never have reached
* breadcrumb: another exporter owns it, it was sampled away, or the run is
* still in flight. Then every span points at a parent that isn't there and a
* plain `parent_span_id IS NULL` finds no root at all, dropping the run's name
* and payload. Ordering parentless-first, then earliest-starting, takes the
* true root when there is one and the topmost surviving span when there isn't.
*/
const ROOT_ORDER = "PARTITION BY trace_id ORDER BY CASE WHEN parent_span_id IS NULL THEN 0 ELSE 1 END, start_time, id";
/** The span table with `root_rank` attached, as the aggregations read it. */
function rankedSpans(table, whereSql) {
return `(SELECT *, ROW_NUMBER() OVER (${ROOT_ORDER}) AS root_rank
FROM ${table} ${whereSql ? `WHERE ${whereSql}` : ""}) s`;
}
/** Keyset predicate for "rows after `cursor`" given a DESC (sortExpr, keyExpr). */
function keysetSql(sortExpr, keyExpr, cursor, ph) {
const c = decodeCursor(cursor);
if (!c) return "";
return `(${sortExpr} < ${ph(c.sort)} OR (${sortExpr} = ${ph(c.sort)} AND ${keyExpr} < ${ph(c.key)}))`;
}
const CURSOR_SEP = "|";
function encodeCursor(sort, key) {
return `${sort}${CURSOR_SEP}${key}`;
}
function decodeCursor(cursor) {
const i = cursor.indexOf(CURSOR_SEP);
if (i < 0) return null;
const sort = Number(cursor.slice(0, i));
const key = cursor.slice(i + 1);
return Number.isFinite(sort) && key ? {
sort,
key
} : null;
}
/** Wrap a page of rows with its next cursor (null when the page wasn't full). */
function pageOf(items, limit, sortOf, keyOf) {
const last = items[items.length - 1];
return {
items,
nextCursor: items.length >= limit && last ? encodeCursor(sortOf(last), keyOf(last)) : null
};
}
/** Span -> DB row. JSON payloads are stringified (works for TEXT and JSONB). */
function spanToRow(span) {
return {
id: span.id,
trace_id: span.traceId,
parent_span_id: span.parentSpanId ?? null,
name: span.name,
function_id: span.functionId ?? null,
kind: span.kind,
environment: span.environment,
user_id: span.userId ?? null,
session_id: span.sessionId ?? null,
model: span.model ?? null,
provider: span.provider ?? null,
input_tokens: span.inputTokens ?? null,
output_tokens: span.outputTokens ?? null,
cached_input_tokens: span.cachedInputTokens ?? null,
cache_write_tokens: span.cacheWriteTokens ?? null,
reasoning_tokens: span.reasoningTokens ?? null,
cost: span.cost ?? null,
status: span.status,
error: span.error ?? null,
input: span.input === void 0 ? null : JSON.stringify(span.input),
output: span.output === void 0 ? null : JSON.stringify(span.output),
metadata: span.metadata == null ? null : JSON.stringify(span.metadata),
start_time: span.startTime,
end_time: span.endTime ?? null
};
}
/** TEXT columns hold JSON strings, JSONB comes back pre-parsed — accept both. */
function jsonValue(value) {
if (value == null) return null;
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** BIGINT columns come back as strings from node-postgres — coerce. */
function numValue(value) {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function rowToSpan(row) {
return {
id: row.id,
traceId: row.trace_id,
parentSpanId: row.parent_span_id ?? null,
name: row.name,
functionId: row.function_id ?? null,
kind: row.kind,
environment: row.environment,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
model: row.model ?? null,
provider: row.provider ?? null,
inputTokens: numValue(row.input_tokens),
outputTokens: numValue(row.output_tokens),
cachedInputTokens: numValue(row.cached_input_tokens),
cacheWriteTokens: numValue(row.cache_write_tokens),
reasoningTokens: numValue(row.reasoning_tokens),
cost: numValue(row.cost),
status: row.status,
error: row.error ?? null,
input: jsonValue(row.input) ?? void 0,
output: jsonValue(row.output) ?? void 0,
metadata: jsonValue(row.metadata),
startTime: numValue(row.start_time),
endTime: numValue(row.end_time)
};
}
function rowToTraceSummary(row) {
return {
traceId: row.trace_id,
name: row.name,
environment: row.environment,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
spanCount: numValue(row.span_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
function rowToSessionSummary(row) {
return {
sessionKey: row.session_key,
sessionId: row.session_id ?? null,
userId: row.user_id ?? null,
environment: row.environment,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
runCount: numValue(row.run_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
failName: row.fail_name ?? null,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
function rowToRunSummary(row) {
return {
traceId: row.trace_id,
name: row.name,
input: jsonValue(row.input) ?? void 0,
output: jsonValue(row.output) ?? void 0,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
spanCount: numValue(row.span_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
failName: row.fail_name ?? null,
failError: row.fail_error ?? null,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
/**
* Session aggregation. A trace's session is derived first (only the root span
* reliably carries session_id — AI SDK child spans don't), then traces group
* into sessions; sessionless traces stand alone keyed by trace_id. `whereSql`
* filters which traces feed the rollup; `havingSql` is the outer keyset.
* Ordered by last activity (MAX end_time) so the cursor key sits in the row.
*/
function sessionSummarySelect(table, whereSql, havingSql) {
return `SELECT
COALESCE(t.session_id, t.trace_id) AS session_key,
MAX(t.session_id) AS session_id,
MAX(t.user_id) AS user_id,
MIN(t.environment) AS environment,
MIN(t.start_time) AS start_time,
MAX(t.end_time) AS end_time,
COUNT(*) AS run_count,
SUM(t.error_count) AS error_count,
MAX(t.fail_name) AS fail_name,
SUM(t.input_tokens) AS input_tokens,
SUM(t.output_tokens) AS output_tokens,
SUM(t.cost) AS cost
FROM (
SELECT trace_id,
MAX(session_id) AS session_id,
MAX(user_id) AS user_id,
MIN(environment) AS environment,
MIN(start_time) AS start_time,
MAX(COALESCE(end_time, start_time)) AS end_time,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
MAX(CASE WHEN status = 'error' THEN name END) AS fail_name,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${table}
${whereSql ? `WHERE ${whereSql}` : ""}
GROUP BY trace_id
) t
GROUP BY COALESCE(t.session_id, t.trace_id)
${havingSql ? `HAVING ${havingSql}` : ""}
ORDER BY MAX(t.end_time) DESC, COALESCE(t.session_id, t.trace_id) DESC`;
}
/** Headline stats over the filtered trace set: one row per trace, then rolled up. */
function statsSelect(table, whereSql) {
return `SELECT
COUNT(*) AS runs,
SUM(has_error) AS errors,
SUM(cost) AS cost,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
AVG(duration) AS avg_latency,
MAX(duration) AS max_latency
FROM (
SELECT trace_id,
MAX(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS has_error,
SUM(COALESCE(cost, 0)) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
MAX(end_time) - MIN(start_time) AS duration
FROM ${table}
${whereSql ? `WHERE ${whereSql}` : ""}
GROUP BY trace_id
) t`;
}
function shapeStats(row) {
const runs = numValue(row?.runs) ?? 0;
const errors = numValue(row?.errors) ?? 0;
const avg = numValue(row?.avg_latency);
return {
runs,
errors,
errorRate: runs > 0 ? errors / runs : 0,
cost: numValue(row?.cost) ?? 0,
inputTokens: numValue(row?.input_tokens) ?? 0,
outputTokens: numValue(row?.output_tokens) ?? 0,
avgLatencyMs: avg == null ? null : Math.round(avg),
maxLatencyMs: numValue(row?.max_latency)
};
}
function runSummarySelect(table, keyFilter, castText) {
return `SELECT
trace_id,
MAX(CASE WHEN root_rank = 1 THEN name END) AS name,
MAX(CASE WHEN root_rank = 1 THEN input${castText} END) AS input,
MAX(CASE WHEN root_rank = 1 THEN output${castText} END) AS output,
MIN(start_time) AS start_time,
MAX(end_time) AS end_time,
COUNT(*) AS span_count,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
MAX(CASE WHEN status = 'error' THEN name END) AS fail_name,
MAX(CASE WHEN status = 'error' THEN error END) AS fail_error,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${rankedSpans(table, `trace_id IN (
SELECT trace_id FROM ${table}
GROUP BY trace_id
HAVING COALESCE(MAX(session_id), trace_id) = ${keyFilter}
)`)}
GROUP BY trace_id
ORDER BY MIN(start_time) ASC`;
}
/**
* Cost time series bucketed by day + model. `dayExpr` is the dialect-specific
* expression turning start_time (epoch ms) into a UTC 'YYYY-MM-DD' string;
* `filter` carries the cutoff/environment predicates (leading AND).
*/
function costByDaySelect(table, dayExpr, filter) {
return `SELECT ${dayExpr} AS day, model,
SUM(cost) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(cached_input_tokens, 0)) AS cached_input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
COUNT(*) AS count
FROM ${table}
WHERE cost IS NOT NULL ${filter}
GROUP BY day, model
ORDER BY day ASC`;
}
/**
* Cost attributed to the function that spent it: the caller's functionId, or
* the run's root-span name for spans that carry none (manual `bc.trace` work,
* or instrumentation that never named itself). Attributing per span rather than
* per trace splits a run that calls two functions between them, and survives a
* root that belongs to some other tracer.
*/
function costByFunctionSelect(table, filter) {
return `SELECT COALESCE(function_id, root_name) AS key,
SUM(COALESCE(cost, 0)) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(cached_input_tokens, 0)) AS cached_input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
COUNT(DISTINCT trace_id) AS count
FROM (
SELECT trace_id, function_id, cost, input_tokens, cached_input_tokens, output_tokens,
FIRST_VALUE(name) OVER (${ROOT_ORDER}) AS root_name
FROM ${table}
WHERE 1 = 1 ${filter}
) t
GROUP BY COALESCE(function_id, root_name)
ORDER BY cost DESC`;
}
function shapeCostSummary(windowDays, dayRows, funcRows) {
const days = dayRows.map((r) => ({
day: r.day,
model: r.model ?? null,
cost: numValue(r.cost) ?? 0,
inputTokens: numValue(r.input_tokens) ?? 0,
cachedInputTokens: numValue(r.cached_input_tokens) ?? 0,
outputTokens: numValue(r.output_tokens) ?? 0,
count: numValue(r.count) ?? 0
}));
const totals = days.reduce((a, d) => ({
cost: a.cost + d.cost,
inputTokens: a.inputTokens + d.inputTokens,
cachedInputTokens: a.cachedInputTokens + d.cachedInputTokens,
outputTokens: a.outputTokens + d.outputTokens
}), {
cost: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0
});
const modelMap = /* @__PURE__ */ new Map();
for (const d of days) {
const g = modelMap.get(d.model) ?? {
key: d.model,
cost: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
count: 0
};
g.cost += d.cost;
g.inputTokens += d.inputTokens;
g.cachedInputTokens += d.cachedInputTokens;
g.outputTokens += d.outputTokens;
g.count += d.count;
modelMap.set(d.model, g);
}
return {
windowDays,
totals,
days,
byModel: [...modelMap.values()].sort((a, b) => b.cost - a.cost),
byFunction: funcRows.map((r) => ({
key: r.key ?? null,
cost: numValue(r.cost) ?? 0,
inputTokens: numValue(r.input_tokens) ?? 0,
cachedInputTokens: numValue(r.cached_input_tokens) ?? 0,
outputTokens: numValue(r.output_tokens) ?? 0,
count: numValue(r.count) ?? 0
})).filter((g) => g.cost > 0).sort((a, b) => b.cost - a.cost)
};
}
/**
* Shared trace aggregation. `whereSql` selects which traces to include (from
* traceFilterSql); `havingSql` is the keyset predicate. Adapters append LIMIT.
*/
function traceSummarySelect(table, whereSql, havingSql) {
return `SELECT
trace_id,
MAX(CASE WHEN root_rank = 1 THEN name END) AS name,
MIN(environment) AS environment,
MAX(user_id) AS user_id,
MAX(session_id) AS session_id,
MIN(start_time) AS start_time,
MAX(end_time) AS end_time,
COUNT(*) AS span_count,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${rankedSpans(table, whereSql)}
GROUP BY trace_id
${havingSql ? `HAVING ${havingSql}` : ""}
ORDER BY MIN(start_time) DESC, trace_id DESC`;
}
function rowToMcpKey(row) {
return {
id: row.id,
name: row.name,
keyPrefix: row.key_prefix,
createdAt: Number(row.created_at),
lastUsedAt: row.last_used_at === null ? null : Number(row.last_used_at)
};
}
//#endregion
//#region src/db/schema.ts
/**
* Single source of truth for breadcrumb's tables. Adapters generate their
* DDL from this; the CLI's migrate/generate will diff against it.
* Tables live in the user's database, so everything is prefixed.
*/
const SPANS_TABLE = "breadcrumb_spans";
const spanColumns = {
id: {
type: "text",
primary: true
},
trace_id: { type: "text" },
parent_span_id: {
type: "text",
nullable: true
},
name: { type: "text" },
function_id: {
type: "text",
nullable: true
},
kind: { type: "text" },
environment: { type: "text" },
user_id: {
type: "text",
nullable: true
},
session_id: {
type: "text",
nullable: true
},
model: {
type: "text",
nullable: true
},
provider: {
type: "text",
nullable: true
},
input_tokens: {
type: "integer",
nullable: true
},
output_tokens: {
type: "integer",
nullable: true
},
cached_input_tokens: {
type: "integer",
nullable: true
},
cache_write_tokens: {
type: "integer",
nullable: true
},
reasoning_tokens: {
type: "integer",
nullable: true
},
cost: {
type: "real",
nullable: true
},
status: { type: "text" },
error: {
type: "text",
nullable: true
},
input: {
type: "json",
nullable: true
},
output: {
type: "json",
nullable: true
},
metadata: {
type: "json",
nullable: true
},
start_time: { type: "integer" },
end_time: {
type: "integer",
nullable: true
}
};
const spanIndexes = [
{
name: "breadcrumb_spans_trace_id",
columns: ["trace_id"]
},
{
name: "breadcrumb_spans_env_start",
columns: ["environment", "start_time"]
},
{
name: "breadcrumb_spans_user_id",
columns: ["user_id", "trace_id"]
},
{
name: "breadcrumb_spans_model",
columns: ["model", "trace_id"]
},
{
name: "breadcrumb_spans_status",
columns: ["status", "trace_id"]
}
];
/** Tiny key/value table for cross-instance coordination (sweep claims). */
const META_TABLE = "breadcrumb_meta";
const metaColumns = {
key: {
type: "text",
primary: true
},
value: { type: "integer" }
};
/**
* Keys that let a coding agent read traces over MCP. Created from the dashboard
* (so whoever can already see traces can mint one) and presented as a bearer
* token. Only the SHA-256 hash is stored — the token itself is shown once at
* creation and is unrecoverable afterwards, so a database leak yields nothing
* replayable. `key_prefix` exists purely so the UI can tell two keys apart.
*/
const MCP_KEYS_TABLE = "breadcrumb_mcp_keys";
const mcpKeyColumns = {
id: {
type: "text",
primary: true
},
name: { type: "text" },
key_hash: { type: "text" },
key_prefix: { type: "text" },
created_at: { type: "integer" },
last_used_at: {
type: "integer",
nullable: true
}
};
const mcpKeyIndexes = [{
name: "breadcrumb_mcp_keys_hash",
columns: ["key_hash"],
unique: true
}];
//#endregion
//#region src/db/ddl.ts
const TYPE_MAP = {
postgres: {
text: "TEXT",
integer: "BIGINT",
real: "DOUBLE PRECISION",
json: "JSONB"
},
sqlite: {
text: "TEXT",
integer: "INTEGER",
real: "REAL",
json: "TEXT"
}
};
function columnDdl(dialect, name, spec) {
const parts = [name, TYPE_MAP[dialect][spec.type]];
if (spec.primary) parts.push("PRIMARY KEY");
if (!spec.nullable && !spec.primary) parts.push("NOT NULL");
return parts.join(" ");
}
function createTableSql(dialect, table, columns) {
return `CREATE TABLE IF NOT EXISTS ${table} (${Object.entries(columns).map(([name, spec]) => columnDdl(dialect, name, spec)).join(", ")})`;
}
function addColumnSql(dialect, table, name, spec) {
return `ALTER TABLE ${table} ADD COLUMN ${dialect === "postgres" ? "IF NOT EXISTS " : ""}${name} ${TYPE_MAP[dialect][spec.type]}`;
}
function createIndexSql(table, idx) {
return `CREATE ${idx.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${idx.name} ON ${table} (${idx.columns.join(", ")})`;
}
/**
* Diff the current schema against a live database's state and return the DDL to
* reconcile it. The single source of truth for both `migrate()` (which executes
* the statements) and `generate` (which writes them to a file).
*/
function planMigration(dialect, state) {
const statements = [];
const createdTables = [];
const addedColumns = [];
if (!state.spansExists) {
statements.push(createTableSql(dialect, SPANS_TABLE, spanColumns));
createdTables.push(SPANS_TABLE);
} else for (const [name, spec] of Object.entries(spanColumns)) {
if (state.spansColumns.has(name)) continue;
statements.push(addColumnSql(dialect, SPANS_TABLE, name, spec));
addedColumns.push(`${SPANS_TABLE}.${name}`);
}
for (const idx of spanIndexes) if (!state.indexNames.has(idx.name)) statements.push(createIndexSql(SPANS_TABLE, idx));
if (!state.metaExists) {
statements.push(createTableSql(dialect, META_TABLE, metaColumns));
createdTables.push(META_TABLE);
}
if (!state.mcpKeysExists) {
statements.push(createTableSql(dialect, MCP_KEYS_TABLE, mcpKeyColumns));
createdTables.push(MCP_KEYS_TABLE);
}
for (const idx of mcpKeyIndexes) if (!state.indexNames.has(idx.name)) statements.push(createIndexSql(MCP_KEYS_TABLE, idx));
return {
statements,
createdTables,
addedColumns
};
}
/** The empty state — plans a full, fresh schema without a database connection. */
const EMPTY_SCHEMA_STATE = {
spansExists: false,
spansColumns: /* @__PURE__ */ new Set(),
indexNames: /* @__PURE__ */ new Set(),
metaExists: false,
mcpKeysExists: false
};
/** Render a plan as a reviewable `.sql` migration file. */
function renderMigrationSql(dialect, plan, generatedAt) {
return [
`-- Generated by \`breadcrumb generate\` at ${generatedAt}`,
`-- Dialect: ${dialect}`,
"-- Additive-only. Review, commit, and apply with your migration tooling.",
""
].join("\n") + plan.statements.map((s) => `${s};`).join("\n\n") + "\n";
}
//#endregion
export { statsSelect as C, spanToRow as S, traceSummarySelect as T, rowToTraceSummary as _, META_TABLE as a, shapeCostSummary as b, clampLimit as c, keysetSql as d, pageOf as f, rowToSpan as g, rowToSessionSummary as h, MCP_KEYS_TABLE as i, costByDaySelect as l, rowToRunSummary as m, planMigration as n, SPANS_TABLE as o, rowToMcpKey as p, renderMigrationSql as r, spanColumns as s, EMPTY_SCHEMA_STATE as t, costByFunctionSelect as u, runSummarySelect as v, traceFilterSql as w, shapeStats as x, sessionSummarySelect as y };
import { S as TraceSummary, _ as SpanKind, a as DatabaseAdapter, b as Stats, f as Page, g as SessionSummary, h as SchemaState, i as CostSummary, m as RunSummary, o as Dialect, r as CostQueryOptions, s as ListOptions, u as MigrationPlan, v as SpanRecord, x as TraceFilter } from "./types-BL3Zics_.mjs";
import { Tracer } from "@opentelemetry/api";
//#region src/otel/pipeline.d.ts
/** Matches the AI SDK's telemetry metadata constraint (OTel AttributeValue). */
type TelemetryMetadataValue = string | number | boolean | string[] | number[] | boolean[];
interface TelemetryOptions {
/** Names the operation. Every span of the call carries it, so a run reads as
* what it does rather than as `ai.streamText`, wherever it sits in a trace. */
functionId?: string;
/** Your app's end-user id — groups and filters traces by who ran them. */
userId?: string;
/** Groups related runs into one conversation/session. */
sessionId?: string;
metadata?: Record<string, TelemetryMetadataValue>;
recordInputs?: boolean;
recordOutputs?: boolean;
}
/** The settings object the Vercel AI SDK expects for experimental_telemetry. */
interface TelemetrySettings {
isEnabled: true;
tracer: Tracer;
functionId?: string;
metadata?: Record<string, TelemetryMetadataValue>;
recordInputs?: boolean;
recordOutputs?: boolean;
}
//#endregion
//#region src/pricing.d.ts
/** USD per 1M tokens, as declared by your app. breadcrumb ships no prices. */
interface ModelPrice {
input: number;
output: number;
/** Price per 1M cache-read tokens. Defaults to `input` (no discount assumed). */
cachedInput?: number;
/** Price per 1M cache-write tokens. Defaults to `input` (no premium assumed). */
cacheWrite?: number;
}
/**
* Your model prices, keyed by model name. Keys match as lowercase substrings
* of the span's model, longest key wins (so "claude-opus" beats "claude").
*/
type PricingTable = Record<string, ModelPrice>;
type Pricing = PricingTable | false;
//#endregion
//#region src/retention.d.ts
interface RetentionOptions {
/** Window for environments without an explicit entry. Default: "90d". */
default?: string;
/** Per-environment overrides, e.g. { development: "7d" }. */
environments?: Record<string, string>;
/** "auto" (default): bounded sweeps piggyback on ingest. "manual": only bc.api.runRetention(). */
sweep?: "auto" | "manual";
}
//#endregion
//#region src/mcp/config.d.ts
interface McpOptions {
/**
* What MCP clients register this server as. Defaults to "breadcrumb", or
* "breadcrumb-local" when reached over a loopback address, so a developer can
* connect their local and deployed instances at once without the second
* `mcp add` overwriting the first. Set it explicitly when you run more than
* two, e.g. "breadcrumb-staging".
*/
name?: string;
/**
* Hide the captured prompt/completion payloads from the agent. Everything else
* — timings, tokens, cost, model, status, errors — stays queryable, so an
* agent can still diagnose a failing run without reading user content.
*/
hidePayloads?: boolean;
}
//#endregion
//#region src/router.d.ts
type AuthorizeFn = (request: Request) => boolean | Response | undefined | null | Promise<boolean | Response | undefined | null>;
//#endregion
//#region src/trace.d.ts
interface SpanAttrs {
/** The operation this span belongs to, for attributing cost to a function
* the way `bc.telemetry({ functionId })` does. Defaults to nothing: a manual
* span is attributed to its run's root name. */
functionId?: string;
kind?: SpanKind;
model?: string;
provider?: string;
inputTokens?: number;
outputTokens?: number;
/** Cache-read tokens (subset of inputTokens). */
cachedInputTokens?: number;
/** Cache-write tokens (subset of inputTokens). */
cacheWriteTokens?: number;
/** Reasoning/thinking tokens (subset of outputTokens). */
reasoningTokens?: number;
cost?: number;
input?: unknown;
output?: unknown;
metadata?: Record<string, unknown>;
}
interface TraceAttrs {
functionId?: string;
userId?: string;
sessionId?: string;
metadata?: Record<string, unknown>;
}
interface SpanContext {
/** Attach/override attributes on the current span (tokens, model, io, ...). */
set(attrs: SpanAttrs): void;
/** Run a nested child span. A thrown error marks the span failed and rethrows. */
span<T>(name: string, fn: (s: SpanContext) => T | Promise<T>): Promise<T>;
span<T>(name: string, attrs: SpanAttrs, fn: (s: SpanContext) => T | Promise<T>): Promise<T>;
}
type TraceFn = {
<T>(name: string, fn: (t: SpanContext) => T | Promise<T>): Promise<T>;
<T>(name: string, attrs: TraceAttrs, fn: (t: SpanContext) => T | Promise<T>): Promise<T>;
};
//#endregion
//#region src/db/ddl.d.ts
/**
* Diff the current schema against a live database's state and return the DDL to
* reconcile it. The single source of truth for both `migrate()` (which executes
* the statements) and `generate` (which writes them to a file).
*/
declare function planMigration(dialect: Dialect, state: SchemaState): MigrationPlan;
/** The empty state — plans a full, fresh schema without a database connection. */
declare const EMPTY_SCHEMA_STATE: SchemaState;
/** Render a plan as a reviewable `.sql` migration file. */
declare function renderMigrationSql(dialect: Dialect, plan: MigrationPlan, generatedAt: string): string;
//#endregion
//#region src/index.d.ts
interface BreadcrumbOptions {
/** Database adapter, e.g. sqlite(".breadcrumb/dev.db") from @breadcrumb-sh/core/adapters */
database: DatabaseAdapter;
/** Where the handler is mounted, e.g. "/admin/traces". Default: "/breadcrumb" */
basePath?: string;
/** Stamped on every span. Default: VERCEL_ENV ?? NODE_ENV ?? "development" */
environment?: string;
/**
* Enables the HTTP ingest endpoints for external services (OTLP later).
* Omit entirely if only this app writes traces — the endpoints then 404.
*/
ingest?: {
apiKey: string;
};
/**
* Retention windows per environment. Defaults: 90d, development 7d.
* Sweeps are bounded and piggyback on ingest — no cron needed.
*/
retention?: RetentionOptions;
/**
* Guards the UI/query routes (ingest routes use the API key instead).
* Return true to allow, false to 401, or a Response (e.g. a redirect).
* Alternative to wrapping the mount in middleware.
*/
authorize?: AuthorizeFn;
/**
* Your model prices (USD per 1M tokens), keyed by model name. When a span
* has tokens + model but no explicit cost, breadcrumb computes it from these.
* Omit to store only costs you set yourself — breadcrumb assumes no prices.
*/
pricing?: Pricing;
/**
* Scrub PII or trim payloads before storage. Runs on every span from every
* path (manual, AI SDK, external ingest) just before it's written. Mutate the
* span in place, or return a replacement.
*/
redact?: (span: SpanRecord) => SpanRecord | void;
/**
* Max characters kept for a span's captured input/output; longer payloads are
* truncated with a marker so one call can't write megabytes. Default 16384;
* set 0 to disable.
*/
maxPayloadChars?: number;
/**
* "batch" (default): buffer spans and export every ~2s — best for a
* long-running server. "sync": export each span as it ends, for serverless or
* edge where a final flush isn't guaranteed. Either way, `await bc.flush()`
* (or `waitUntil(bc.flush())`) before a serverless function returns.
*/
flushMode?: "batch" | "sync";
/**
* "auto" (default): create/upgrade the schema on first use — great for local
* development. "manual": never run DDL at runtime; you own migrations, applied
* with `breadcrumb migrate` or `breadcrumb generate` plus your tooling.
*/
migrations?: "auto" | "manual";
/**
* Tunes the MCP endpoint at `basePath + /api/mcp`, where a coding agent reads
* your traces. Not a switch: the endpoint is always mounted, but it is
* unreachable until someone mints a key from the dashboard's MCP tab, which
* `authorize` already guards. The agent gets policy-gated read access to the
* span table only — no other table in your database, and no writes.
*/
mcp?: McpOptions;
}
interface Breadcrumb {
/** Fetch-native handler: mount it, wrapped in your own auth. */
handler: (request: Request) => Promise<Response>;
/** Manual tracing: bc.trace("name", { userId }, async (t) => { ... t.span(...) }) */
trace: TraceFn;
/** Preconfigured experimental_telemetry settings for the Vercel AI SDK. */
telemetry: (options?: TelemetryOptions) => TelemetrySettings;
/** Flush buffered spans (serverless: call before the runtime freezes). */
flush: () => Promise<void>;
/**
* Programmatic queries + ingest, callable server-side without HTTP — the
* headless surface for building a custom admin UI over your own data.
*/
api: {
/** Traces (one per run), newest first, filtered + keyset-paginated. */
listTraces(options?: ListOptions): Promise<Page<TraceSummary>>;
/** Sessions (traces grouped by sessionId), by last activity, paginated. */
listSessions(options?: ListOptions): Promise<Page<SessionSummary>>;
listRuns(options: {
sessionKey: string;
}): Promise<RunSummary[]>;
getTrace(options: {
id: string;
}): Promise<SpanRecord[]>;
getSpan(options: {
id: string;
}): Promise<SpanRecord | null>;
listEnvironments(): Promise<string[]>;
costSummary(options?: CostQueryOptions): Promise<CostSummary>;
/** Headline numbers (runs, error rate, cost, latency) over a filter. */
stats(options?: TraceFilter): Promise<Stats>;
ingestSpans(options: {
spans: SpanRecord[];
}): Promise<void>;
/** One bounded retention batch (for cron/manual sweeping); returns rows deleted. */
runRetention(): Promise<number>;
};
options: Required<Pick<BreadcrumbOptions, "basePath" | "environment">> & BreadcrumbOptions;
}
declare function breadcrumb(options: BreadcrumbOptions): Breadcrumb;
//#endregion
export { TelemetrySettings as _, planMigration as a, SpanContext as c, AuthorizeFn as d, RetentionOptions as f, TelemetryOptions as g, PricingTable as h, EMPTY_SCHEMA_STATE as i, TraceAttrs as l, Pricing as m, BreadcrumbOptions as n, renderMigrationSql as o, ModelPrice as p, breadcrumb as r, SpanAttrs as s, Breadcrumb as t, TraceFn as u };
import { o as SPANS_TABLE } from "./ddl-C4JrQPLV.mjs";
import { t as resolveMcpServerName } from "./config-BANN28mc.mjs";
import { Valv } from "@valv/core";
import { createMcpServer } from "@valv/mcp-sdk";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { SqliteAdapter } from "@valv/sqlite";
import { PostgresAdapter } from "@valv/postgres";
//#region src/mcp/schema.ts
/**
* The span table, declared for valv rather than introspected.
*
* Breadcrumb owns this schema, so probing the database would only rediscover
* what is already known — and would also surface `breadcrumb_meta` and
* `breadcrumb_mcp_keys`, which the agent must never see. Declaring one resource
* means the agent's reachable surface is exactly this table, by construction.
*
* The descriptions are load-bearing: they are the only thing telling the model
* that durations are epoch milliseconds, that a trace is a set of spans rather
* than a row, and which columns are worth grouping by.
*/
const NATIVE = {
postgres: {
text: "TEXT",
integer: "BIGINT",
real: "DOUBLE PRECISION",
json: "JSONB"
},
sqlite: {
text: "TEXT",
integer: "INTEGER",
real: "REAL",
json: "TEXT"
}
};
const COLUMNS = {
id: {
type: "string",
native: "text",
id: true,
description: "Unique span id."
},
trace_id: {
type: "string",
native: "text",
description: "Groups spans into one trace (a single run). A trace is not a row — aggregate over this to reason about a run."
},
parent_span_id: {
type: "string",
native: "text",
nullable: true,
description: "Parent span's id. NULL marks the trace's root span, whose name is the run's name. It can also be non-NULL and point at a span that is not in this table, when another tracer owns the span above it — treat the trace's earliest span as its root in that case."
},
name: {
type: "string",
native: "text",
description: "Operation name, e.g. the tool or function called."
},
function_id: {
type: "string",
native: "text",
nullable: true,
description: "The operation the caller named (functionId), carried by every span of that call. Group cost and latency by this to attribute them to a function; NULL for spans that were never named."
},
kind: {
type: "enum",
native: "text",
enumValues: [
"span",
"llm",
"tool",
"embedding",
"retrieval",
"agent"
],
description: "What produced the span. 'llm' spans carry model, tokens and cost."
},
environment: {
type: "string",
native: "text",
description: "Deployment environment, e.g. 'production' or 'development'."
},
user_id: {
type: "string",
native: "text",
nullable: true,
description: "Your app's end-user id, if set."
},
session_id: {
type: "string",
native: "text",
nullable: true,
description: "Groups related traces into a session. NULL when the run was not part of one."
},
model: {
type: "string",
native: "text",
nullable: true,
description: "Model name for llm spans, e.g. 'claude-sonnet-5'. Useful to group cost and latency by."
},
provider: {
type: "string",
native: "text",
nullable: true,
description: "Model provider, e.g. 'anthropic'."
},
input_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Prompt tokens."
},
output_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Completion tokens."
},
cached_input_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Prompt tokens served from cache."
},
cache_write_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Tokens written to the prompt cache."
},
reasoning_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Reasoning tokens, when the model reports them."
},
cost: {
type: "number",
native: "real",
nullable: true,
description: "Cost in USD. NULL when no price is configured for the model — not the same as zero."
},
status: {
type: "enum",
native: "text",
enumValues: ["ok", "error"],
description: "Span outcome. Filter on 'error' to find failures."
},
error: {
type: "string",
native: "text",
nullable: true,
description: "Error message when status is 'error'."
},
input: {
type: "json",
native: "json",
nullable: true,
description: "Captured input payload (prompt or arguments). May be truncated, and may be redacted."
},
output: {
type: "json",
native: "json",
nullable: true,
description: "Captured output payload (completion or return value). May be truncated, and may be redacted."
},
metadata: {
type: "json",
native: "json",
nullable: true,
description: "Arbitrary metadata attached at trace time."
},
start_time: {
type: "number",
native: "integer",
description: "Start time as epoch MILLISECONDS, stored as an integer — not a date. Compare against epoch-ms numbers; date functions do not apply."
},
end_time: {
type: "number",
native: "integer",
nullable: true,
description: "End time as epoch MILLISECONDS. NULL if the span never completed. Duration is end_time - start_time."
}
};
/**
* Build the valv schema for a dialect.
*
* Note the timestamps are declared `number`, not `date`. They really are epoch
* milliseconds in an integer column, and calling them dates would hand the model
* date functions that silently misread them (SQLite's strftime would parse the
* integer as a Julian day and bucket into the wrong era).
*/
function spanSchema(dialect) {
const fields = {};
for (const [name, col] of Object.entries(COLUMNS)) fields[name] = {
name,
type: col.type,
nativeType: NATIVE[dialect][col.native],
isNullable: col.nullable ?? false,
isId: col.id ?? false,
isPrimaryKeyPart: col.id ?? false,
description: col.description,
...col.enumValues ? { enumValues: [...col.enumValues] } : {}
};
return { resources: { [SPANS_TABLE]: {
name: SPANS_TABLE,
tableName: SPANS_TABLE,
description: "LLM and agent execution spans. One row per operation; spans sharing a trace_id form one run. Timestamps are epoch milliseconds.",
fields,
relations: {}
} } };
}
//#endregion
//#region src/mcp/server.ts
const PROTOCOL_IDENTITY_VERSION = "1";
function valvAdapter(adapter) {
const schema = spanSchema(adapter.id);
const client = adapter.client();
return adapter.id === "sqlite" ? new SqliteAdapter(client, { schema }) : new PostgresAdapter(client, { schema });
}
/**
* Build the valv instance the MCP server fronts. The schema declares exactly one
* resource, so `deny-all` plus a single allow rule means the agent can reach the
* span table and nothing else — notably not `breadcrumb_mcp_keys`, whose hashes
* would otherwise be readable through the very keys they authenticate.
*/
async function createTraceValv(adapter, options = {}) {
const valv = new Valv({
adapter: valvAdapter(adapter),
defaultPolicy: "deny-all"
});
valv.policy(SPANS_TABLE, () => ({
read: true,
...options.hidePayloads ? { fields: { deny: ["input", "output"] } } : {}
}));
await valv.loadSchema();
return valv;
}
/**
* A fetch handler serving MCP over streamable HTTP, for mounting inside the
* app's own router. Stateless: a fresh server and transport per request, which
* is what lets this run unchanged on serverless and edge runtimes where no
* instance survives between calls.
*/
function createMcpHandler(valv, options = {}) {
return async (request) => {
const server = createMcpServer(valv, {
context: {},
serverInfo: {
name: resolveMcpServerName(options, request),
version: PROTOCOL_IDENTITY_VERSION
}
});
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: void 0,
enableJsonResponse: true
});
try {
await server.connect(transport);
return await transport.handleRequest(request);
} finally {
transport.close();
server.close();
}
};
}
//#endregion
export { createMcpHandler, createTraceValv };
//#region src/db/types.d.ts
type SpanKind = "span" | "llm" | "tool" | "embedding" | "retrieval" | "agent";
type SpanStatus = "ok" | "error";
/** The normalized span model — every ingest dialect maps into this. */
interface SpanRecord {
id: string;
traceId: string;
parentSpanId?: string | null;
name: string;
/**
* The operation the caller named — `bc.telemetry({ functionId })`. Kept
* alongside `name` (which a tool span overrides with the tool's name) so cost
* and latency can be attributed to a function wherever its spans sit.
*/
functionId?: string | null;
kind: SpanKind;
environment: string;
userId?: string | null;
sessionId?: string | null;
model?: string | null;
provider?: string | null;
/** Total input tokens, INCLUSIVE of cached reads/writes (normalized). */
inputTokens?: number | null;
outputTokens?: number | null;
/** Cache-read tokens — a subset of inputTokens, billed at a discount. */
cachedInputTokens?: number | null;
/** Cache-write/creation tokens — a subset of inputTokens, billed at a premium. */
cacheWriteTokens?: number | null;
/** Reasoning/thinking tokens — a subset of outputTokens. */
reasoningTokens?: number | null;
cost?: number | null;
status: SpanStatus;
error?: string | null;
input?: unknown;
output?: unknown;
metadata?: Record<string, unknown> | null;
startTime: number;
endTime?: number | null;
}
/** Trace list row, aggregated from spans on read. */
interface TraceSummary {
traceId: string;
name: string;
environment: string;
userId: string | null;
sessionId: string | null;
startTime: number;
endTime: number | null;
spanCount: number;
errorCount: number;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
/**
* Filters shared by list + stats queries. Each dimension selects *traces*
* (a trace matches if any of its spans satisfies the predicate), so a filtered
* list still aggregates a trace's full span set — counts and cost stay whole.
*/
interface TraceFilter {
environment?: string;
userId?: string;
/** "error" → traces with a failed span; "ok" → traces with none. */
status?: SpanStatus;
/** Traces containing a span with this exact model. */
model?: string;
/** Inclusive lower/upper bounds on span start_time (epoch ms). */
since?: number;
until?: number;
}
interface ListOptions extends TraceFilter {
/** Page size. Default 50, clamped to 500. */
limit?: number;
/** Opaque keyset cursor from a previous page's `nextCursor`. */
cursor?: string;
}
/** Back-compat alias for the pre-filter options shape. */
type ListTracesOptions = ListOptions;
/** One page of results plus the cursor to fetch the next (null at the end). */
interface Page<T> {
items: T[];
nextCursor: string | null;
}
/** Headline numbers over a filtered set of traces, for a custom dashboard. */
interface Stats {
/** Distinct traces (runs) in the set. */
runs: number;
/** Runs containing at least one failed span. */
errors: number;
/** errors / runs, 0 when empty. */
errorRate: number;
cost: number;
inputTokens: number;
outputTokens: number;
/** Mean/max root-span duration in ms; null when no run has ended. */
avgLatencyMs: number | null;
maxLatencyMs: number | null;
}
/**
* A session groups traces sharing a sessionId; traces without one stand
* alone (sessionKey = traceId), so every trace appears in the sessions view.
*/
interface SessionSummary {
sessionKey: string;
sessionId: string | null;
userId: string | null;
environment: string;
startTime: number;
endTime: number | null;
runCount: number;
errorCount: number;
failName: string | null;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
/** One run = one trace, with its root span's payload for the feed. */
interface RunSummary {
traceId: string;
name: string;
input: unknown;
output: unknown;
startTime: number;
endTime: number | null;
spanCount: number;
errorCount: number;
failName: string | null;
failError: string | null;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
interface MigrationResult {
createdTables: string[];
addedColumns: string[];
}
type Dialect = "postgres" | "sqlite";
/** A snapshot of the live schema, from `inspectSchema()`, used to plan a diff. */
interface SchemaState {
spansExists: boolean;
spansColumns: Set<string>;
indexNames: Set<string>;
metaExists: boolean;
mcpKeysExists: boolean;
}
/** The DDL needed to bring a database to the current schema. */
interface MigrationPlan {
/** Statements to run, in order, without trailing semicolons. */
statements: string[];
createdTables: string[];
addedColumns: string[];
}
/**
* One retention rule: delete spans older than `before` (epoch ms).
* `environment: null` matches every environment NOT covered by another rule.
*/
interface RetentionRule {
environment: string | null;
before: number;
}
interface CostQueryOptions {
environment?: string;
/** Trailing window in days (default 14, max 90). */
days?: number;
}
/** One day+model cost bucket, for the stacked time series. */
interface CostDatum {
day: string;
model: string | null;
cost: number;
inputTokens: number;
/** Cache-read tokens within inputTokens. */
cachedInputTokens: number;
outputTokens: number;
count: number;
}
interface CostGroup {
key: string | null;
cost: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
count: number;
}
interface CostSummary {
windowDays: number;
totals: {
cost: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
};
days: CostDatum[];
byModel: CostGroup[];
byFunction: CostGroup[];
}
/**
* An MCP key as the dashboard sees it. Deliberately excludes `key_hash` — the
* only place that ever reads it is the resolver, via findMcpKeyByHash.
*/
interface McpKeyRecord {
id: string;
name: string;
keyPrefix: string;
createdAt: number;
lastUsedAt: number | null;
}
interface DatabaseAdapter {
id: Dialect;
/** Create/upgrade breadcrumb's tables. Additive-only; safe to call repeatedly. */
migrate(): Promise<MigrationResult>;
/** Read the live schema, so a migration can be planned without applying it. */
inspectSchema(): Promise<SchemaState>;
insertSpans(spans: SpanRecord[]): Promise<void>;
listTraces(options: ListOptions): Promise<TraceSummary[]>;
listSessions(options: ListOptions): Promise<SessionSummary[]>;
listRuns(sessionKey: string): Promise<RunSummary[]>;
listEnvironments(): Promise<string[]>;
costSummary(options: CostQueryOptions): Promise<CostSummary>;
stats(filter: TraceFilter): Promise<Stats>;
getTraceSpans(traceId: string): Promise<SpanRecord[]>;
getSpan(id: string): Promise<SpanRecord | null>;
/** Bounded delete of expired spans; returns rows deleted (may be < the backlog). */
deleteExpiredSpans(rules: RetentionRule[], limit: number): Promise<number>;
/**
* Atomically claim the sweep slot: true if this caller may sweep now
* (no other instance swept within intervalMs). DB-backed, pool-safe.
*/
claimSweep(now: number, intervalMs: number): Promise<boolean>;
listMcpKeys(): Promise<McpKeyRecord[]>;
insertMcpKey(record: McpKeyRecord & {
keyHash: string;
}): Promise<void>;
findMcpKeyByHash(keyHash: string): Promise<McpKeyRecord | null>;
/** Returns false when no key had that id (already revoked, or never existed). */
deleteMcpKey(id: string): Promise<boolean>;
touchMcpKey(id: string, at: number): Promise<void>;
/**
* The underlying driver handle (better-sqlite3 Database, pg.Pool, ...), so
* features needing direct SQL can reach it. The MCP server hands this to valv,
* which compiles and runs the agent's queries under its own policy.
*/
client(): unknown;
close?(): Promise<void>;
}
//#endregion
export { TraceSummary as S, SpanKind as _, DatabaseAdapter as a, Stats as b, ListTracesOptions as c, MigrationResult as d, Page as f, SessionSummary as g, SchemaState as h, CostSummary as i, McpKeyRecord as l, RunSummary as m, CostGroup as n, Dialect as o, RetentionRule as p, CostQueryOptions as r, ListOptions as s, CostDatum as t, MigrationPlan as u, SpanRecord as v, TraceFilter as x, SpanStatus as y };
+1
-1

@@ -1,2 +0,2 @@

import { a as DatabaseAdapter } from "../types-DIWvQ8Lw.mjs";
import { a as DatabaseAdapter } from "../types-BL3Zics_.mjs";
import DatabaseType from "better-sqlite3";

@@ -3,0 +3,0 @@ //#region src/adapters/sqlite.d.ts

@@ -1,2 +0,2 @@

import { C as statsSelect, S as spanToRow, T as traceSummarySelect, _ as rowToTraceSummary, a as META_TABLE, b as shapeCostSummary, c as clampLimit, d as keysetSql, g as rowToSpan, h as rowToSessionSummary, i as MCP_KEYS_TABLE, l as costByDaySelect, m as rowToRunSummary, n as planMigration, o as SPANS_TABLE, p as rowToMcpKey, s as spanColumns, u as costByFunctionSelect, v as runSummarySelect, w as traceFilterSql, x as shapeStats, y as sessionSummarySelect } from "../ddl-lDbkSGwU.mjs";
import { C as statsSelect, S as spanToRow, T as traceSummarySelect, _ as rowToTraceSummary, a as META_TABLE, b as shapeCostSummary, c as clampLimit, d as keysetSql, g as rowToSpan, h as rowToSessionSummary, i as MCP_KEYS_TABLE, l as costByDaySelect, m as rowToRunSummary, n as planMigration, o as SPANS_TABLE, p as rowToMcpKey, s as spanColumns, u as costByFunctionSelect, v as runSummarySelect, w as traceFilterSql, x as shapeStats, y as sessionSummarySelect } from "../ddl-C4JrQPLV.mjs";
import { createRequire } from "node:module";

@@ -3,0 +3,0 @@ //#region src/adapters/sqlite.ts

@@ -1,2 +0,2 @@

import { S as TraceSummary, _ as SpanKind, b as Stats, f as Page, g as SessionSummary, i as CostSummary, l as McpKeyRecord, m as RunSummary, n as CostGroup, s as ListOptions, t as CostDatum, v as SpanRecord, x as TraceFilter, y as SpanStatus } from "./types-DIWvQ8Lw.mjs";
import { S as TraceSummary, _ as SpanKind, b as Stats, f as Page, g as SessionSummary, i as CostSummary, l as McpKeyRecord, m as RunSummary, n as CostGroup, s as ListOptions, t as CostDatum, v as SpanRecord, x as TraceFilter, y as SpanStatus } from "./types-BL3Zics_.mjs";
//#region src/client.d.ts

@@ -3,0 +3,0 @@ interface BreadcrumbClientOptions {

@@ -1,3 +0,3 @@

import { S as TraceSummary, _ as SpanKind, a as DatabaseAdapter, b as Stats, c as ListTracesOptions, d as MigrationResult, f as Page, g as SessionSummary, h as SchemaState, i as CostSummary, m as RunSummary, n as CostGroup, o as Dialect, p as RetentionRule, r as CostQueryOptions, s as ListOptions, t as CostDatum, u as MigrationPlan, v as SpanRecord, x as TraceFilter, y as SpanStatus } from "./types-DIWvQ8Lw.mjs";
import { _ as TelemetrySettings, a as planMigration, c as SpanContext, d as AuthorizeFn, f as RetentionOptions, g as TelemetryOptions, h as PricingTable, i as EMPTY_SCHEMA_STATE, l as TraceAttrs, m as Pricing, n as BreadcrumbOptions, o as renderMigrationSql, p as ModelPrice, r as breadcrumb, s as SpanAttrs, t as Breadcrumb, u as TraceFn } from "./index-REnkvpTI.mjs";
import { S as TraceSummary, _ as SpanKind, a as DatabaseAdapter, b as Stats, c as ListTracesOptions, d as MigrationResult, f as Page, g as SessionSummary, h as SchemaState, i as CostSummary, m as RunSummary, n as CostGroup, o as Dialect, p as RetentionRule, r as CostQueryOptions, s as ListOptions, t as CostDatum, u as MigrationPlan, v as SpanRecord, x as TraceFilter, y as SpanStatus } from "./types-BL3Zics_.mjs";
import { _ as TelemetrySettings, a as planMigration, c as SpanContext, d as AuthorizeFn, f as RetentionOptions, g as TelemetryOptions, h as PricingTable, i as EMPTY_SCHEMA_STATE, l as TraceAttrs, m as Pricing, n as BreadcrumbOptions, o as renderMigrationSql, p as ModelPrice, r as breadcrumb, s as SpanAttrs, t as Breadcrumb, u as TraceFn } from "./index-Bl8Kc3Jd.mjs";
export { type AuthorizeFn, Breadcrumb, BreadcrumbOptions, type CostDatum, type CostGroup, type CostQueryOptions, type CostSummary, type DatabaseAdapter, type Dialect, EMPTY_SCHEMA_STATE, type ListOptions, type ListTracesOptions, type MigrationPlan, type MigrationResult, type ModelPrice, type Page, type Pricing, type PricingTable, type RetentionOptions, type RetentionRule, type RunSummary, type SchemaState, type SessionSummary, type SpanAttrs, type SpanContext, type SpanKind, type SpanRecord, type SpanStatus, type Stats, type TelemetryOptions, type TelemetrySettings, type TraceAttrs, type TraceFilter, type TraceFn, type TraceSummary, breadcrumb, planMigration, renderMigrationSql };

@@ -1,2 +0,2 @@

import { c as clampLimit, f as pageOf, n as planMigration, r as renderMigrationSql, t as EMPTY_SCHEMA_STATE } from "./ddl-lDbkSGwU.mjs";
import { c as clampLimit, f as pageOf, n as planMigration, r as renderMigrationSql, t as EMPTY_SCHEMA_STATE } from "./ddl-C4JrQPLV.mjs";
import { t as resolveMcpServerName } from "./config-BANN28mc.mjs";

@@ -130,6 +130,21 @@ import { SpanStatusCode, context } from "@opentelemetry/api";

}
/**
* Whether the functionId should stand in for this span's name. It should when
* the name came from the AI SDK's own operation (`ai.streamText`), which says
* nothing about the work; a span the caller named itself keeps that name.
*
* The SDK stamps `ai.telemetry.functionId` on every span of a call — the
* operation wrapper, the `.do*` model call and each `ai.toolCall` alike — so
* only the wrapper takes its name from it, or one call would read as three
* identical rows.
*/
function namedByFunctionId(attrs) {
const op = str(attrs["ai.operationId"]);
return op !== null && !op.includes(".do");
}
function normalizeSpanData(data, defaultEnvironment) {
const attrs = data.attributes;
const isError = data.error !== null;
const name = !data.parentSpanId ? str(attrs["ai.telemetry.functionId"]) ?? data.name : str(attrs["ai.toolCall.name"]) ?? str(attrs["gen_ai.tool.name"]) ?? data.name;
const functionId = first(str(attrs["breadcrumb.functionId"]), str(attrs["ai.telemetry.functionId"]));
const name = first(str(attrs["ai.toolCall.name"]), str(attrs["gen_ai.tool.name"])) ?? (namedByFunctionId(attrs) ? functionId : null) ?? data.name;
const kind = inferKind(data.name, attrs);

@@ -145,2 +160,3 @@ const input = first(parseMaybeJson(attrs["breadcrumb.input"]), parseMaybeJson(attrs["ai.prompt.messages"]), parseMaybeJson(attrs["ai.prompt"]), parseMaybeJson(attrs["ai.toolCall.args"]), parseMaybeJson(attrs["gen_ai.input.messages"]));

name,
functionId,
kind,

@@ -220,7 +236,11 @@ environment: data.environment ?? defaultEnvironment,

tracer,
telemetry(options = {}) {
telemetry({ userId, sessionId, metadata, ...rest } = {}) {
const merged = { ...metadata };
if (userId !== void 0) merged.userId = userId;
if (sessionId !== void 0) merged.sessionId = sessionId;
return {
isEnabled: true,
tracer,
...options
...rest,
...Object.keys(merged).length > 0 ? { metadata: merged } : {}
};

@@ -512,2 +532,3 @@ },

name: raw.name,
functionId: typeof raw.functionId === "string" ? raw.functionId : null,
kind: typeof raw.kind === "string" ? raw.kind : "span",

@@ -664,2 +685,3 @@ environment: typeof raw.environment === "string" ? raw.environment : environment,

function applySpanAttrs(span, attrs) {
if (attrs.functionId !== void 0) span.setAttribute("breadcrumb.functionId", attrs.functionId);
if (attrs.kind !== void 0) span.setAttribute("breadcrumb.kind", attrs.kind);

@@ -679,2 +701,3 @@ if (attrs.model !== void 0) span.setAttribute("breadcrumb.model", attrs.model);

function applyTraceAttrs(span, attrs) {
if (attrs.functionId !== void 0) span.setAttribute("breadcrumb.functionId", attrs.functionId);
if (attrs.userId !== void 0) span.setAttribute("breadcrumb.userId", attrs.userId);

@@ -792,3 +815,3 @@ if (attrs.sessionId !== void 0) span.setAttribute("breadcrumb.sessionId", attrs.sessionId);

const mcpHandler = () => mcpHandlerPromise ??= (async () => {
const { createTraceValv, createMcpHandler } = await import("./server-CswVhz-p.mjs");
const { createTraceValv, createMcpHandler } = await import("./server-B_Jj9Yg3.mjs");
await ready();

@@ -795,0 +818,0 @@ return createMcpHandler(await createTraceValv(adapter, mcpOptions), mcpOptions);

@@ -1,2 +0,2 @@

import { v as SpanRecord } from "../types-DIWvQ8Lw.mjs";
import { v as SpanRecord } from "../types-BL3Zics_.mjs";
//#region src/kit/tree.d.ts

@@ -88,4 +88,7 @@ /**

interface TraceModel {
/** The run's first root. A trace whose parent spans never arrived can have
* several — `roots` holds them all, and every one is rendered. */
root: SpanRecord | null;
/** The root's extent, floored at 1 so it is always safe to divide by. */
roots: SpanRecord[];
/** The run's extent, floored at 1 so it is always safe to divide by. */
total: number;

@@ -92,0 +95,0 @@ /** Biggest self time below the root — the scale heat is measured against. */

@@ -29,2 +29,33 @@ //#region src/kit/tree.ts

/**
* Children by parent id, with spans whose parent never arrived keyed under
* null. A trace can hold several of those: another exporter owns the span above
* them, or it was sampled away, and what reaches breadcrumb is a forest rather
* than a tree. Every one of them is a root, so none of the run goes unrendered.
*/
function groupByParent(spans) {
const ids = new Set(spans.map((s) => s.id));
const byParent = /* @__PURE__ */ new Map();
for (const s of spans) {
const key = s.parentSpanId && ids.has(s.parentSpanId) ? s.parentSpanId : null;
byParent.get(key)?.push(s) ?? byParent.set(key, [s]);
}
for (const list of byParent.values()) list.sort((a, b) => a.startTime - b.startTime);
byParent.get(null)?.sort((a, b) => Number(!!a.parentSpanId) - Number(!!b.parentSpanId));
return byParent;
}
/** The spans no parent in this trace accounts for: the run's root, or roots. */
function rootSpans(spans) {
return groupByParent(spans).get(null) ?? [];
}
/** Wall-clock extent of the whole run, floored at 1 so it is safe to divide by. */
function traceExtent(spans) {
let start = Infinity;
let end = -Infinity;
for (const s of spans) {
start = Math.min(start, s.startTime);
end = Math.max(end, s.endTime ?? s.startTime);
}
return end > start ? end - start : 1;
}
/**
* Denoised view of a trace. Instrumentation wraps every model call in a

@@ -40,12 +71,6 @@ * pass-through span (`ai.streamText` around `ai.streamText.doStream`), which

function flowRows(spans) {
const ids = new Set(spans.map((s) => s.id));
const byParent = /* @__PURE__ */ new Map();
for (const s of spans) {
const key = s.parentSpanId && ids.has(s.parentSpanId) ? s.parentSpanId : null;
byParent.get(key)?.push(s) ?? byParent.set(key, [s]);
}
for (const list of byParent.values()) list.sort((a, b) => a.startTime - b.startTime);
const root = (byParent.get(null) ?? [])[0];
if (!root) return [];
const total = (root.endTime ?? root.startTime) - root.startTime || 1;
const byParent = groupByParent(spans);
const roots = byParent.get(null) ?? [];
if (roots.length === 0) return [];
const total = traceExtent(spans);
const kids = (s) => byParent.get(s.id) ?? [];

@@ -60,8 +85,3 @@ const extent = (s) => (s.endTime ?? s.startTime) - s.startTime;

const isMinor = (s) => kids(s).length === 0 && s.kind === "span" && !load(s) && selfTime(s, []) < total * .01;
const out = [{
type: "span",
span: root,
depth: 0,
children: kids(root)
}];
const out = [];
const walk = (parent, depth) => {

@@ -91,3 +111,11 @@ const minor = [];

};
walk(root, 1);
for (const root of roots) {
out.push({
type: "span",
span: root,
depth: 0,
children: kids(root)
});
walk(root, 1);
}
return out;

@@ -97,9 +125,3 @@ }

function fullRows(spans) {
const ids = new Set(spans.map((s) => s.id));
const byParent = /* @__PURE__ */ new Map();
for (const s of spans) {
const key = s.parentSpanId && ids.has(s.parentSpanId) ? s.parentSpanId : null;
byParent.get(key)?.push(s) ?? byParent.set(key, [s]);
}
for (const list of byParent.values()) list.sort((a, b) => a.startTime - b.startTime);
const byParent = groupByParent(spans);
const out = [];

@@ -122,7 +144,3 @@ const walk = (parent, depth) => {

const ids = new Set(spans.map((s) => s.id));
const byParent = /* @__PURE__ */ new Map();
for (const s of spans) {
const key = s.parentSpanId && ids.has(s.parentSpanId) ? s.parentSpanId : null;
byParent.get(key)?.push(s) ?? byParent.set(key, [s]);
}
const byParent = groupByParent(spans);
const nonRoot = spans.filter((s) => s.parentSpanId && ids.has(s.parentSpanId));

@@ -233,8 +251,10 @@ let slowest = null;

const kidsOf = (id) => childrenById.get(id) ?? [];
const root = spans.find((s) => !s.parentSpanId) ?? spans[0] ?? null;
const total = root ? extent(root) || 1 : 1;
const roots = rootSpans(spans);
const root = roots[0] ?? null;
const total = spans.length > 0 ? traceExtent(spans) : 1;
const spots = spans.length > 0 ? hotspots(spans) : null;
const rootIds = new Set(roots.map((s) => s.id));
let maxSelf = 0;
for (const s of spans) {
if (!s.parentSpanId) continue;
if (rootIds.has(s.id)) continue;
maxSelf = Math.max(maxSelf, selfTime(s, kidsOf(s.id)));

@@ -291,2 +311,3 @@ }

root,
roots,
total,

@@ -293,0 +314,0 @@ maxSelf,

@@ -1,2 +0,2 @@

import { t as Breadcrumb } from "./index-REnkvpTI.mjs";
import { t as Breadcrumb } from "./index-Bl8Kc3Jd.mjs";
//#region src/next.d.ts

@@ -3,0 +3,0 @@ type Handler = (request: Request) => Promise<Response>;

@@ -1,2 +0,2 @@

import { t as Breadcrumb } from "./index-REnkvpTI.mjs";
import { t as Breadcrumb } from "./index-Bl8Kc3Jd.mjs";
import { IncomingMessage, ServerResponse } from "node:http";

@@ -3,0 +3,0 @@ //#region src/node.d.ts

{
"name": "@breadcrumb-sh/core",
"version": "0.0.1",
"version": "0.1.0",
"description": "Embeddable LLM tracing for TypeScript apps. Your database, your deployment, your UI.",

@@ -5,0 +5,0 @@ "license": "MIT",

@@ -71,6 +71,9 @@ # @breadcrumb-sh/core

prompt,
experimental_telemetry: bc.telemetry({ functionId: "generate-answer" }),
experimental_telemetry: bc.telemetry({ functionId: "generate-answer", userId, sessionId }),
});
```
`functionId` names the call and carries the cost attribution, wherever the call
sits in the trace; `userId` and `sessionId` group runs by user and conversation.
**Manual tracing.** `bc.trace(name, attrs?, fn)`, with nested `t.span(...)`:

@@ -77,0 +80,0 @@

//#region src/db/rows.ts
/** Page size, defaulted and bounded so a caller can't request the whole table. */
function clampLimit(limit) {
return Math.min(Math.max(limit ?? 50, 1), 500);
}
/**
* Trace-selecting predicate for the WHERE clause, ANDing one `trace_id IN (…)`
* subquery per active filter. Kept as subqueries (not a flat row WHERE) because
* dimensions live on different spans of a trace — the root carries userId, a
* child carries model — so a single row rarely satisfies two at once. Returns
* "" when no filter is set.
*/
function traceFilterSql(table, filter, ph) {
const preds = [];
const scope = [];
if (filter.environment !== void 0) scope.push(`environment = ${ph(filter.environment)}`);
if (filter.since !== void 0) scope.push(`start_time >= ${ph(filter.since)}`);
if (filter.until !== void 0) scope.push(`start_time <= ${ph(filter.until)}`);
if (scope.length) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE ${scope.join(" AND ")})`);
if (filter.userId !== void 0) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE user_id = ${ph(filter.userId)})`);
if (filter.model !== void 0) preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE model = ${ph(filter.model)})`);
if (filter.status === "error") preds.push(`trace_id IN (SELECT trace_id FROM ${table} WHERE status = 'error')`);
if (filter.status === "ok") preds.push(`trace_id NOT IN (SELECT trace_id FROM ${table} WHERE status = 'error')`);
return preds.join(" AND ");
}
/** Keyset predicate for "rows after `cursor`" given a DESC (sortExpr, keyExpr). */
function keysetSql(sortExpr, keyExpr, cursor, ph) {
const c = decodeCursor(cursor);
if (!c) return "";
return `(${sortExpr} < ${ph(c.sort)} OR (${sortExpr} = ${ph(c.sort)} AND ${keyExpr} < ${ph(c.key)}))`;
}
const CURSOR_SEP = "|";
function encodeCursor(sort, key) {
return `${sort}${CURSOR_SEP}${key}`;
}
function decodeCursor(cursor) {
const i = cursor.indexOf(CURSOR_SEP);
if (i < 0) return null;
const sort = Number(cursor.slice(0, i));
const key = cursor.slice(i + 1);
return Number.isFinite(sort) && key ? {
sort,
key
} : null;
}
/** Wrap a page of rows with its next cursor (null when the page wasn't full). */
function pageOf(items, limit, sortOf, keyOf) {
const last = items[items.length - 1];
return {
items,
nextCursor: items.length >= limit && last ? encodeCursor(sortOf(last), keyOf(last)) : null
};
}
/** Span -> DB row. JSON payloads are stringified (works for TEXT and JSONB). */
function spanToRow(span) {
return {
id: span.id,
trace_id: span.traceId,
parent_span_id: span.parentSpanId ?? null,
name: span.name,
kind: span.kind,
environment: span.environment,
user_id: span.userId ?? null,
session_id: span.sessionId ?? null,
model: span.model ?? null,
provider: span.provider ?? null,
input_tokens: span.inputTokens ?? null,
output_tokens: span.outputTokens ?? null,
cached_input_tokens: span.cachedInputTokens ?? null,
cache_write_tokens: span.cacheWriteTokens ?? null,
reasoning_tokens: span.reasoningTokens ?? null,
cost: span.cost ?? null,
status: span.status,
error: span.error ?? null,
input: span.input === void 0 ? null : JSON.stringify(span.input),
output: span.output === void 0 ? null : JSON.stringify(span.output),
metadata: span.metadata == null ? null : JSON.stringify(span.metadata),
start_time: span.startTime,
end_time: span.endTime ?? null
};
}
/** TEXT columns hold JSON strings, JSONB comes back pre-parsed — accept both. */
function jsonValue(value) {
if (value == null) return null;
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** BIGINT columns come back as strings from node-postgres — coerce. */
function numValue(value) {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function rowToSpan(row) {
return {
id: row.id,
traceId: row.trace_id,
parentSpanId: row.parent_span_id ?? null,
name: row.name,
kind: row.kind,
environment: row.environment,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
model: row.model ?? null,
provider: row.provider ?? null,
inputTokens: numValue(row.input_tokens),
outputTokens: numValue(row.output_tokens),
cachedInputTokens: numValue(row.cached_input_tokens),
cacheWriteTokens: numValue(row.cache_write_tokens),
reasoningTokens: numValue(row.reasoning_tokens),
cost: numValue(row.cost),
status: row.status,
error: row.error ?? null,
input: jsonValue(row.input) ?? void 0,
output: jsonValue(row.output) ?? void 0,
metadata: jsonValue(row.metadata),
startTime: numValue(row.start_time),
endTime: numValue(row.end_time)
};
}
function rowToTraceSummary(row) {
return {
traceId: row.trace_id,
name: row.name,
environment: row.environment,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
spanCount: numValue(row.span_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
function rowToSessionSummary(row) {
return {
sessionKey: row.session_key,
sessionId: row.session_id ?? null,
userId: row.user_id ?? null,
environment: row.environment,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
runCount: numValue(row.run_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
failName: row.fail_name ?? null,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
function rowToRunSummary(row) {
return {
traceId: row.trace_id,
name: row.name,
input: jsonValue(row.input) ?? void 0,
output: jsonValue(row.output) ?? void 0,
startTime: numValue(row.start_time),
endTime: numValue(row.end_time),
spanCount: numValue(row.span_count) ?? 0,
errorCount: numValue(row.error_count) ?? 0,
failName: row.fail_name ?? null,
failError: row.fail_error ?? null,
inputTokens: numValue(row.input_tokens) ?? 0,
outputTokens: numValue(row.output_tokens) ?? 0,
cost: numValue(row.cost)
};
}
/**
* Session aggregation. A trace's session is derived first (only the root span
* reliably carries session_id — AI SDK child spans don't), then traces group
* into sessions; sessionless traces stand alone keyed by trace_id. `whereSql`
* filters which traces feed the rollup; `havingSql` is the outer keyset.
* Ordered by last activity (MAX end_time) so the cursor key sits in the row.
*/
function sessionSummarySelect(table, whereSql, havingSql) {
return `SELECT
COALESCE(t.session_id, t.trace_id) AS session_key,
MAX(t.session_id) AS session_id,
MAX(t.user_id) AS user_id,
MIN(t.environment) AS environment,
MIN(t.start_time) AS start_time,
MAX(t.end_time) AS end_time,
COUNT(*) AS run_count,
SUM(t.error_count) AS error_count,
MAX(t.fail_name) AS fail_name,
SUM(t.input_tokens) AS input_tokens,
SUM(t.output_tokens) AS output_tokens,
SUM(t.cost) AS cost
FROM (
SELECT trace_id,
MAX(session_id) AS session_id,
MAX(user_id) AS user_id,
MIN(environment) AS environment,
MIN(start_time) AS start_time,
MAX(COALESCE(end_time, start_time)) AS end_time,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
MAX(CASE WHEN status = 'error' THEN name END) AS fail_name,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${table}
${whereSql ? `WHERE ${whereSql}` : ""}
GROUP BY trace_id
) t
GROUP BY COALESCE(t.session_id, t.trace_id)
${havingSql ? `HAVING ${havingSql}` : ""}
ORDER BY MAX(t.end_time) DESC, COALESCE(t.session_id, t.trace_id) DESC`;
}
/** Headline stats over the filtered trace set: one row per trace, then rolled up. */
function statsSelect(table, whereSql) {
return `SELECT
COUNT(*) AS runs,
SUM(has_error) AS errors,
SUM(cost) AS cost,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
AVG(duration) AS avg_latency,
MAX(duration) AS max_latency
FROM (
SELECT trace_id,
MAX(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS has_error,
SUM(COALESCE(cost, 0)) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
MAX(end_time) - MIN(start_time) AS duration
FROM ${table}
${whereSql ? `WHERE ${whereSql}` : ""}
GROUP BY trace_id
) t`;
}
function shapeStats(row) {
const runs = numValue(row?.runs) ?? 0;
const errors = numValue(row?.errors) ?? 0;
const avg = numValue(row?.avg_latency);
return {
runs,
errors,
errorRate: runs > 0 ? errors / runs : 0,
cost: numValue(row?.cost) ?? 0,
inputTokens: numValue(row?.input_tokens) ?? 0,
outputTokens: numValue(row?.output_tokens) ?? 0,
avgLatencyMs: avg == null ? null : Math.round(avg),
maxLatencyMs: numValue(row?.max_latency)
};
}
function runSummarySelect(table, keyFilter, castText) {
return `SELECT
trace_id,
COALESCE(MAX(CASE WHEN parent_span_id IS NULL THEN name END), MIN(name)) AS name,
MAX(CASE WHEN parent_span_id IS NULL THEN input${castText} END) AS input,
MAX(CASE WHEN parent_span_id IS NULL THEN output${castText} END) AS output,
MIN(start_time) AS start_time,
MAX(end_time) AS end_time,
COUNT(*) AS span_count,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
MAX(CASE WHEN status = 'error' THEN name END) AS fail_name,
MAX(CASE WHEN status = 'error' THEN error END) AS fail_error,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${table}
WHERE trace_id IN (
SELECT trace_id FROM ${table}
GROUP BY trace_id
HAVING COALESCE(MAX(session_id), trace_id) = ${keyFilter}
)
GROUP BY trace_id
ORDER BY MIN(start_time) ASC`;
}
/**
* Cost time series bucketed by day + model. `dayExpr` is the dialect-specific
* expression turning start_time (epoch ms) into a UTC 'YYYY-MM-DD' string;
* `filter` carries the cutoff/environment predicates (leading AND).
*/
function costByDaySelect(table, dayExpr, filter) {
return `SELECT ${dayExpr} AS day, model,
SUM(cost) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(cached_input_tokens, 0)) AS cached_input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
COUNT(*) AS count
FROM ${table}
WHERE cost IS NOT NULL ${filter}
GROUP BY day, model
ORDER BY day ASC`;
}
/** Cost attributed to each run's root-span name (the "function"). */
function costByFunctionSelect(table, filter) {
return `SELECT root_name AS key,
SUM(cost) AS cost,
SUM(input_tokens) AS input_tokens,
SUM(cached_input_tokens) AS cached_input_tokens,
SUM(output_tokens) AS output_tokens,
COUNT(*) AS count
FROM (
SELECT trace_id,
MAX(CASE WHEN parent_span_id IS NULL THEN name END) AS root_name,
SUM(COALESCE(cost, 0)) AS cost,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(cached_input_tokens, 0)) AS cached_input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens
FROM ${table}
WHERE 1 = 1 ${filter}
GROUP BY trace_id
) t
GROUP BY root_name
ORDER BY cost DESC`;
}
function shapeCostSummary(windowDays, dayRows, funcRows) {
const days = dayRows.map((r) => ({
day: r.day,
model: r.model ?? null,
cost: numValue(r.cost) ?? 0,
inputTokens: numValue(r.input_tokens) ?? 0,
cachedInputTokens: numValue(r.cached_input_tokens) ?? 0,
outputTokens: numValue(r.output_tokens) ?? 0,
count: numValue(r.count) ?? 0
}));
const totals = days.reduce((a, d) => ({
cost: a.cost + d.cost,
inputTokens: a.inputTokens + d.inputTokens,
cachedInputTokens: a.cachedInputTokens + d.cachedInputTokens,
outputTokens: a.outputTokens + d.outputTokens
}), {
cost: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0
});
const modelMap = /* @__PURE__ */ new Map();
for (const d of days) {
const g = modelMap.get(d.model) ?? {
key: d.model,
cost: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
count: 0
};
g.cost += d.cost;
g.inputTokens += d.inputTokens;
g.cachedInputTokens += d.cachedInputTokens;
g.outputTokens += d.outputTokens;
g.count += d.count;
modelMap.set(d.model, g);
}
return {
windowDays,
totals,
days,
byModel: [...modelMap.values()].sort((a, b) => b.cost - a.cost),
byFunction: funcRows.map((r) => ({
key: r.key ?? null,
cost: numValue(r.cost) ?? 0,
inputTokens: numValue(r.input_tokens) ?? 0,
cachedInputTokens: numValue(r.cached_input_tokens) ?? 0,
outputTokens: numValue(r.output_tokens) ?? 0,
count: numValue(r.count) ?? 0
})).filter((g) => g.cost > 0).sort((a, b) => b.cost - a.cost)
};
}
/**
* Shared trace aggregation. `whereSql` selects which traces to include (from
* traceFilterSql); `havingSql` is the keyset predicate. Adapters append LIMIT.
*/
function traceSummarySelect(table, whereSql, havingSql) {
return `SELECT
trace_id,
COALESCE(MAX(CASE WHEN parent_span_id IS NULL THEN name END), MIN(name)) AS name,
MIN(environment) AS environment,
MAX(user_id) AS user_id,
MAX(session_id) AS session_id,
MIN(start_time) AS start_time,
MAX(end_time) AS end_time,
COUNT(*) AS span_count,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count,
SUM(COALESCE(input_tokens, 0)) AS input_tokens,
SUM(COALESCE(output_tokens, 0)) AS output_tokens,
SUM(cost) AS cost
FROM ${table}
${whereSql ? `WHERE ${whereSql}` : ""}
GROUP BY trace_id
${havingSql ? `HAVING ${havingSql}` : ""}
ORDER BY MIN(start_time) DESC, trace_id DESC`;
}
function rowToMcpKey(row) {
return {
id: row.id,
name: row.name,
keyPrefix: row.key_prefix,
createdAt: Number(row.created_at),
lastUsedAt: row.last_used_at === null ? null : Number(row.last_used_at)
};
}
//#endregion
//#region src/db/schema.ts
/**
* Single source of truth for breadcrumb's tables. Adapters generate their
* DDL from this; the CLI's migrate/generate will diff against it.
* Tables live in the user's database, so everything is prefixed.
*/
const SPANS_TABLE = "breadcrumb_spans";
const spanColumns = {
id: {
type: "text",
primary: true
},
trace_id: { type: "text" },
parent_span_id: {
type: "text",
nullable: true
},
name: { type: "text" },
kind: { type: "text" },
environment: { type: "text" },
user_id: {
type: "text",
nullable: true
},
session_id: {
type: "text",
nullable: true
},
model: {
type: "text",
nullable: true
},
provider: {
type: "text",
nullable: true
},
input_tokens: {
type: "integer",
nullable: true
},
output_tokens: {
type: "integer",
nullable: true
},
cached_input_tokens: {
type: "integer",
nullable: true
},
cache_write_tokens: {
type: "integer",
nullable: true
},
reasoning_tokens: {
type: "integer",
nullable: true
},
cost: {
type: "real",
nullable: true
},
status: { type: "text" },
error: {
type: "text",
nullable: true
},
input: {
type: "json",
nullable: true
},
output: {
type: "json",
nullable: true
},
metadata: {
type: "json",
nullable: true
},
start_time: { type: "integer" },
end_time: {
type: "integer",
nullable: true
}
};
const spanIndexes = [
{
name: "breadcrumb_spans_trace_id",
columns: ["trace_id"]
},
{
name: "breadcrumb_spans_env_start",
columns: ["environment", "start_time"]
},
{
name: "breadcrumb_spans_user_id",
columns: ["user_id", "trace_id"]
},
{
name: "breadcrumb_spans_model",
columns: ["model", "trace_id"]
},
{
name: "breadcrumb_spans_status",
columns: ["status", "trace_id"]
}
];
/** Tiny key/value table for cross-instance coordination (sweep claims). */
const META_TABLE = "breadcrumb_meta";
const metaColumns = {
key: {
type: "text",
primary: true
},
value: { type: "integer" }
};
/**
* Keys that let a coding agent read traces over MCP. Created from the dashboard
* (so whoever can already see traces can mint one) and presented as a bearer
* token. Only the SHA-256 hash is stored — the token itself is shown once at
* creation and is unrecoverable afterwards, so a database leak yields nothing
* replayable. `key_prefix` exists purely so the UI can tell two keys apart.
*/
const MCP_KEYS_TABLE = "breadcrumb_mcp_keys";
const mcpKeyColumns = {
id: {
type: "text",
primary: true
},
name: { type: "text" },
key_hash: { type: "text" },
key_prefix: { type: "text" },
created_at: { type: "integer" },
last_used_at: {
type: "integer",
nullable: true
}
};
const mcpKeyIndexes = [{
name: "breadcrumb_mcp_keys_hash",
columns: ["key_hash"],
unique: true
}];
//#endregion
//#region src/db/ddl.ts
const TYPE_MAP = {
postgres: {
text: "TEXT",
integer: "BIGINT",
real: "DOUBLE PRECISION",
json: "JSONB"
},
sqlite: {
text: "TEXT",
integer: "INTEGER",
real: "REAL",
json: "TEXT"
}
};
function columnDdl(dialect, name, spec) {
const parts = [name, TYPE_MAP[dialect][spec.type]];
if (spec.primary) parts.push("PRIMARY KEY");
if (!spec.nullable && !spec.primary) parts.push("NOT NULL");
return parts.join(" ");
}
function createTableSql(dialect, table, columns) {
return `CREATE TABLE IF NOT EXISTS ${table} (${Object.entries(columns).map(([name, spec]) => columnDdl(dialect, name, spec)).join(", ")})`;
}
function addColumnSql(dialect, table, name, spec) {
return `ALTER TABLE ${table} ADD COLUMN ${dialect === "postgres" ? "IF NOT EXISTS " : ""}${name} ${TYPE_MAP[dialect][spec.type]}`;
}
function createIndexSql(table, idx) {
return `CREATE ${idx.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${idx.name} ON ${table} (${idx.columns.join(", ")})`;
}
/**
* Diff the current schema against a live database's state and return the DDL to
* reconcile it. The single source of truth for both `migrate()` (which executes
* the statements) and `generate` (which writes them to a file).
*/
function planMigration(dialect, state) {
const statements = [];
const createdTables = [];
const addedColumns = [];
if (!state.spansExists) {
statements.push(createTableSql(dialect, SPANS_TABLE, spanColumns));
createdTables.push(SPANS_TABLE);
} else for (const [name, spec] of Object.entries(spanColumns)) {
if (state.spansColumns.has(name)) continue;
statements.push(addColumnSql(dialect, SPANS_TABLE, name, spec));
addedColumns.push(`${SPANS_TABLE}.${name}`);
}
for (const idx of spanIndexes) if (!state.indexNames.has(idx.name)) statements.push(createIndexSql(SPANS_TABLE, idx));
if (!state.metaExists) {
statements.push(createTableSql(dialect, META_TABLE, metaColumns));
createdTables.push(META_TABLE);
}
if (!state.mcpKeysExists) {
statements.push(createTableSql(dialect, MCP_KEYS_TABLE, mcpKeyColumns));
createdTables.push(MCP_KEYS_TABLE);
}
for (const idx of mcpKeyIndexes) if (!state.indexNames.has(idx.name)) statements.push(createIndexSql(MCP_KEYS_TABLE, idx));
return {
statements,
createdTables,
addedColumns
};
}
/** The empty state — plans a full, fresh schema without a database connection. */
const EMPTY_SCHEMA_STATE = {
spansExists: false,
spansColumns: /* @__PURE__ */ new Set(),
indexNames: /* @__PURE__ */ new Set(),
metaExists: false,
mcpKeysExists: false
};
/** Render a plan as a reviewable `.sql` migration file. */
function renderMigrationSql(dialect, plan, generatedAt) {
return [
`-- Generated by \`breadcrumb generate\` at ${generatedAt}`,
`-- Dialect: ${dialect}`,
"-- Additive-only. Review, commit, and apply with your migration tooling.",
""
].join("\n") + plan.statements.map((s) => `${s};`).join("\n\n") + "\n";
}
//#endregion
export { statsSelect as C, spanToRow as S, traceSummarySelect as T, rowToTraceSummary as _, META_TABLE as a, shapeCostSummary as b, clampLimit as c, keysetSql as d, pageOf as f, rowToSpan as g, rowToSessionSummary as h, MCP_KEYS_TABLE as i, costByDaySelect as l, rowToRunSummary as m, planMigration as n, SPANS_TABLE as o, rowToMcpKey as p, renderMigrationSql as r, spanColumns as s, EMPTY_SCHEMA_STATE as t, costByFunctionSelect as u, runSummarySelect as v, traceFilterSql as w, shapeStats as x, sessionSummarySelect as y };
import { S as TraceSummary, _ as SpanKind, a as DatabaseAdapter, b as Stats, f as Page, g as SessionSummary, h as SchemaState, i as CostSummary, m as RunSummary, o as Dialect, r as CostQueryOptions, s as ListOptions, u as MigrationPlan, v as SpanRecord, x as TraceFilter } from "./types-DIWvQ8Lw.mjs";
import { Tracer } from "@opentelemetry/api";
//#region src/otel/pipeline.d.ts
/** Matches the AI SDK's telemetry metadata constraint (OTel AttributeValue). */
type TelemetryMetadataValue = string | number | boolean | string[] | number[] | boolean[];
interface TelemetryOptions {
functionId?: string;
metadata?: Record<string, TelemetryMetadataValue>;
recordInputs?: boolean;
recordOutputs?: boolean;
}
/** The settings object the Vercel AI SDK expects for experimental_telemetry. */
interface TelemetrySettings extends TelemetryOptions {
isEnabled: true;
tracer: Tracer;
}
//#endregion
//#region src/pricing.d.ts
/** USD per 1M tokens, as declared by your app. breadcrumb ships no prices. */
interface ModelPrice {
input: number;
output: number;
/** Price per 1M cache-read tokens. Defaults to `input` (no discount assumed). */
cachedInput?: number;
/** Price per 1M cache-write tokens. Defaults to `input` (no premium assumed). */
cacheWrite?: number;
}
/**
* Your model prices, keyed by model name. Keys match as lowercase substrings
* of the span's model, longest key wins (so "claude-opus" beats "claude").
*/
type PricingTable = Record<string, ModelPrice>;
type Pricing = PricingTable | false;
//#endregion
//#region src/retention.d.ts
interface RetentionOptions {
/** Window for environments without an explicit entry. Default: "90d". */
default?: string;
/** Per-environment overrides, e.g. { development: "7d" }. */
environments?: Record<string, string>;
/** "auto" (default): bounded sweeps piggyback on ingest. "manual": only bc.api.runRetention(). */
sweep?: "auto" | "manual";
}
//#endregion
//#region src/mcp/config.d.ts
interface McpOptions {
/**
* What MCP clients register this server as. Defaults to "breadcrumb", or
* "breadcrumb-local" when reached over a loopback address, so a developer can
* connect their local and deployed instances at once without the second
* `mcp add` overwriting the first. Set it explicitly when you run more than
* two, e.g. "breadcrumb-staging".
*/
name?: string;
/**
* Hide the captured prompt/completion payloads from the agent. Everything else
* — timings, tokens, cost, model, status, errors — stays queryable, so an
* agent can still diagnose a failing run without reading user content.
*/
hidePayloads?: boolean;
}
//#endregion
//#region src/router.d.ts
type AuthorizeFn = (request: Request) => boolean | Response | undefined | null | Promise<boolean | Response | undefined | null>;
//#endregion
//#region src/trace.d.ts
interface SpanAttrs {
kind?: SpanKind;
model?: string;
provider?: string;
inputTokens?: number;
outputTokens?: number;
/** Cache-read tokens (subset of inputTokens). */
cachedInputTokens?: number;
/** Cache-write tokens (subset of inputTokens). */
cacheWriteTokens?: number;
/** Reasoning/thinking tokens (subset of outputTokens). */
reasoningTokens?: number;
cost?: number;
input?: unknown;
output?: unknown;
metadata?: Record<string, unknown>;
}
interface TraceAttrs {
userId?: string;
sessionId?: string;
metadata?: Record<string, unknown>;
}
interface SpanContext {
/** Attach/override attributes on the current span (tokens, model, io, ...). */
set(attrs: SpanAttrs): void;
/** Run a nested child span. A thrown error marks the span failed and rethrows. */
span<T>(name: string, fn: (s: SpanContext) => T | Promise<T>): Promise<T>;
span<T>(name: string, attrs: SpanAttrs, fn: (s: SpanContext) => T | Promise<T>): Promise<T>;
}
type TraceFn = {
<T>(name: string, fn: (t: SpanContext) => T | Promise<T>): Promise<T>;
<T>(name: string, attrs: TraceAttrs, fn: (t: SpanContext) => T | Promise<T>): Promise<T>;
};
//#endregion
//#region src/db/ddl.d.ts
/**
* Diff the current schema against a live database's state and return the DDL to
* reconcile it. The single source of truth for both `migrate()` (which executes
* the statements) and `generate` (which writes them to a file).
*/
declare function planMigration(dialect: Dialect, state: SchemaState): MigrationPlan;
/** The empty state — plans a full, fresh schema without a database connection. */
declare const EMPTY_SCHEMA_STATE: SchemaState;
/** Render a plan as a reviewable `.sql` migration file. */
declare function renderMigrationSql(dialect: Dialect, plan: MigrationPlan, generatedAt: string): string;
//#endregion
//#region src/index.d.ts
interface BreadcrumbOptions {
/** Database adapter, e.g. sqlite(".breadcrumb/dev.db") from @breadcrumb-sh/core/adapters */
database: DatabaseAdapter;
/** Where the handler is mounted, e.g. "/admin/traces". Default: "/breadcrumb" */
basePath?: string;
/** Stamped on every span. Default: VERCEL_ENV ?? NODE_ENV ?? "development" */
environment?: string;
/**
* Enables the HTTP ingest endpoints for external services (OTLP later).
* Omit entirely if only this app writes traces — the endpoints then 404.
*/
ingest?: {
apiKey: string;
};
/**
* Retention windows per environment. Defaults: 90d, development 7d.
* Sweeps are bounded and piggyback on ingest — no cron needed.
*/
retention?: RetentionOptions;
/**
* Guards the UI/query routes (ingest routes use the API key instead).
* Return true to allow, false to 401, or a Response (e.g. a redirect).
* Alternative to wrapping the mount in middleware.
*/
authorize?: AuthorizeFn;
/**
* Your model prices (USD per 1M tokens), keyed by model name. When a span
* has tokens + model but no explicit cost, breadcrumb computes it from these.
* Omit to store only costs you set yourself — breadcrumb assumes no prices.
*/
pricing?: Pricing;
/**
* Scrub PII or trim payloads before storage. Runs on every span from every
* path (manual, AI SDK, external ingest) just before it's written. Mutate the
* span in place, or return a replacement.
*/
redact?: (span: SpanRecord) => SpanRecord | void;
/**
* Max characters kept for a span's captured input/output; longer payloads are
* truncated with a marker so one call can't write megabytes. Default 16384;
* set 0 to disable.
*/
maxPayloadChars?: number;
/**
* "batch" (default): buffer spans and export every ~2s — best for a
* long-running server. "sync": export each span as it ends, for serverless or
* edge where a final flush isn't guaranteed. Either way, `await bc.flush()`
* (or `waitUntil(bc.flush())`) before a serverless function returns.
*/
flushMode?: "batch" | "sync";
/**
* "auto" (default): create/upgrade the schema on first use — great for local
* development. "manual": never run DDL at runtime; you own migrations, applied
* with `breadcrumb migrate` or `breadcrumb generate` plus your tooling.
*/
migrations?: "auto" | "manual";
/**
* Tunes the MCP endpoint at `basePath + /api/mcp`, where a coding agent reads
* your traces. Not a switch: the endpoint is always mounted, but it is
* unreachable until someone mints a key from the dashboard's MCP tab, which
* `authorize` already guards. The agent gets policy-gated read access to the
* span table only — no other table in your database, and no writes.
*/
mcp?: McpOptions;
}
interface Breadcrumb {
/** Fetch-native handler: mount it, wrapped in your own auth. */
handler: (request: Request) => Promise<Response>;
/** Manual tracing: bc.trace("name", { userId }, async (t) => { ... t.span(...) }) */
trace: TraceFn;
/** Preconfigured experimental_telemetry settings for the Vercel AI SDK. */
telemetry: (options?: TelemetryOptions) => TelemetrySettings;
/** Flush buffered spans (serverless: call before the runtime freezes). */
flush: () => Promise<void>;
/**
* Programmatic queries + ingest, callable server-side without HTTP — the
* headless surface for building a custom admin UI over your own data.
*/
api: {
/** Traces (one per run), newest first, filtered + keyset-paginated. */
listTraces(options?: ListOptions): Promise<Page<TraceSummary>>;
/** Sessions (traces grouped by sessionId), by last activity, paginated. */
listSessions(options?: ListOptions): Promise<Page<SessionSummary>>;
listRuns(options: {
sessionKey: string;
}): Promise<RunSummary[]>;
getTrace(options: {
id: string;
}): Promise<SpanRecord[]>;
getSpan(options: {
id: string;
}): Promise<SpanRecord | null>;
listEnvironments(): Promise<string[]>;
costSummary(options?: CostQueryOptions): Promise<CostSummary>;
/** Headline numbers (runs, error rate, cost, latency) over a filter. */
stats(options?: TraceFilter): Promise<Stats>;
ingestSpans(options: {
spans: SpanRecord[];
}): Promise<void>;
/** One bounded retention batch (for cron/manual sweeping); returns rows deleted. */
runRetention(): Promise<number>;
};
options: Required<Pick<BreadcrumbOptions, "basePath" | "environment">> & BreadcrumbOptions;
}
declare function breadcrumb(options: BreadcrumbOptions): Breadcrumb;
//#endregion
export { TelemetrySettings as _, planMigration as a, SpanContext as c, AuthorizeFn as d, RetentionOptions as f, TelemetryOptions as g, PricingTable as h, EMPTY_SCHEMA_STATE as i, TraceAttrs as l, Pricing as m, BreadcrumbOptions as n, renderMigrationSql as o, ModelPrice as p, breadcrumb as r, SpanAttrs as s, Breadcrumb as t, TraceFn as u };
import { o as SPANS_TABLE } from "./ddl-lDbkSGwU.mjs";
import { t as resolveMcpServerName } from "./config-BANN28mc.mjs";
import { Valv } from "@valv/core";
import { createMcpServer } from "@valv/mcp-sdk";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { SqliteAdapter } from "@valv/sqlite";
import { PostgresAdapter } from "@valv/postgres";
//#region src/mcp/schema.ts
/**
* The span table, declared for valv rather than introspected.
*
* Breadcrumb owns this schema, so probing the database would only rediscover
* what is already known — and would also surface `breadcrumb_meta` and
* `breadcrumb_mcp_keys`, which the agent must never see. Declaring one resource
* means the agent's reachable surface is exactly this table, by construction.
*
* The descriptions are load-bearing: they are the only thing telling the model
* that durations are epoch milliseconds, that a trace is a set of spans rather
* than a row, and which columns are worth grouping by.
*/
const NATIVE = {
postgres: {
text: "TEXT",
integer: "BIGINT",
real: "DOUBLE PRECISION",
json: "JSONB"
},
sqlite: {
text: "TEXT",
integer: "INTEGER",
real: "REAL",
json: "TEXT"
}
};
const COLUMNS = {
id: {
type: "string",
native: "text",
id: true,
description: "Unique span id."
},
trace_id: {
type: "string",
native: "text",
description: "Groups spans into one trace (a single run). A trace is not a row — aggregate over this to reason about a run."
},
parent_span_id: {
type: "string",
native: "text",
nullable: true,
description: "Parent span's id. NULL marks the trace's root span, whose name is the run's name."
},
name: {
type: "string",
native: "text",
description: "Operation name, e.g. the tool or function called."
},
kind: {
type: "enum",
native: "text",
enumValues: [
"span",
"llm",
"tool",
"embedding",
"retrieval",
"agent"
],
description: "What produced the span. 'llm' spans carry model, tokens and cost."
},
environment: {
type: "string",
native: "text",
description: "Deployment environment, e.g. 'production' or 'development'."
},
user_id: {
type: "string",
native: "text",
nullable: true,
description: "Your app's end-user id, if set."
},
session_id: {
type: "string",
native: "text",
nullable: true,
description: "Groups related traces into a session. NULL when the run was not part of one."
},
model: {
type: "string",
native: "text",
nullable: true,
description: "Model name for llm spans, e.g. 'claude-sonnet-5'. Useful to group cost and latency by."
},
provider: {
type: "string",
native: "text",
nullable: true,
description: "Model provider, e.g. 'anthropic'."
},
input_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Prompt tokens."
},
output_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Completion tokens."
},
cached_input_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Prompt tokens served from cache."
},
cache_write_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Tokens written to the prompt cache."
},
reasoning_tokens: {
type: "number",
native: "integer",
nullable: true,
description: "Reasoning tokens, when the model reports them."
},
cost: {
type: "number",
native: "real",
nullable: true,
description: "Cost in USD. NULL when no price is configured for the model — not the same as zero."
},
status: {
type: "enum",
native: "text",
enumValues: ["ok", "error"],
description: "Span outcome. Filter on 'error' to find failures."
},
error: {
type: "string",
native: "text",
nullable: true,
description: "Error message when status is 'error'."
},
input: {
type: "json",
native: "json",
nullable: true,
description: "Captured input payload (prompt or arguments). May be truncated, and may be redacted."
},
output: {
type: "json",
native: "json",
nullable: true,
description: "Captured output payload (completion or return value). May be truncated, and may be redacted."
},
metadata: {
type: "json",
native: "json",
nullable: true,
description: "Arbitrary metadata attached at trace time."
},
start_time: {
type: "number",
native: "integer",
description: "Start time as epoch MILLISECONDS, stored as an integer — not a date. Compare against epoch-ms numbers; date functions do not apply."
},
end_time: {
type: "number",
native: "integer",
nullable: true,
description: "End time as epoch MILLISECONDS. NULL if the span never completed. Duration is end_time - start_time."
}
};
/**
* Build the valv schema for a dialect.
*
* Note the timestamps are declared `number`, not `date`. They really are epoch
* milliseconds in an integer column, and calling them dates would hand the model
* date functions that silently misread them (SQLite's strftime would parse the
* integer as a Julian day and bucket into the wrong era).
*/
function spanSchema(dialect) {
const fields = {};
for (const [name, col] of Object.entries(COLUMNS)) fields[name] = {
name,
type: col.type,
nativeType: NATIVE[dialect][col.native],
isNullable: col.nullable ?? false,
isId: col.id ?? false,
isPrimaryKeyPart: col.id ?? false,
description: col.description,
...col.enumValues ? { enumValues: [...col.enumValues] } : {}
};
return { resources: { [SPANS_TABLE]: {
name: SPANS_TABLE,
tableName: SPANS_TABLE,
description: "LLM and agent execution spans. One row per operation; spans sharing a trace_id form one run. Timestamps are epoch milliseconds.",
fields,
relations: {}
} } };
}
//#endregion
//#region src/mcp/server.ts
const PROTOCOL_IDENTITY_VERSION = "1";
function valvAdapter(adapter) {
const schema = spanSchema(adapter.id);
const client = adapter.client();
return adapter.id === "sqlite" ? new SqliteAdapter(client, { schema }) : new PostgresAdapter(client, { schema });
}
/**
* Build the valv instance the MCP server fronts. The schema declares exactly one
* resource, so `deny-all` plus a single allow rule means the agent can reach the
* span table and nothing else — notably not `breadcrumb_mcp_keys`, whose hashes
* would otherwise be readable through the very keys they authenticate.
*/
async function createTraceValv(adapter, options = {}) {
const valv = new Valv({
adapter: valvAdapter(adapter),
defaultPolicy: "deny-all"
});
valv.policy(SPANS_TABLE, () => ({
read: true,
...options.hidePayloads ? { fields: { deny: ["input", "output"] } } : {}
}));
await valv.loadSchema();
return valv;
}
/**
* A fetch handler serving MCP over streamable HTTP, for mounting inside the
* app's own router. Stateless: a fresh server and transport per request, which
* is what lets this run unchanged on serverless and edge runtimes where no
* instance survives between calls.
*/
function createMcpHandler(valv, options = {}) {
return async (request) => {
const server = createMcpServer(valv, {
context: {},
serverInfo: {
name: resolveMcpServerName(options, request),
version: PROTOCOL_IDENTITY_VERSION
}
});
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: void 0,
enableJsonResponse: true
});
try {
await server.connect(transport);
return await transport.handleRequest(request);
} finally {
transport.close();
server.close();
}
};
}
//#endregion
export { createMcpHandler, createTraceValv };
//#region src/db/types.d.ts
type SpanKind = "span" | "llm" | "tool" | "embedding" | "retrieval" | "agent";
type SpanStatus = "ok" | "error";
/** The normalized span model — every ingest dialect maps into this. */
interface SpanRecord {
id: string;
traceId: string;
parentSpanId?: string | null;
name: string;
kind: SpanKind;
environment: string;
userId?: string | null;
sessionId?: string | null;
model?: string | null;
provider?: string | null;
/** Total input tokens, INCLUSIVE of cached reads/writes (normalized). */
inputTokens?: number | null;
outputTokens?: number | null;
/** Cache-read tokens — a subset of inputTokens, billed at a discount. */
cachedInputTokens?: number | null;
/** Cache-write/creation tokens — a subset of inputTokens, billed at a premium. */
cacheWriteTokens?: number | null;
/** Reasoning/thinking tokens — a subset of outputTokens. */
reasoningTokens?: number | null;
cost?: number | null;
status: SpanStatus;
error?: string | null;
input?: unknown;
output?: unknown;
metadata?: Record<string, unknown> | null;
startTime: number;
endTime?: number | null;
}
/** Trace list row, aggregated from spans on read. */
interface TraceSummary {
traceId: string;
name: string;
environment: string;
userId: string | null;
sessionId: string | null;
startTime: number;
endTime: number | null;
spanCount: number;
errorCount: number;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
/**
* Filters shared by list + stats queries. Each dimension selects *traces*
* (a trace matches if any of its spans satisfies the predicate), so a filtered
* list still aggregates a trace's full span set — counts and cost stay whole.
*/
interface TraceFilter {
environment?: string;
userId?: string;
/** "error" → traces with a failed span; "ok" → traces with none. */
status?: SpanStatus;
/** Traces containing a span with this exact model. */
model?: string;
/** Inclusive lower/upper bounds on span start_time (epoch ms). */
since?: number;
until?: number;
}
interface ListOptions extends TraceFilter {
/** Page size. Default 50, clamped to 500. */
limit?: number;
/** Opaque keyset cursor from a previous page's `nextCursor`. */
cursor?: string;
}
/** Back-compat alias for the pre-filter options shape. */
type ListTracesOptions = ListOptions;
/** One page of results plus the cursor to fetch the next (null at the end). */
interface Page<T> {
items: T[];
nextCursor: string | null;
}
/** Headline numbers over a filtered set of traces, for a custom dashboard. */
interface Stats {
/** Distinct traces (runs) in the set. */
runs: number;
/** Runs containing at least one failed span. */
errors: number;
/** errors / runs, 0 when empty. */
errorRate: number;
cost: number;
inputTokens: number;
outputTokens: number;
/** Mean/max root-span duration in ms; null when no run has ended. */
avgLatencyMs: number | null;
maxLatencyMs: number | null;
}
/**
* A session groups traces sharing a sessionId; traces without one stand
* alone (sessionKey = traceId), so every trace appears in the sessions view.
*/
interface SessionSummary {
sessionKey: string;
sessionId: string | null;
userId: string | null;
environment: string;
startTime: number;
endTime: number | null;
runCount: number;
errorCount: number;
failName: string | null;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
/** One run = one trace, with its root span's payload for the feed. */
interface RunSummary {
traceId: string;
name: string;
input: unknown;
output: unknown;
startTime: number;
endTime: number | null;
spanCount: number;
errorCount: number;
failName: string | null;
failError: string | null;
inputTokens: number;
outputTokens: number;
cost: number | null;
}
interface MigrationResult {
createdTables: string[];
addedColumns: string[];
}
type Dialect = "postgres" | "sqlite";
/** A snapshot of the live schema, from `inspectSchema()`, used to plan a diff. */
interface SchemaState {
spansExists: boolean;
spansColumns: Set<string>;
indexNames: Set<string>;
metaExists: boolean;
mcpKeysExists: boolean;
}
/** The DDL needed to bring a database to the current schema. */
interface MigrationPlan {
/** Statements to run, in order, without trailing semicolons. */
statements: string[];
createdTables: string[];
addedColumns: string[];
}
/**
* One retention rule: delete spans older than `before` (epoch ms).
* `environment: null` matches every environment NOT covered by another rule.
*/
interface RetentionRule {
environment: string | null;
before: number;
}
interface CostQueryOptions {
environment?: string;
/** Trailing window in days (default 14, max 90). */
days?: number;
}
/** One day+model cost bucket, for the stacked time series. */
interface CostDatum {
day: string;
model: string | null;
cost: number;
inputTokens: number;
/** Cache-read tokens within inputTokens. */
cachedInputTokens: number;
outputTokens: number;
count: number;
}
interface CostGroup {
key: string | null;
cost: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
count: number;
}
interface CostSummary {
windowDays: number;
totals: {
cost: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
};
days: CostDatum[];
byModel: CostGroup[];
byFunction: CostGroup[];
}
/**
* An MCP key as the dashboard sees it. Deliberately excludes `key_hash` — the
* only place that ever reads it is the resolver, via findMcpKeyByHash.
*/
interface McpKeyRecord {
id: string;
name: string;
keyPrefix: string;
createdAt: number;
lastUsedAt: number | null;
}
interface DatabaseAdapter {
id: Dialect;
/** Create/upgrade breadcrumb's tables. Additive-only; safe to call repeatedly. */
migrate(): Promise<MigrationResult>;
/** Read the live schema, so a migration can be planned without applying it. */
inspectSchema(): Promise<SchemaState>;
insertSpans(spans: SpanRecord[]): Promise<void>;
listTraces(options: ListOptions): Promise<TraceSummary[]>;
listSessions(options: ListOptions): Promise<SessionSummary[]>;
listRuns(sessionKey: string): Promise<RunSummary[]>;
listEnvironments(): Promise<string[]>;
costSummary(options: CostQueryOptions): Promise<CostSummary>;
stats(filter: TraceFilter): Promise<Stats>;
getTraceSpans(traceId: string): Promise<SpanRecord[]>;
getSpan(id: string): Promise<SpanRecord | null>;
/** Bounded delete of expired spans; returns rows deleted (may be < the backlog). */
deleteExpiredSpans(rules: RetentionRule[], limit: number): Promise<number>;
/**
* Atomically claim the sweep slot: true if this caller may sweep now
* (no other instance swept within intervalMs). DB-backed, pool-safe.
*/
claimSweep(now: number, intervalMs: number): Promise<boolean>;
listMcpKeys(): Promise<McpKeyRecord[]>;
insertMcpKey(record: McpKeyRecord & {
keyHash: string;
}): Promise<void>;
findMcpKeyByHash(keyHash: string): Promise<McpKeyRecord | null>;
/** Returns false when no key had that id (already revoked, or never existed). */
deleteMcpKey(id: string): Promise<boolean>;
touchMcpKey(id: string, at: number): Promise<void>;
/**
* The underlying driver handle (better-sqlite3 Database, pg.Pool, ...), so
* features needing direct SQL can reach it. The MCP server hands this to valv,
* which compiles and runs the agent's queries under its own policy.
*/
client(): unknown;
close?(): Promise<void>;
}
//#endregion
export { TraceSummary as S, SpanKind as _, DatabaseAdapter as a, Stats as b, ListTracesOptions as c, MigrationResult as d, Page as f, SessionSummary as g, SchemaState as h, CostSummary as i, McpKeyRecord as l, RunSummary as m, CostGroup as n, Dialect as o, RetentionRule as p, CostQueryOptions as r, ListOptions as s, CostDatum as t, MigrationPlan as u, SpanRecord as v, TraceFilter as x, SpanStatus as y };