Skip to the content

Capsid menu

src/ops-otlp.ts

443 lines and 28 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of ops-otlp.ts
FunctionLinesComplexityCoveredCRAPMutation
attrMap78 to 901177%WatchCRAP 12.5No data: mutation testing has not run on this repo yet
temporalityOf92 to 96580%WatchCRAP 5.2No data: mutation testing has not run on this repo yet
pointValue99 to 1068100%WatchCRAP 8No data: mutation testing has not run on this repo yet
timeOf108 to 1136100%WatchCRAP 6No data: mutation testing has not run on this repo yet
arr115 to 1152100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:115>115 to 1152100%OkCRAP 2No data: mutation testing has not run on this repo yet
parseMetrics119 to 1832579%CriticalCRAP 31No data: mutation testing has not run on this repo yet
problem121 to 1243100%OkCRAP 3No data: mutation testing has not run on this repo yet
parseLogs187 to 2241890%WatchCRAP 18.4No data: mutation testing has not run on this repo yet
aggregate237 to 2546100%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:253>253 to 2531100%OkCRAP 1No data: mutation testing has not run on this repo yet
jobForSession263 to 2663100%OkCRAP 3No data: mutation testing has not run on this repo yet
claimSession273 to 2811100%OkCRAP 1No data: mutation testing has not run on this repo yet
upsertUsage289 to 3202100%OkCRAP 2No data: mutation testing has not run on this repo yet
readOtlpBody324 to 3611582%WatchCRAP 16.4No data: mutation testing has not run on this repo yet
transform344 to 3482100%OkCRAP 2No data: mutation testing has not run on this repo yet
recordExport364 to 4491779%WatchCRAP 19.7No data: mutation testing has not run on this repo yet
<anonymous:375>375 to 3751100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:378>378 to 382260%OkCRAP 2.3No data: mutation testing has not run on this repo yet
<anonymous:402>402 to 4062100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:426>426 to 4261100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:426>426 to 4261100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:429>429 to 4291100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:430>430 to 4302100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:434>434 to 4342100%OkCRAP 2No data: mutation testing has not run on this repo yet
handle451 to 46470%CriticalCRAP 56No data: mutation testing has not run on this repo yet
handleOtlpMetrics466 to 468133%OkCRAP 1.3No data: mutation testing has not run on this repo yet
handleOtlpLogs470 to 472133%OkCRAP 1.3No 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);
}