Structured event log for mods: `$.modEvents.emit` writes per-session OTel log records as JSONL

Telemetry for mods. It adds $.modEvents.emit, which writes each mod's events to per-session JSONL. Each line is an OpenTelemetry log record, so a collector can tail the files, and the observability:session index ingests them as mod_events.
register.ts provides emit and records at session start whether the session is local or reached over mosh or ssh, with each attached herdr client's reach. Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.types/index.d.ts declares $.modEvents.emit.A mod lists "dependencies": ["mod-events"] in its plugin.json, adds "../../mod-events/types" to its mod/tsconfig.json include, and calls:
void $.modEvents.emit({ mod: "herdr", event: "herdr.call", ok: exitCode === 0, ms, detail: { command, exitCode, stderr } });
emit never rejects. Without this plugin enabled, the default emit drops events. Every mod emits session.start from its session.start hook, so a query can tell whether it was live.
Records land in <config>/mod-events/<session>/<mod>.<instance>.<n>.jsonl, <config> being CLAUDE_CONFIG_DIR or ~/.claude. <instance> is the first event's timestamp, so a reload starts new files, and <n> rolls at 64 KB.
Each line is one OTel log record with the data model's field names, flat rather than wrapped in an OTLP resourceLogs envelope:
{"timestamp":"2026-10-06T17:00:00.012Z","severity_text":"WARN","severity_number":13,"event_name":"herdr.herdr.call","attributes":{"command":"pane.report-agent","exitCode":1,"stderr":"…","duration_ms":12,"session.id":"<session-id>"},"resource":{"service.name":"claude-code","service.version":"2.1.291","claude_code.surface":"mosh"},"scope":{"name":"herdr"}}
timestamp is ISO 8601 in UTC, like Claude Code's own event.timestamp.severity_text is INFO (9), or WARN (13) when ok is false. A failing mod degrades the session without ending it, so nothing logs ERROR.event_name is <mod>.<event>.attributes holds detail, plus duration_ms from ms and session.id. Both keys match Claude Code's native OTel events, so a backend can join a mod's records to the session's own telemetry.resource carries service.name (claude-code, as the native exporter uses) and service.version. Once the session's heartbeat has judged it, it also carries claude_code.surface (local, mosh, or ssh).scope.name is the mod. The engine doesn't expose a calling plugin's version, so scope has no version.$.fs can only replace a file whole, so each event rewrites its chunk. The new text always extends the old, and a rolled chunk is never written again, so a tailer's fingerprint and offset stay valid. $.fs.write truncates and then writes in place rather than renaming a temp file over the chunk, and $.fs has no rename. A tailer polling inside that window reads an empty file. The collector's filelog receiver skips a file whose fingerprint is empty, then resumes at its offset once the bytes are back.
An otelcol-contrib filelog receiver maps each line into a log record. event_name lands as the event.name attribute, as Claude Code's own events carry it:
receivers:
filelog/mod_events:
include: ["${env:HOME}/.claude/mod-events/**/*.jsonl"]
start_at: beginning
operators:
- type: json_parser
parse_to: body
timestamp:
parse_from: body.timestamp
layout_type: gotime
layout: "2006-01-02T15:04:05.000Z07:00"
severity:
parse_from: body.severity_text
scope_name:
parse_from: body.scope.name
- type: move
from: body.attributes
to: attributes
- type: move
from: body.event_name
to: attributes["event.name"]
- type: move
from: body.resource
to: resource
- type: remove
field: body
Retention deletes the oldest files once the directory passes 500 MB. The session index keeps rows it has already ingested.
In a test, stub $.modEvents from your own engine.create hook and collect events at modEvents.emit:
on("engine.create", async ($, e, next) => ({ ...(await next(e)), modEvents: { emit: () => Promise.resolve() } }));
on("modEvents.emit", ($, e) => (events.push(e), { value: undefined }));
bun scripts/mod-test.ts mod-events runs the mod's tests.
mod/register.ts 218 lines1import type { EngineInterface, On } from "claude-code";
2import type { ModEventsInput, ModEventsRecord } from "../types";
3
4const MOD = "mod-events";
5const CHUNK_BYTES = 64 * 1024;
6
7interface Chunk {
8 path: string;
9 text: string;
10 version: number;
11 isWriting: boolean;
12}
13
14interface Log {
15 root: string | undefined;
16 instance: number | undefined;
17 resource: Record<string, string> | undefined;
18 chunks: Map<string, { chunk: Chunk; n: number }>;
19}
20
21export type Surface = "local" | "mosh" | "ssh";
22
23interface Process {
24 pid: number;
25 ppid: number;
26 name: string;
27}
28
29function parsePs(stdout: string): Map<number, Process> {
30 const table = new Map<number, Process>();
31 for (const line of stdout.split("\n")) {
32 const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
33 if (match === null) continue;
34 const [, pid, ppid, command] = match;
35 const name = command?.split("/").at(-1) ?? "";
36 table.set(Number(pid), { pid: Number(pid), ppid: Number(ppid), name });
37 }
38 return table;
39}
40
41function reachOf(table: Map<number, Process>, pid: number): Surface {
42 for (let p = table.get(pid); p !== undefined && p.pid > 1; p = table.get(p.ppid)) {
43 if (p.name === "mosh-server") return "mosh";
44 if (p.name === "sshd" || p.name.startsWith("sshd-")) return "ssh";
45 }
46 return "local";
47}
48
49function summarize(clients: Surface[], isSsh: boolean): Surface {
50 if (clients.includes("mosh")) return "mosh";
51 if (isSsh || clients.includes("ssh")) return "ssh";
52 return "local";
53}
54
55/**
56 * Classifies each attached herdr client by its ancestry. The server is the
57 * `herdr` process whose parent is launchd, and every other one is a client.
58 */
59export function surfaceOf(
60 ps: string | undefined,
61 sshConnection: string | undefined,
62): { surface: Surface; clients: Surface[] } {
63 const clients: Surface[] = [];
64 if (ps !== undefined) {
65 const table = parsePs(ps);
66 for (const p of table.values()) {
67 if (p.name === "herdr" && p.ppid > 1) clients.push(reachOf(table, p.pid));
68 }
69 }
70 // Panes inherit the herdr server's env, so `SSH_CONNECTION` speaks for the session only without clients.
71 const isSsh = clients.length === 0 && sshConnection !== undefined && sshConnection !== "";
72 return { surface: summarize(clients, isSsh), clients: clients.toSorted() };
73}
74
75async function flush($: EngineInterface, chunk: Chunk): Promise<void> {
76 if (chunk.isWriting) return;
77 chunk.isWriting = true;
78 try {
79 let written: number;
80 do {
81 written = chunk.version;
82 // oxlint-disable-next-line no-await-in-loop -- each rewrite must land before the next, or an older text could win.
83 await $.fs.write(chunk.path, chunk.text);
84 } while (written !== chunk.version);
85 } finally {
86 chunk.isWriting = false;
87 }
88}
89
90async function rootOf($: EngineInterface, log: Log): Promise<string | undefined> {
91 if (log.root !== undefined) return log.root;
92 const [config, home] = await Promise.all([$.env.get("CLAUDE_CONFIG_DIR"), $.env.get("HOME")]);
93 if (config !== undefined && config !== "") log.root = `${config}/mod-events`;
94 else if (home !== undefined) log.root = `${home}/.claude/mod-events`;
95 return log.root;
96}
97
98const fileSafe = (name: string) => name.replaceAll(/[^\w.-]/g, "_");
99
100async function resourceOf($: EngineInterface, log: Log): Promise<Record<string, string>> {
101 log.resource ??= {
102 "service.name": "claude-code",
103 "service.version": (await $.session.version()).version,
104 };
105 return log.resource;
106}
107
108function toRecord(
109 input: ModEventsInput,
110 ts: number,
111 session: string,
112 resource: Record<string, string>,
113): ModEventsRecord {
114 const isOk = input.ok ?? true;
115 return {
116 timestamp: new Date(ts).toISOString(),
117 severity_text: isOk ? "INFO" : "WARN",
118 severity_number: isOk ? 9 : 13,
119 event_name: `${input.mod}.${input.event}`,
120 attributes: {
121 ...input.detail,
122 ...(input.ms !== undefined && { duration_ms: input.ms }),
123 "session.id": session,
124 },
125 resource,
126 scope: { name: input.mod },
127 };
128}
129
130async function record($: EngineInterface, log: Log, input: ModEventsInput): Promise<void> {
131 const [root, ts, session, resource] = await Promise.all([
132 rootOf($, log),
133 $.clock.now(),
134 $.session.id(),
135 resourceOf($, log),
136 ]);
137 if (root === undefined) return;
138 log.instance ??= ts;
139 const line = toRecord(input, ts, session, resource);
140 const text = `${JSON.stringify(line)}\n`;
141 const key = `${session}/${fileSafe(input.mod)}`;
142 let slot = log.chunks.get(key);
143 if (
144 slot === undefined ||
145 (slot.chunk.text !== "" && slot.chunk.text.length + text.length > CHUNK_BYTES)
146 ) {
147 const n = slot === undefined ? 0 : slot.n + 1;
148 slot = {
149 n,
150 chunk: {
151 path: `${root}/${key}.${log.instance}.${n}.jsonl`,
152 text: "",
153 version: 0,
154 isWriting: false,
155 },
156 };
157 log.chunks.set(key, slot);
158 }
159 slot.chunk.text += text;
160 slot.chunk.version += 1;
161 await flush($, slot.chunk);
162}
163
164async function emit($: EngineInterface, log: Log, input: ModEventsInput): Promise<void> {
165 try {
166 await record($, log, input);
167 } catch (error) {
168 $.ui.log(`mod-events: ${input.mod} ${input.event} not recorded: ${String(error)}`, {
169 to: "debug",
170 });
171 }
172}
173
174async function readSurface($: EngineInterface): Promise<ReturnType<typeof surfaceOf>> {
175 const [ssh, ps] = await Promise.all([
176 $.env.get("SSH_CONNECTION"),
177 $.process
178 .run(["ps", "-axo", "pid=,ppid=,comm="], { timeoutMs: 5000 })
179 .then((r) => (r.exitCode === 0 ? r.stdout : undefined))
180 .catch(() => undefined),
181 ]);
182 return surfaceOf(ps, ssh);
183}
184
185/**
186 * Seats `$.modEvents` for every mod that depends on this plugin and writes
187 * what they emit as OTel log records to
188 * `<config>/mod-events/<session>/<mod>.<instance>.<n>.jsonl`. A chunk is
189 * rewritten whole on each event, since `$.fs` has no append, so chunks roll at
190 * 64 KB and a reload starts a new instance. Each rewrite only appends, and a
191 * rolled chunk is never written again, so a tailer's offset stays valid.
192 */
193export function register(on: On): void {
194 const log: Log = { root: undefined, instance: undefined, resource: undefined, chunks: new Map() };
195
196 on("engine.create", async ($, e, next) => {
197 const built = await next(e);
198 return { ...built, modEvents: { emit: () => Promise.resolve() } };
199 });
200
201 on("modEvents.emit", async ($, e, next) => {
202 await emit($, log, e);
203 return next(e);
204 }).catch(($, e, next) => (next.called ? next(e) : { value: undefined }));
205
206 on("session.start", async ($, e, next) => {
207 const started = await next(e);
208 const { surface, clients } = await readSurface($);
209 log.resource = { ...(await resourceOf($, log)), "claude_code.surface": surface };
210 await emit($, log, {
211 mod: MOD,
212 event: "session.start",
213 detail: { clients, surfaceKind: e.surface, isInteractive: e.isInteractive },
214 });
215 return started;
216 });
217}
218types/index.d.ts 46 lines1/**
2 * What a mod passes to `$.modEvents.emit`.
3 */
4export interface ModEventsInput {
5 /** The emitting plugin's name. */
6 mod: string;
7 /** What happened, dotted: `session.start`, `herdr.call`, `pick`. */
8 event: string;
9 /** False when what the event records failed. Defaults to true. */
10 ok?: boolean;
11 /** How long it took, for an event with a duration. */
12 ms?: number;
13 /** Event-specific fields, as JSON data. */
14 detail?: Record<string, unknown>;
15}
16
17/**
18 * One line of `<config>/mod-events/<session>/<mod>.<instance>.<n>.jsonl`: an
19 * OTel log record with its fields flat, the shape the session index ingests as
20 * `mod_events`.
21 */
22export interface ModEventsRecord {
23 /** ISO 8601, as Claude Code's own `event.timestamp`. */
24 timestamp: string;
25 severity_text: "INFO" | "WARN";
26 severity_number: 9 | 13;
27 /** `<mod>.<event>`. */
28 event_name: string;
29 /** The event's detail, plus `duration_ms` and `session.id`. */
30 attributes: Record<string, unknown>;
31 /** `service.name`, `service.version`, and `claude_code.surface` once known. */
32 resource: Record<string, string>;
33 scope: { name: string };
34}
35
36export interface ModEvents {
37 /** Records one event. Never rejects, so a caller need not await it. */
38 emit: (event: ModEventsInput) => Promise<void>;
39}
40
41declare module "claude-code" {
42 interface EngineInterface {
43 modEvents: ModEvents;
44 }
45}
46