SLOPSHOPPER

autotel

OpenTelemetry for Claude Code plugins: adds $.autotel in the engine.create fold so a plugin records spans, traces every hook dispatch with the chain beneath…

newrowsnetworktimer
★ 8v0.1.0Apache-2.0updated 2026-10-09jagreehal/autotel/packages/autotel-claude-code
A shopper browsing a rack in a slop shop
README

autotel-claude-code

OpenTelemetry for Claude Code plugins, as a Claude Mod.

Function hooks make every plugin a middleware chain: each $ call is an event, hooks nest by registration order, and a hook may answer for the tool beneath it. Claude Code's own OTel export tells you what the model and the tools did; this mod tells you what the hooks did around them — which plugin sat where, how long each link took, and who said no.

What it records

  • One trace per interaction. A claude_code.turn root opens at prompt.submit and closes at turn.complete, carrying the turn id, its reason and duration.
  • One span per dispatch worth the bytes — tool.call, tool.check, turn.step (each model call), every classic.* shell hook, process.run, http.fetch, fs.read/fs.write, mcp.call, model.*, agent.spawn, command.run. Getters, per-draw and per-tick events are left out.
  • The chain beneath, one span event per link: plugin.name, tier, outcome (returned, passed, skipped, expired, …), own wall time, and the skip reason. A denied dispatch names the plugin that decided it in claude_code.decided_by: the final denial, followed down through the links that passed it along unchanged.
  • The call's duration on its row. A ToolUse render hook appends 1.2s — or denied by hook · 3ms — beside the tool row in the terminal and the desktop app.

Every span is INTERNAL, attributed with claude_code.event, plugin.name (who raised it) and claude_code.tier; a tool.call adds tool_name, tool_use_id and agent_id in a subagent.

$.autotel for plugin authors

The mod adds one noun in the engine.create fold:

const summary = await $.autotel.span(
  'summarise',
  async (span) => {
    span.setAttribute('gen_ai.request.model', model);
    return $.model.complete(request);
  },
  { 'plugin.feature': 'summary' },
);

The span joins the current turn's trace beside the dispatches above. It ends when the body settles; a throw marks it failed with the error's message and rethrows. Types come from this package: add node_modules/autotel-claude-code/types to your tsconfig include, or import type { Autotel } from 'autotel-claude-code', and EngineInterface gains autotel.

Where the mod is not seated, $.autotel is absent and the call throws — the same contract as Claude Code's built-in $.telemetry.

Install

Function hooks ship behind a flag while Anthropic finishes them:

npm install autotel-claude-code
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir node_modules/autotel-claude-code

Or point a marketplace at the package directory. The mod exports to whatever OTEL_EXPORTER_OTLP_ENDPOINT names, over OTLP/HTTP JSON, honouring OTEL_EXPORTER_OTLP_HEADERS and OTEL_SERVICE_NAME (default claude-code, so the spans sit beside Claude Code's own). With no endpoint set it records nothing and $.autotel.span only runs its body.

autotel-devtools claude sets the endpoint for you:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 npx autotel-devtools claude --plugin-dir node_modules/autotel-claude-code

How it is built

The plugin runs inside Claude Code's hook sandbox (a Bun worker, no ambient imports), so there is no OpenTelemetry SDK here: spans are plain records batched over $.clock.after and posted with $.http.fetch. hooks/register.ts is the boundary that reads the engine's e and next into plain values; hooks/recorder.ts decides what a span is named, where it sits and what it carries; hooks/otlp.ts encodes and sends. The hooks are type-checked against the declarations /plugin-types writes, vendored in types/claude-code.d.ts.

claude plugin validate . lists the hooks, the $ calls and the environment variables the module reads — nothing else is reachable.

Limits

  • A dispatch's own $ calls (a hook reading a file during tool.call) are siblings under the turn, not children of the dispatch: the sandbox has no async context to carry a parent across.
  • One attempt per batch; a collector that is down loses that batch. Devtools is loopback, so this has not mattered yet.
Source 4 files
hooks/register.ts 157 lines
1import type { On } from 'claude-code';
2
3import type { Autotel } from '../types';
4
5import { createExporter, parseHeaders } from './otlp';
6import {
7  createRecorder,
8  badge,
9  type DispatchStart,
10  type Recorder,
11} from './recorder';
12
13/** The name `.claude-plugin/plugin.json` declares; `next.origin.plugin` reads it. */
14export const PLUGIN_NAME = 'autotel';
15
16/**
17 * The dispatches worth a span: where time goes and where a hook can say no.
18 * Every `classic.*` shell hook counts. The rest of the engine's calls
19 * (`tool.describe`, `agent.offer`, `prompt.section`, every getter, every
20 * draw) fire dozens of times per turn and say nothing a trace should carry;
21 * a `clock.after` is also how the exporter's own batch timer fires.
22 */
23const TRACED = new Set<DispatchStart['event']>([
24  'prompt.submit',
25  'turn.start',
26  'turn.step',
27  'turn.complete',
28  'turn.abort',
29  'tool.call',
30  'tool.check',
31  'mcp.call',
32  'model.complete',
33  'model.classify',
34  'model.fork',
35  'process.run',
36  'http.fetch',
37  'fs.read',
38  'fs.write',
39  'command.run',
40  'agent.spawn',
41  'session.start',
42  'session.compact',
43  'plugin.register',
44  'ui.press',
45]);
46
47function isTraced(event: DispatchStart['event']): boolean {
48  return TRACED.has(event) || event.startsWith('classic.');
49}
50
51/**
52 * Registers the mod's hooks. Each reads the engine's `e` and `next` into the
53 * recorder's plain values at the boundary; what to record is decided there.
54 *
55 * `engine.create` adds `$.autotel`; `session.start` turns the exporter on
56 * over `$.http`, `$.clock` and `$.env` when `OTEL_EXPORTER_OTLP_ENDPOINT` is
57 * set. The `*` hook records one span per dispatch with the chain beneath it
58 * as span events, one per link. The `ToolUse` render hook draws each call's
59 * settled duration beside its row.
60 *
61 * @param on the engine's registrar
62 */
63export function register(on: On) {
64  let recorder: Recorder = createRecorder(undefined);
65
66  // The noun is one object for the life of the fold, delegating to whichever
67  // recorder is current: inert until `session.start` has read the
68  // environment, exporting after.
69  const autotel: Autotel = {
70    span: (name, fn, attributes) => recorder.autotel.span(name, fn, attributes),
71  };
72
73  on('engine.create', async ($, e, next) => ({
74    ...(await next(e)),
75    autotel,
76  }));
77
78  // `$` admits no call during the fold, so the environment is read once the
79  // session has started, the way the built-in telemetry mod reads it lazily.
80  on('session.start', async ($, e, next) => {
81    const started = await next(e);
82    const endpoint = await $.env.get('OTEL_EXPORTER_OTLP_ENDPOINT');
83    if (endpoint === undefined || endpoint === '') return started;
84
85    const serviceName = (await $.env.get('OTEL_SERVICE_NAME')) ?? 'claude-code';
86    recorder = createRecorder(
87      createExporter({
88        fetch: (url, init) => $.http.fetch(url, init),
89        after: (ms, fn) => $.clock.after(ms, fn),
90        endpoint,
91        headers: parseHeaders(await $.env.get('OTEL_EXPORTER_OTLP_HEADERS')),
92        resource: {
93          'service.name': serviceName,
94          'session.id': await $.session.id(),
95        },
96      }),
97    );
98    return started;
99  });
100
101  on('*', async ($, e, next) => {
102    if (!isTraced(next.event) || next.origin.plugin === PLUGIN_NAME)
103      return next(e);
104
105    const start: DispatchStart = { event: next.event, origin: next.origin };
106    if (next.is('tool.call', e)) {
107      start.tool = { name: e.tool, useId: e.tool_use_id };
108      if (e.agentId !== undefined) start.tool.agentId = e.agentId;
109    }
110    if (next.event === 'prompt.submit' || next.event === 'turn.start')
111      start.opensTurn = true;
112    if (next.is('turn.start', e)) start.turnId = e.turnId;
113    const settle = recorder.begin(start);
114
115    const end: Parameters<typeof settle>[0] = { trace: [] };
116    try {
117      if (next.is('tool.call', e)) {
118        const result = await next(e);
119        end.denied = result.deny !== undefined;
120        return result;
121      }
122      if (next.is('prompt.submit', e)) {
123        const result = await next(e);
124        if (result.drop !== undefined) end.promptDropped = result.drop;
125        return result;
126      }
127      return await next(e);
128    } catch (thrown) {
129      end.error = String(thrown);
130      throw thrown;
131    } finally {
132      end.trace = next.trace;
133      if (next.is('turn.complete', e)) {
134        end.turnComplete = {
135          turnId: e.turnId,
136          reason: e.reason,
137          isAborted: e.isAborted,
138          durationMs: e.durationMs,
139        };
140        if (e.agentId !== undefined) end.turnComplete.agentId = e.agentId;
141      }
142      settle(end);
143      if (next.event === 'classic.SessionEnd') void recorder.flush();
144    }
145  });
146
147  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
148    const rendered = await next(e);
149    const timing = recorder.timingFor(e.props.tool_use_id);
150    if (timing === undefined || e.props.isRunning) return rendered;
151    const { Box, Text } = await $.ui.resolve(e);
152    return Box({
153      children: [rendered, Text({ dimColor: true, children: badge(timing) })],
154    });
155  });
156}
157
hooks/otlp.ts 220 lines
1import type { HttpInit, HttpResponse, TimerCall } from 'claude-code';
2
3import type { AttributeValue, Attributes, Span } from '../types';
4
5/**
6 * Spans out of the plugin sandbox as OTLP/JSON, over the two nouns it has:
7 * `$.http.fetch` to send and `$.clock.after` to batch.
8 *
9 * Nothing ambient is reached for. There is no OpenTelemetry SDK here because
10 * the sandbox has no `node:` modules and a hook is budgeted in microseconds;
11 * a span is a plain record until the batch timer posts it.
12 */
13
14/** Spans ended within this window go out as one request. */
15const FLUSH_MS = 1_000;
16/** Spans held while the endpoint is unreachable; the oldest go first. */
17const MAX_QUEUED = 1_000;
18const TRACES_PATH = '/v1/traces';
19
20export type ExporterDeps = {
21  fetch: (url: string, init: HttpInit) => Promise<HttpResponse>;
22  after: TimerCall;
23  /** `OTEL_EXPORTER_OTLP_ENDPOINT`: a base URL, or one already ending in `/v1/traces`. */
24  endpoint: string;
25  /** `OTEL_EXPORTER_OTLP_HEADERS`, parsed. */
26  headers: NonNullable<HttpInit['headers']>;
27  resource: Attributes;
28};
29
30/** Where a new span sits: its trace and, inside a turn, the turn's root span. */
31export type SpanContext = {
32  traceId: string;
33  parentSpanId?: string;
34};
35
36export type SpanEnd = {
37  /** The failure's message; absent when the span succeeded. */
38  error?: string;
39};
40
41/** A span the mod holds open: the caller-facing `Span` plus its identity and `end`. */
42export type OpenSpan = Span & {
43  readonly spanId: string;
44  readonly traceId: string;
45  end: (outcome?: SpanEnd) => void;
46};
47
48export type Exporter = {
49  start: (
50    name: string,
51    context: SpanContext,
52    attributes?: Attributes,
53  ) => OpenSpan;
54  /** Posts everything queued now; resolves once the request settled either way. */
55  flush: () => Promise<void>;
56};
57
58type OtlpAnyValue =
59  | { stringValue: string }
60  | { intValue: string }
61  | { doubleValue: number }
62  | { boolValue: boolean };
63
64type OtlpKeyValue = { key: string; value: OtlpAnyValue };
65
66type OtlpEvent = {
67  name: string;
68  timeUnixNano: string;
69  attributes: OtlpKeyValue[];
70};
71
72export type OtlpSpan = {
73  traceId: string;
74  spanId: string;
75  parentSpanId?: string;
76  name: string;
77  /** INTERNAL: a hook dispatch is neither a server nor a client. */
78  kind: 1;
79  startTimeUnixNano: string;
80  endTimeUnixNano: string;
81  attributes: OtlpKeyValue[];
82  events: OtlpEvent[];
83  status: { code: 1 } | { code: 2; message: string };
84};
85
86const HEX = '0123456789abcdef';
87
88/** `bytes` random bytes as lowercase hex: 16 for a trace id, 8 for a span id. */
89export function newId(bytes: 16 | 8): string {
90  let id = '';
91  for (let i = 0; i < bytes * 2; i++) id += HEX[Math.floor(Math.random() * 16)];
92  return id;
93}
94
95function nanos(ms: number): string {
96  return (BigInt(Math.round(ms)) * 1_000_000n).toString();
97}
98
99function anyValue(value: AttributeValue): OtlpAnyValue {
100  if (value === true || value === false) return { boolValue: value };
101  if (Number.isFinite(value)) {
102    // SAFETY: Number.isFinite is true only for a number primitive.
103    const n = value as number;
104    return Number.isInteger(n) ? { intValue: String(n) } : { doubleValue: n };
105  }
106  return { stringValue: String(value) };
107}
108
109export function keyValues(attributes: Attributes): OtlpKeyValue[] {
110  return Object.entries(attributes).map(([key, value]) => ({
111    key,
112    value: anyValue(value),
113  }));
114}
115
116/** `OTEL_EXPORTER_OTLP_HEADERS` (`a=b,c=d`) as the headers `$.http.fetch` takes. */
117export function parseHeaders(
118  raw: string | undefined,
119): NonNullable<HttpInit['headers']> {
120  const headers: NonNullable<HttpInit['headers']> = {};
121  for (const pair of (raw ?? '').split(',')) {
122    const eq = pair.indexOf('=');
123    if (eq < 1) continue;
124    headers[pair.slice(0, eq).trim()] = decodeURIComponent(
125      pair.slice(eq + 1).trim(),
126    );
127  }
128  return headers;
129}
130
131export function tracesUrl(endpoint: string): string {
132  const trimmed = endpoint.replace(/\/+$/, '');
133  return trimmed.endsWith(TRACES_PATH) ? trimmed : `${trimmed}${TRACES_PATH}`;
134}
135
136export function createExporter(deps: ExporterDeps): Exporter {
137  const url = tracesUrl(deps.endpoint);
138  const headers = { 'content-type': 'application/json', ...deps.headers };
139  const resource = keyValues(deps.resource);
140  const queue: OtlpSpan[] = [];
141  let scheduled = false;
142
143  async function flush(): Promise<void> {
144    scheduled = false;
145    if (queue.length === 0) return;
146    const spans = queue.splice(0, queue.length);
147    const body = JSON.stringify({
148      resourceSpans: [
149        {
150          resource: { attributes: resource },
151          scopeSpans: [{ scope: { name: 'autotel-claude-code' }, spans }],
152        },
153      ],
154    });
155    // One attempt per batch: devtools is loopback. Add retry with backoff if
156    // a flaky collector shows up in practice.
157    await deps
158      .fetch(url, { method: 'POST', headers, body })
159      .catch(() => undefined);
160  }
161
162  function enqueue(span: OtlpSpan): void {
163    if (queue.length >= MAX_QUEUED) queue.shift();
164    queue.push(span);
165    if (scheduled) return;
166    scheduled = true;
167    deps.after(FLUSH_MS, () => void flush());
168  }
169
170  function start(
171    name: string,
172    context: SpanContext,
173    attributes: Attributes = {},
174  ): OpenSpan {
175    const startMs = Date.now();
176    const own = new Map<string, AttributeValue>(Object.entries(attributes));
177    const events: OtlpEvent[] = [];
178    let ended = false;
179    const spanId = newId(8);
180
181    return {
182      spanId,
183      traceId: context.traceId,
184      setAttribute: (key, value) => void own.set(key, value),
185      setAttributes: (more) => {
186        for (const [key, value] of Object.entries(more)) own.set(key, value);
187      },
188      addEvent: (eventName, eventAttributes = {}) =>
189        void events.push({
190          name: eventName,
191          timeUnixNano: nanos(Date.now()),
192          attributes: keyValues(eventAttributes),
193        }),
194      end: (outcome = {}) => {
195        if (ended) return;
196        ended = true;
197        const record: OtlpSpan = {
198          traceId: context.traceId,
199          spanId,
200          name,
201          kind: 1,
202          startTimeUnixNano: nanos(startMs),
203          endTimeUnixNano: nanos(Date.now()),
204          attributes: keyValues(Object.fromEntries(own)),
205          events,
206          status:
207            outcome.error === undefined
208              ? { code: 1 }
209              : { code: 2, message: outcome.error },
210        };
211        if (context.parentSpanId !== undefined)
212          record.parentSpanId = context.parentSpanId;
213        enqueue(record);
214      },
215    };
216  }
217
218  return { start, flush };
219}
220
hooks/recorder.ts 261 lines
1import type { EventName, Origin, TraceEntry } from 'claude-code';
2
3import type { Attributes, Autotel } from '../types';
4import { newId, type Exporter, type OpenSpan, type SpanContext } from './otlp';
5
6/**
7 * What the mod records, as plain values: the hooks in `register.ts` read the
8 * engine's `e` and `next` into these at the boundary, and everything that
9 * decides a span's name, place and attributes lives here, where a test can
10 * drive it without the engine.
11 */
12
13/** One link of the chain beneath a dispatch, as `next.trace` reports it. */
14export type Link = TraceEntry<EventName, unknown, unknown>;
15
16/** A dispatch as it begins: the event, who raised it, and what it names. */
17export type DispatchStart = {
18  event: EventName;
19  origin: Origin;
20  /** On `tool.call`: the tool and the call's id (`agentId` in a subagent). */
21  tool?: { name: string; useId: string; agentId?: string };
22  /**
23   * Opens the turn's trace when none is open: `prompt.submit`, where an
24   * interaction begins, and `turn.start`, for a turn nothing submitted (a
25   * resumed session's, a scheduled one's).
26   */
27  opensTurn?: true;
28  /** On `turn.start`: the turn's id, stamped on the open turn's root. */
29  turnId?: string;
30};
31
32/** A dispatch as it settles: the chain beneath, and the failure if it threw. */
33export type DispatchEnd = {
34  trace: readonly Link[];
35  error?: string;
36  /**
37   * On `tool.call`: the result was `{ deny }`, so a hook answered for the
38   * tool. Read off the result, not the chain: a hooks module that passes a
39   * result through reports `returned` too, since results cross as copies.
40   */
41  denied?: boolean;
42  /** On `prompt.submit`: the result was `{ drop }`; no turn will follow. */
43  promptDropped?: string;
44  /**
45   * On `turn.complete`: how the turn ended. Closes the open turn's trace when
46   * it is that turn's; a subagent's (`agentId` set) closes nothing.
47   */
48  turnComplete?: {
49    turnId: string;
50    agentId?: string;
51    reason: string;
52    isAborted: boolean;
53    durationMs: number;
54  };
55};
56
57/** What the `ToolUse` row badge shows for a call once it has settled. */
58export type ToolTiming = {
59  ms: number;
60  /** A hook answered the call itself, rather than the tool. */
61  denied: boolean;
62};
63
64export type Recorder = {
65  /** Records one dispatch: returns what to call once it settles. */
66  begin: (start: DispatchStart) => (end: DispatchEnd) => void;
67  /** The settled timing of a tool call, once its dispatch has ended. */
68  timingFor: (toolUseId: string) => ToolTiming | undefined;
69  /** The `$.autotel` noun, its spans placed the way dispatches are. */
70  autotel: Autotel;
71  /** Posts everything queued now: a turn's end, a session's end. */
72  flush: () => Promise<void>;
73};
74
75/**
76 * One turn's trace: its root span stays open from `prompt.submit` (or
77 * `turn.start`) to the `turn.complete` that names it. `id` is known once
78 * `turn.start` has run.
79 */
80type Turn = {
81  traceId: string;
82  root: OpenSpan;
83  id?: string;
84};
85
86/**
87 * The `deny` a link's returned value carries, if it is one. Results cross
88 * the chain as plain data; a `{ deny: reason }` is the one shape that means
89 * a hook answered for the tool.
90 */
91function returnedDenial(returned: Link['returned']): string | undefined {
92  if (Object(returned) !== returned) return undefined;
93  // SAFETY: an object; `deny` is read out and checked for being a string.
94  const { deny } = returned as { deny?: unknown };
95  return String(deny) === deny ? String(deny) : undefined;
96}
97
98/**
99 * The link that decided a denied dispatch: follow the final denial down
100 * through contiguous forwarding links, and stop when the downstream result
101 * changes. A logger passing a guard's denial up shares that denial with
102 * every link above the guard; an outer policy that replaces a success (or
103 * an overridden inner denial) beneath it is where the final denial starts.
104 * Hooks bypassed by `next.to` stay in the chain as `skipped` with no
105 * result — ignore them when reading the first result and when walking.
106 * When the first real result is not the denial, omit the name rather than
107 * guess from a deeper, overridden carrier.
108 */
109function decidedBy(trace: readonly Link[]): Link | undefined {
110  let finalDenial: string | undefined;
111  let decider: Link | undefined;
112  for (const link of trace) {
113    if (link.outcome === 'skipped') continue;
114    const denial = returnedDenial(link.returned);
115    if (finalDenial === undefined) {
116      if (denial === undefined) return undefined;
117      finalDenial = denial;
118    } else if (denial !== finalDenial) {
119      break;
120    }
121    if (link.plugin === 'engine') break;
122    decider = link;
123  }
124  return decider;
125}
126
127function linkAttributes(link: Link): Attributes {
128  const attributes = {
129    'plugin.name': link.plugin,
130    'claude_code.hook.tier': link.tier,
131    'claude_code.hook.outcome': link.outcome,
132    duration_ms: link.ms,
133  };
134  return link.reason === undefined
135    ? attributes
136    : { ...attributes, 'claude_code.hook.reason': link.reason };
137}
138
139/** The badge text for a settled tool call. */
140export function badge(timing: ToolTiming): string {
141  const ms =
142    timing.ms < 1000
143      ? `${Math.round(timing.ms)}ms`
144      : `${(timing.ms / 1000).toFixed(2)}s`;
145  return timing.denied ? ` denied by hook · ${ms}` : ` ${ms}`;
146}
147
148/** A span for callers with nothing to export to: the body still runs. */
149const NO_SPAN = {
150  setAttribute: () => {},
151  setAttributes: () => {},
152  addEvent: () => {},
153};
154
155/**
156 * @param exporter where spans go; absent, the recorder is inert and
157 *   `$.autotel.span` only runs its body
158 */
159export function createRecorder(exporter: Exporter | undefined): Recorder {
160  let turn: Turn | undefined;
161  const timings = new Map<string, ToolTiming>();
162
163  function contextFor(): SpanContext {
164    return turn === undefined
165      ? { traceId: newId(16) }
166      : { traceId: turn.traceId, parentSpanId: turn.root.spanId };
167  }
168
169  function begin(start: DispatchStart): (end: DispatchEnd) => void {
170    if (exporter === undefined) return () => {};
171    const live = exporter;
172
173    // The root this dispatch opened, if it did: a prompt queued while a turn
174    // runs opens nothing, and its rejection must close nothing.
175    let opened: Turn | undefined;
176    if (start.opensTurn && turn === undefined) {
177      const traceId = newId(16);
178      opened = { traceId, root: live.start('claude_code.turn', { traceId }) };
179      turn = opened;
180    }
181    if (start.turnId !== undefined && turn !== undefined) {
182      turn.id = start.turnId;
183      turn.root.setAttribute('claude_code.turn.id', start.turnId);
184    }
185
186    const tool = start.tool;
187    const span = live.start(`claude_code.${start.event}`, contextFor(), {
188      'claude_code.event': start.event,
189      'plugin.name': start.origin.plugin,
190      'claude_code.tier': start.origin.tier,
191    });
192    if (tool !== undefined) {
193      span.setAttributes({ tool_name: tool.name, tool_use_id: tool.useId });
194      if (tool.agentId !== undefined)
195        span.setAttribute('agent_id', tool.agentId);
196    }
197    const startedMs = Date.now();
198
199    return (end) => {
200      for (const link of end.trace) span.addEvent('hook', linkAttributes(link));
201      const denied = end.denied === true;
202      const decider = denied ? decidedBy(end.trace) : undefined;
203      if (decider !== undefined)
204        span.setAttribute('claude_code.decided_by', decider.plugin);
205      span.end(end.error === undefined ? {} : { error: end.error });
206
207      if (tool !== undefined)
208        timings.set(tool.useId, { ms: Date.now() - startedMs, denied });
209
210      const dropped = end.promptDropped ?? end.error;
211      if (opened !== undefined && dropped !== undefined && turn === opened) {
212        opened.root.setAttribute('claude_code.prompt.dropped', dropped);
213        opened.root.end(end.error === undefined ? {} : { error: end.error });
214        turn = undefined;
215        void live.flush();
216      }
217
218      const complete = end.turnComplete;
219      if (
220        complete !== undefined &&
221        complete.agentId === undefined &&
222        turn !== undefined &&
223        (turn.id === undefined || turn.id === complete.turnId)
224      ) {
225        turn.root.setAttributes({
226          'claude_code.turn.reason': complete.reason,
227          'claude_code.turn.aborted': complete.isAborted,
228          duration_ms: complete.durationMs,
229        });
230        turn.root.end(
231          complete.reason === 'error' ? { error: 'turn ended in error' } : {},
232        );
233        turn = undefined;
234        void live.flush();
235      }
236    };
237  }
238
239  const autotel: Autotel = {
240    span: async (name, fn, attributes) => {
241      if (exporter === undefined) return fn(NO_SPAN);
242      const span = exporter.start(name, contextFor(), attributes);
243      try {
244        const result = await fn(span);
245        span.end();
246        return result;
247      } catch (error) {
248        span.end({ error: String(error) });
249        throw error;
250      }
251    },
252  };
253
254  return {
255    begin,
256    timingFor: (id) => timings.get(id),
257    autotel,
258    flush: () => exporter?.flush() ?? Promise.resolve(),
259  };
260}
261
types/index.d.ts 60 lines
1/**
2 * The `$.autotel` noun as every caller sees it: the one contract for the noun,
3 * its types exported here and the noun declared on `EngineInterface`.
4 *
5 * The autotel mod adds the noun in the `engine.create` fold. A plugin that
6 * calls it reads these types by including this folder in its tsconfig, the way
7 * Claude Code's own `telemetry` mod publishes `$.telemetry`.
8 */
9
10/** An attribute value as OTLP carries it. Nested data is JSON-encoded by the caller. */
11export type AttributeValue = string | number | boolean;
12
13/** Attributes on a span or an event, by OpenTelemetry attribute key. */
14export type Attributes = Readonly<Record<string, AttributeValue>>;
15
16/**
17 * The span handed to `$.autotel.span`'s body: set attributes as facts arrive,
18 * record point-in-time events. It ends when the body settles; a thrown error
19 * marks it as failed with the error's message.
20 */
21export type Span = {
22  setAttribute: (key: string, value: AttributeValue) => void;
23  setAttributes: (attributes: Attributes) => void;
24  addEvent: (name: string, attributes?: Attributes) => void;
25};
26
27/**
28 * Instrumentation for plugins, exported over OTLP/HTTP to the endpoint
29 * `OTEL_EXPORTER_OTLP_ENDPOINT` names (`autotel-devtools claude` sets it).
30 *
31 * A span started inside a turn joins that turn's trace, beside the hook
32 * dispatches the mod records itself; outside a turn it is a trace of its own.
33 */
34export type Autotel = {
35  /**
36   * Runs `fn` inside a span named `name`, ended when `fn` settles.
37   *
38   * @example
39   * const summary = await $.autotel.span('summarise', async span => {
40   *   span.setAttribute('gen_ai.request.model', model)
41   *   return $.model.complete(request)
42   * }, { 'plugin.feature': 'summary' })
43   */
44  span: <T>(
45    name: string,
46    fn: (span: Span) => Promise<T> | T,
47    attributes?: Attributes,
48  ) => Promise<T>;
49};
50
51declare module 'claude-code' {
52  interface EngineInterface {
53    /**
54     * Plugin instrumentation as OpenTelemetry spans; present where the autotel
55     * mod is seated, absent everywhere else.
56     */
57    autotel: Autotel;
58  }
59}
60