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…

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.
claude_code.turn root opens at prompt.submit and closes at turn.complete, carrying the turn id, its reason and duration.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.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.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 authorsThe 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.
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
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.
$ 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.hooks/register.ts 157 lines1import 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}
157hooks/otlp.ts 220 lines1import 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}
220hooks/recorder.ts 261 lines1import 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}
261types/index.d.ts 60 lines1/**
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