src/ops-otlp.ts
443 lines and 28 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| attrMap | 78 to 90 | 11 | 77% | WatchCRAP 12.5 | No data: mutation testing has not run on this repo yet |
| temporalityOf | 92 to 96 | 5 | 80% | WatchCRAP 5.2 | No data: mutation testing has not run on this repo yet |
| pointValue | 99 to 106 | 8 | 100% | WatchCRAP 8 | No data: mutation testing has not run on this repo yet |
| timeOf | 108 to 113 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| arr | 115 to 115 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:115> | 115 to 115 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| parseMetrics | 119 to 183 | 25 | 79% | CriticalCRAP 31 | No data: mutation testing has not run on this repo yet |
| problem | 121 to 124 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| parseLogs | 187 to 224 | 18 | 90% | WatchCRAP 18.4 | No data: mutation testing has not run on this repo yet |
| aggregate | 237 to 254 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:253> | 253 to 253 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| jobForSession | 263 to 266 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| claimSession | 273 to 281 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| upsertUsage | 289 to 320 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| readOtlpBody | 324 to 361 | 15 | 82% | WatchCRAP 16.4 | No data: mutation testing has not run on this repo yet |
| transform | 344 to 348 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| recordExport | 364 to 449 | 17 | 79% | WatchCRAP 19.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:375> | 375 to 375 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:378> | 378 to 382 | 2 | 60% | OkCRAP 2.3 | No data: mutation testing has not run on this repo yet |
| <anonymous:402> | 402 to 406 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:426> | 426 to 426 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:426> | 426 to 426 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:429> | 429 to 429 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:430> | 430 to 430 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:434> | 434 to 434 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| handle | 451 to 464 | 7 | 0% | CriticalCRAP 56 | No data: mutation testing has not run on this repo yet |
| handleOtlpMetrics | 466 to 468 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| handleOtlpLogs | 470 to 472 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/http.ts, src/log.ts, src/ops-session-auth.ts, src/signed-requests.ts. Imported by src/routes.ts.
Source
import { readBoundedText } from "./signed-requests";
import { resolveSessionCaller, type SessionCaller } from "./ops-session-auth";
import { logEvent } from "./log";
import { jsonResponse as json, textResponse as plain } from "./http";
// Claude Code's OpenTelemetry, received as OTLP/HTTP JSON (docs/telemetry.md).
//
// POST /ops/otlp/v1/metrics keeps per-session totals in session_usage
// (migrations/0025): cost, tokens, active time, commits, pull requests and lines of
// code, per metric, type attribute and model. Delta points are added; a cumulative
// point replaces the stored total. Nothing else in the export is read.
//
// POST /ops/otlp/v1/logs keeps one number: how many api_error events each session
// reported, as the metric claude_code.api_error keyed by the HTTP status. Every other
// event is counted as seen and dropped; a log record's body and its other attributes
// (a prompt, when OTEL_LOG_USER_PROMPTS is on; a tool's input) are never read into a
// value that is stored.
//
// ALLOWLIST, NOT DENYLIST. What reaches D1 is: the session id, the job id, the metric
// name from METRICS below, a type from that metric's list, a model name that matches
// MODEL_SHAPE, and a number. A new attribute Claude Code starts sending is ignored
// until it is added here. (test/ops-otlp.test.ts, the PLANT test.)
export const OTLP_METRICS_PATH = "/ops/otlp/v1/metrics";
export const OTLP_LOGS_PATH = "/ops/otlp/v1/logs";
// 1MB, after decompression. The compressed body is held to the same cap.
export const OTLP_MAX_BYTES = 1024 * 1024;
// Bounds on one request, so a runaway exporter costs one bounded batch.
const OTLP_MAX_SERIES = 200;
const OTLP_MAX_SESSIONS = 16;
// The metrics kept, and the type attribute values each may carry. An empty list means
// the metric has no type attribute, and its kind is ''.
const METRICS: Record<string, readonly string[]> = {
"claude_code.cost.usage": [],
"claude_code.token.usage": ["input", "output", "cacheRead", "cacheCreation"],
"claude_code.active_time.total": ["user", "cli"],
"claude_code.commit.count": [],
"claude_code.pull_request.count": [],
"claude_code.lines_of_code.count": ["added", "removed"],
};
const API_ERROR_METRIC = "claude_code.api_error";
const SESSION_SHAPE = /^[A-Za-z0-9._:-]{1,128}$/;
const MODEL_SHAPE = /^[A-Za-z0-9._:@/[\]-]{1,100}$/;
const JOB_SHAPE = /^job_[0-9a-f]{12}$/;
const STATUS_SHAPE = /^[1-5][0-9]{2}$/;
// OTLP's AggregationTemporality: 1 delta, 2 cumulative. JSON carries the number; the
// enum name is accepted too.
type Temporality = "delta" | "cumulative";
interface UsagePoint {
session_id: string;
// The capsid.job_id attribute, when the exporter sent one that is a job id.
job_attr: string | null;
metric: string;
kind: string;
model: string;
value: number;
temporality: Temporality;
// For "latest wins" among cumulative points of one series in one request.
at: bigint;
}
interface ParsedExport {
points: UsagePoint[];
// Points of a kept metric that could not be read: no session id, a value that is
// not a non-negative number, an unknown type, a gauge. Reported as partialSuccess.
rejected: number;
problems: string[];
}
type Attr = { key?: unknown; value?: Record<string, unknown> };
// Only scalar attribute values are read. An arrayValue or kvlistValue is skipped.
function attrMap(list: unknown): Map<string, string> {
const out = new Map<string, string>();
if (!Array.isArray(list)) return out;
for (const item of list as Attr[]) {
if (typeof item?.key !== "string" || !item.value || typeof item.value !== "object") continue;
const v = item.value;
if (typeof v.stringValue === "string") out.set(item.key, v.stringValue);
else if (typeof v.intValue === "string" || typeof v.intValue === "number") out.set(item.key, String(v.intValue));
else if (typeof v.doubleValue === "number") out.set(item.key, String(v.doubleValue));
else if (typeof v.boolValue === "boolean") out.set(item.key, String(v.boolValue));
}
return out;
}
function temporalityOf(raw: unknown): Temporality | null {
if (raw === 1 || raw === "AGGREGATION_TEMPORALITY_DELTA") return "delta";
if (raw === 2 || raw === "AGGREGATION_TEMPORALITY_CUMULATIVE") return "cumulative";
return null;
}
/** A data point's value: asDouble, or asInt, which JSON may carry as a decimal string. */
export function pointValue(point: Record<string, unknown>): number | null {
let value: number | null = null;
if (typeof point.asDouble === "number") value = point.asDouble;
else if (typeof point.asInt === "number") value = point.asInt;
else if (typeof point.asInt === "string" && /^-?[0-9]{1,18}$/.test(point.asInt)) value = Number(point.asInt);
if (value === null || !Number.isFinite(value) || value < 0) return null;
return value;
}
function timeOf(point: Record<string, unknown>): bigint {
const raw = point.timeUnixNano;
if (typeof raw === "string" && /^[0-9]{1,20}$/.test(raw)) return BigInt(raw);
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0) return BigInt(raw);
return 0n;
}
const arr = (v: unknown): Record<string, unknown>[] => (Array.isArray(v) ? (v as Record<string, unknown>[]).filter((x) => x && typeof x === "object") : []);
/** The kept points of an ExportMetricsServiceRequest. Throws nothing; a body that is
* not the expected shape yields no points. */
export function parseMetrics(body: unknown): ParsedExport {
const out: ParsedExport = { points: [], rejected: 0, problems: [] };
const problem = (message: string) => {
out.rejected += 1;
if (out.problems.length < 5 && !out.problems.includes(message)) out.problems.push(message);
};
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
for (const rm of arr(root.resourceMetrics)) {
const resource = attrMap((rm.resource as Record<string, unknown> | undefined)?.attributes);
for (const sm of arr(rm.scopeMetrics)) {
for (const metric of arr(sm.metrics)) {
const name = metric.name;
// Not one of ours: ignored, not rejected. The exporter did nothing wrong.
if (typeof name !== "string" || !Object.hasOwn(METRICS, name)) continue;
const sum = metric.sum as Record<string, unknown> | undefined;
if (!sum || typeof sum !== "object") {
const gaugePoints = arr((metric.gauge as Record<string, unknown> | undefined)?.dataPoints).length;
for (let i = 0; i < gaugePoints; i += 1) problem(`${name} is not a sum`);
continue;
}
const temporality = temporalityOf(sum.aggregationTemporality);
for (const dp of arr(sum.dataPoints)) {
if (!temporality) {
problem(`${name} has no aggregationTemporality`);
continue;
}
const attrs = new Map([...resource, ...attrMap(dp.attributes)]);
const session = attrs.get("session.id");
if (!session || !SESSION_SHAPE.test(session)) {
problem(`${name} point without a usable session.id`);
continue;
}
const value = pointValue(dp);
if (value === null) {
problem(`${name} point with a value that is not a non-negative number`);
continue;
}
const kinds = METRICS[name];
const kind = kinds.length === 0 ? "" : (attrs.get("type") ?? "");
if (kinds.length > 0 && !kinds.includes(kind)) {
problem(`${name} point with an unknown type`);
continue;
}
const model = attrs.get("model") ?? "";
if (model !== "" && !MODEL_SHAPE.test(model)) {
problem(`${name} point with a model name this receiver does not accept`);
continue;
}
const job = attrs.get("capsid.job_id");
out.points.push({
session_id: session,
job_attr: job && JOB_SHAPE.test(job) ? job : null,
metric: name,
kind,
model,
value,
temporality,
at: timeOf(dp),
});
}
}
}
}
return out;
}
/** api_error events from an ExportLogsServiceRequest, as delta points of
* claude_code.api_error keyed by status. Every other record is skipped. */
export function parseLogs(body: unknown): ParsedExport {
const out: ParsedExport = { points: [], rejected: 0, problems: [] };
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
for (const rl of arr(root.resourceLogs)) {
const resource = attrMap((rl.resource as Record<string, unknown> | undefined)?.attributes);
for (const sl of arr(rl.scopeLogs)) {
for (const record of arr(sl.logRecords)) {
const attrs = new Map([...resource, ...attrMap(record.attributes)]);
const event = attrs.get("event.name");
// Claude Code names the event in event.name ("api_error") and in the body
// ("claude_code.api_error"). The body is compared, never kept.
const bodyName = (record.body as Record<string, unknown> | undefined)?.stringValue;
const isApiError = event === "api_error" || event === API_ERROR_METRIC || bodyName === API_ERROR_METRIC;
if (!isApiError) continue;
const session = attrs.get("session.id");
if (!session || !SESSION_SHAPE.test(session)) {
out.rejected += 1;
if (out.problems.length === 0) out.problems.push("api_error record without a usable session.id");
continue;
}
const status = attrs.get("status_code") ?? "";
const model = attrs.get("model") ?? "";
const job = attrs.get("capsid.job_id");
out.points.push({
session_id: session,
job_attr: job && JOB_SHAPE.test(job) ? job : null,
metric: API_ERROR_METRIC,
kind: STATUS_SHAPE.test(status) ? status : "",
model: MODEL_SHAPE.test(model) ? model : "",
value: 1,
temporality: "delta",
at: timeOf(record),
});
}
}
}
return out;
}
interface SeriesWrite {
session_id: string;
metric: string;
kind: string;
model: string;
value: number;
mode: "add" | "replace";
}
/** One write per series: delta points of a series summed, cumulative points of a
* series reduced to the newest. */
export function aggregate(points: UsagePoint[]): SeriesWrite[] {
const deltas = new Map<string, SeriesWrite>();
const latest = new Map<string, { write: SeriesWrite; at: bigint }>();
for (const p of points) {
const key = JSON.stringify([p.session_id, p.metric, p.kind, p.model]);
if (p.temporality === "delta") {
const seen = deltas.get(key);
if (seen) seen.value += p.value;
else deltas.set(key, { session_id: p.session_id, metric: p.metric, kind: p.kind, model: p.model, value: p.value, mode: "add" });
} else {
const seen = latest.get(key);
if (!seen || p.at >= seen.at) {
latest.set(key, { write: { session_id: p.session_id, metric: p.metric, kind: p.kind, model: p.model, value: p.value, mode: "replace" }, at: p.at });
}
}
}
return [...deltas.values(), ...[...latest.values()].map((l) => l.write)];
}
/**
* The job each session's usage is recorded against. A session already bound (its
* agent_sessions row, written by the hook receiver) keeps that job. Otherwise the
* caller's binding. The capsid.job_id attribute never picks a job on its own: it is
* set by whoever configured the exporter, so it is honoured only where it agrees with
* the binding, and a disagreement is reported rather than recorded.
*/
export function jobForSession(caller: SessionCaller, bound: string | null | undefined, attr: string | null): { job: string | null; mismatch: boolean } {
const job = bound ?? caller.job_id;
return { job, mismatch: attr !== null && attr !== job };
}
// A session belongs to the key that first reported it, by hook or by telemetry. The
// claim: an agent_sessions row for a session nobody has reported yet, and nothing for
// one that exists (the hook receiver owns every other column).
// Each statement is one literal at its prepare, so test-integration/query-plans.test.ts
// can reconstruct and EXPLAIN it.
function claimSession(db: D1Database, id: string, actor: string, jobId: string | null, namespace: string | null, at: string): D1PreparedStatement {
return db
.prepare(
`INSERT INTO agent_sessions (session_id, agent, job_id, namespace, started_at, last_event_at, last_event, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 'otlp', ?5)
ON CONFLICT(session_id) DO NOTHING`
)
.bind(id, actor, jobId, namespace, at);
}
// The upsert, conditional on the session being this caller's (?8), in the same batch as
// the claim, so a session another key owns is never written whatever order two
// requests commit in. RETURNING says which series landed; never meta.changes
// (path mutation invariant, test/path-mutation.test.ts). The job a row was first written with is kept (COALESCE).
// The SELECT carries a WHERE, which SQLite needs to read the ON CONFLICT that follows.
// Delta points add to the stored total; cumulative points replace it.
function upsertUsage(
db: D1Database,
mode: "add" | "replace",
w: { session_id: string; metric: string; kind: string; model: string; value: number },
jobId: string | null,
at: string,
actor: string
): D1PreparedStatement {
const statement =
mode === "add"
? db.prepare(
`INSERT INTO session_usage (session_id, job_id, metric, kind, model, value, updated_at)
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7
WHERE EXISTS (SELECT 1 FROM agent_sessions WHERE session_id = ?1 AND agent = ?8)
ON CONFLICT(session_id, metric, kind, model) DO UPDATE SET
value = session_usage.value + excluded.value,
job_id = COALESCE(session_usage.job_id, excluded.job_id),
updated_at = excluded.updated_at
RETURNING session_id`
)
: db.prepare(
`INSERT INTO session_usage (session_id, job_id, metric, kind, model, value, updated_at)
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7
WHERE EXISTS (SELECT 1 FROM agent_sessions WHERE session_id = ?1 AND agent = ?8)
ON CONFLICT(session_id, metric, kind, model) DO UPDATE SET
value = excluded.value,
job_id = COALESCE(session_usage.job_id, excluded.job_id),
updated_at = excluded.updated_at
RETURNING session_id`
);
return statement.bind(w.session_id, jobId, w.metric, w.kind, w.model, w.value, at, actor);
}
/** The request body as text: identity or gzip, at most OTLP_MAX_BYTES either side of
* decompression. */
export async function readOtlpBody(request: Request): Promise<{ ok: true; text: string } | { ok: false; response: Response }> {
const type = (request.headers.get("Content-Type") ?? "").split(";")[0].trim().toLowerCase();
if (type !== "application/json") {
return { ok: false, response: plain("unsupported media type: this receiver takes OTLP/HTTP JSON (OTEL_EXPORTER_OTLP_PROTOCOL=http/json), not protobuf", 415) };
}
const length = Number(request.headers.get("Content-Length") ?? "0");
if (Number.isFinite(length) && length > OTLP_MAX_BYTES) {
return { ok: false, response: plain(`payload too large: the body exceeds ${OTLP_MAX_BYTES} bytes`, 413) };
}
const encoding = (request.headers.get("Content-Encoding") ?? "identity").trim().toLowerCase();
if (encoding !== "identity" && encoding !== "gzip") {
return { ok: false, response: plain(`unsupported content encoding '${encoding}': send gzip or none`, 415) };
}
if (!request.body) return { ok: true, text: "" };
let stream: ReadableStream<Uint8Array> = request.body;
if (encoding === "gzip") {
// The compressed bytes are capped on the way in too, by counting what the
// decompressor is fed.
let fed = 0;
const cap = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
fed += chunk.byteLength;
if (fed > OTLP_MAX_BYTES) controller.error(new Error("compressed body too large"));
else controller.enqueue(chunk);
},
});
stream = request.body.pipeThrough(cap).pipeThrough(new DecompressionStream("gzip") as unknown as TransformStream<Uint8Array, Uint8Array>);
}
try {
const read = await readBoundedText({ body: stream }, OTLP_MAX_BYTES);
if (!read.ok) return { ok: false, response: plain(`payload too large: the body exceeds ${OTLP_MAX_BYTES} bytes after decompression`, 413) };
return { ok: true, text: read.text };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/too large/.test(message)) return { ok: false, response: plain(`payload too large: the compressed body exceeds ${OTLP_MAX_BYTES} bytes`, 413) };
return { ok: false, response: plain(`bad request: the gzip body does not decompress (${message})`, 400) };
}
}
/** Parse, bind and write one export. Returns the OTLP response. */
export async function recordExport(
db: D1Database,
caller: SessionCaller,
parsed: ParsedExport,
now: Date
): Promise<Response> {
let rejected = parsed.rejected;
const problems = [...parsed.problems];
// Bounded: at most OTLP_MAX_SESSIONS sessions per request; points of the rest are
// rejected.
const sessions = [...new Set(parsed.points.map((p) => p.session_id))];
const kept = new Set(sessions.slice(0, OTLP_MAX_SESSIONS));
if (sessions.length > kept.size) problems.push(`more than ${OTLP_MAX_SESSIONS} sessions in one request`);
const points = parsed.points.filter((p) => {
if (kept.has(p.session_id)) return true;
rejected += 1;
return false;
});
// Who owns each session already. A session another key reported first is refused
// here, and the conditional upsert below refuses it again at commit.
const actor = caller.agent.actor;
const bound = new Map<string, string | null>();
const foreign = new Set<string>();
if (kept.size > 0) {
const { results } = await db
.prepare("SELECT session_id, agent, job_id FROM agent_sessions WHERE session_id IN (SELECT value FROM json_each(?1))")
.bind(JSON.stringify([...kept]))
.all<{ session_id: string; agent: string; job_id: string | null }>();
for (const row of results ?? []) {
if (row.agent === actor) bound.set(row.session_id, row.job_id);
else foreign.add(row.session_id);
}
}
if (foreign.size > 0) {
problems.push(`${foreign.size} session(s) were first reported by another key, so their points were not recorded`);
}
const owned = points.filter((p) => {
if (!foreign.has(p.session_id)) return true;
rejected += 1;
return false;
});
const jobOf = new Map<string, string | null>();
let mismatched = 0;
for (const p of owned) {
const { job, mismatch } = jobForSession(caller, bound.get(p.session_id), p.job_attr);
jobOf.set(p.session_id, job);
if (mismatch) mismatched += 1;
}
if (mismatched > 0) problems.push(`${mismatched} point(s) named a capsid.job_id other than the job this caller is bound to; they were recorded against the bound job`);
let writes = aggregate(owned);
if (writes.length > OTLP_MAX_SERIES) {
problems.push(`more than ${OTLP_MAX_SERIES} series in one request`);
// The points behind the dropped series are not counted one by one; each dropped
// series counts as one.
rejected += writes.length - OTLP_MAX_SERIES;
writes = writes.slice(0, OTLP_MAX_SERIES);
}
const at = now.toISOString();
if (writes.length > 0) {
const claims = [...new Set(writes.map((w) => w.session_id))].filter((id) => !bound.has(id));
try {
const results = await db.batch([
...claims.map((id) => claimSession(db, id, actor, caller.job_id, caller.namespace, at)),
...writes.map((w) => upsertUsage(db, w.mode, w, jobOf.get(w.session_id) ?? null, at, actor)),
]);
// A series whose upsert returned no row was refused by the ownership condition:
// another key claimed the session between the read above and this batch.
const refused = results.slice(claims.length).filter((r) => (r.results ?? []).length === 0).length;
if (refused > 0) {
rejected += refused;
problems.push(`${refused} series belonged to a session another key reported first, so they were not recorded`);
}
} catch (err) {
// Not swallowed (CLAUDE.md, no swallowed error rule): logged, and answered 503 so
// the exporter retries rather than losing the points.
const message = err instanceof Error ? err.message : String(err);
logEvent("error", "OTLP_WRITE_FAILED", { message: `OTLP_WRITE_FAILED ${caller.agent.actor}: ${message}` });
return plain(`service unavailable: the usage could not be written (${message})`, 503);
}
}
if (rejected === 0 && problems.length === 0) return json({});
return json({ partialSuccess: { rejectedDataPoints: rejected, errorMessage: problems.join("; ") } });
}
async function handle(request: Request, env: { DB: D1Database; OPERATOR_KEY_HASH?: string }, parse: (body: unknown) => ParsedExport): Promise<Response> {
const caller = await resolveSessionCaller(request, env);
if (caller instanceof Response) return caller;
const body = await readOtlpBody(request);
if (!body.ok) return body.response;
let decoded: unknown;
try {
decoded = JSON.parse(body.text);
} catch {
return plain("bad request: the body is not JSON", 400);
}
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return plain("bad request: the body is not an OTLP export object", 400);
return recordExport(env.DB, caller, parse(decoded), new Date());
}
export function handleOtlpMetrics(request: Request, env: { DB: D1Database; OPERATOR_KEY_HASH?: string }): Promise<Response> {
return handle(request, env, parseMetrics);
}
export function handleOtlpLogs(request: Request, env: { DB: D1Database; OPERATOR_KEY_HASH?: string }): Promise<Response> {
return handle(request, env, parseLogs);
}