SLOPSHOPPER

mod-events

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

newprocess
★ 17v?MITupdated 2026-10-07bendrucker/claude/plugins/mod-events
A shopper browsing a rack in a slop shop
README

Mod Events

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.

  • Mod: 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: types/index.d.ts declares $.modEvents.emit.

Emitting

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.

Format

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.

Collector

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 }));

Tests

bun scripts/mod-test.ts mod-events runs the mod's tests.

Source 2 files
mod/register.ts 218 lines
1import 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}
218
types/index.d.ts 46 lines
1/**
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