diff --git a/scripts/smoke/verify-trace-registry.mjs b/scripts/smoke/verify-trace-registry.mjs new file mode 100644 index 0000000..5939c32 --- /dev/null +++ b/scripts/smoke/verify-trace-registry.mjs @@ -0,0 +1,111 @@ +// Verify the trace registry records one entry per turn at handleStop, that +// `recentTurns(N)` returns them, that `turnsForSession(id)` filters +// correctly, and that `findByTracePrefix(prefix)` resolves partial trace ids. +// +// We point CONFIG_DIR at a tmp directory by overriding HOME for the +// duration of the test — the registry module computes its file path from +// `os.homedir()` via setup.ts:CONFIG_DIR, so flipping HOME is the +// lightest-touch isolation. + +import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir, homedir } from 'os'; +import { join } from 'path'; + +// HOME must be redirected BEFORE we import anything that reads CONFIG_DIR. +const realHome = homedir(); +const fakeHome = mkdtempSync(join(tmpdir(), 'wcp-registry-test-')); +process.env.HOME = fakeHome; + +const distUrl = (rel) => new URL(`../../dist/${rel}`, import.meta.url).href; +const { GlobalDaemon } = await import(distUrl('daemon.js')); +const { recentTurns, turnsForSession, findByTracePrefix, REGISTRY_FILE } = await import(distUrl('traceRegistry.js')); + +// Confirm the registry path is inside the fake home, not the user's real one. +if (!REGISTRY_FILE.startsWith(fakeHome)) { + console.error(`FAIL: REGISTRY_FILE=${REGISTRY_FILE} not under fakeHome=${fakeHome} — would clobber the real registry. Aborting.`); + process.exit(1); +} + +const exporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ + resource: resourceFromAttributes({ 'service.name': 'claude-code', 'service.version': 'test' }), + spanProcessors: [new SimpleSpanProcessor(exporter)], +}); +provider.register(); + +const sessionA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const sessionB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +// Tiny parent transcripts so SessionStart doesn't fail. +const projDir = join(fakeHome, '.claude/projects/-tmp-registry'); +mkdirSync(projDir, { recursive: true }); +const aPath = join(projDir, `${sessionA}.jsonl`); +const bPath = join(projDir, `${sessionB}.jsonl`); +writeFileSync(aPath, JSON.stringify({ type: 'summary', sessionId: sessionA }) + '\n'); +writeFileSync(bPath, JSON.stringify({ type: 'summary', sessionId: sessionB }) + '\n'); + +const daemon = new GlobalDaemon('/tmp/unused.sock', join(tmpdir(), 'registry-test.log'), 'me/proj', 'unused', 'http://unused', false); +daemon.tracer = provider.getTracer('weave-claude-plugin', 'test'); +daemon.provider = provider; + +// Drive 2 turns for session A and 1 turn for session B. +async function runTurn(sessionId, transcriptPath) { + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sessionId, transcript_path: transcriptPath, source: 'startup', cwd: '/tmp/example-cwd' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'p' }); + await daemon.routeEvent({ hook_event_name: 'Stop', session_id: sessionId, last_assistant_message: 'done' }); +} + +await runTurn(sessionA, aPath); +await runTurn(sessionA, aPath); +await runTurn(sessionB, bPath); +await provider.forceFlush(); + +// --- Assertions --- +let fails = 0; +const check = (label, cond) => { console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`); if (!cond) fails++; }; + +// 1. Registry file exists. +check('registry file written', existsSync(REGISTRY_FILE)); + +// 2. recentTurns returns 3 entries, newest last. +const rec = recentTurns(10); +check('recentTurns(10) returns 3 entries', rec.length === 3); + +// 3. Each entry has a non-empty 32-char trace id. +check('every entry has 32-char trace id', rec.every(t => /^[0-9a-f]{32}$/i.test(t.traceId))); + +// 4. recentTurns(2) returns just the last 2. +const last2 = recentTurns(2); +check('recentTurns(2) returns last 2', last2.length === 2 && last2[0].traceId === rec[1].traceId && last2[1].traceId === rec[2].traceId); + +// 5. turnsForSession(A) returns 2; for B returns 1. +const aTurns = turnsForSession(sessionA); +const bTurns = turnsForSession(sessionB); +check('session A has 2 turns', aTurns.length === 2); +check('session B has 1 turn', bTurns.length === 1); + +// 6. findByTracePrefix using the first 8 hex of an entry resolves to it. +const target = rec[1]; +const found = findByTracePrefix(target.traceId.slice(0, 8)); +check('findByTracePrefix(8-char prefix) returns matching entry', found?.traceId === target.traceId); + +// 7. findByTracePrefix with a non-matching prefix returns undefined. +check('findByTracePrefix(zzz) returns undefined', findByTracePrefix('zzzzzzzz') === undefined); + +// 8. cwd is captured. +check('cwd captured on every entry', rec.every(t => t.cwd === '/tmp/example-cwd')); + +// 9. File is valid JSON with the expected schema version. +const file = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8')); +check('registry schema version >= 3', file.version >= 3); +check('registry entries is an array', Array.isArray(file.entries)); + +// Cleanup +process.env.HOME = realHome; +rmSync(fakeHome, { recursive: true, force: true }); + +console.log(`\n${rec.length} turns recorded; ${fails === 0 ? 'all checks passed' : `${fails} failures`}`); +process.exit(fails === 0 ? 0 : 1); diff --git a/src/cli.ts b/src/cli.ts index 4ded5da..a29085a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ import { } from './setup.js'; import { prompt, sendToSocket } from './utils.js'; import { runDaemon } from './daemon.js'; +import { findByTracePrefix, recentTurns, turnsForSession } from './traceRegistry.js'; // --------------------------------------------------------------------------- // Help @@ -43,6 +44,7 @@ Commands: config Manage configuration (show | get | set ) status Check installation status logs Display daemon logs (--tail N, --follow) + trace Inspect recent turn trace ids (recent | lookup) daemon Start the background daemon (used by hook handler) uninstall Remove the plugin and all associated files @@ -57,6 +59,9 @@ Examples: weave-claude-plugin config set weave_project my-entity/my-project weave-claude-plugin status weave-claude-plugin logs --tail 100 + weave-claude-plugin trace recent --limit 10 + weave-claude-plugin trace lookup --session + weave-claude-plugin trace lookup --trace `.trim(); // --------------------------------------------------------------------------- @@ -418,6 +423,92 @@ async function cmdLogs(tail: number, follow: boolean): Promise { } } +// --------------------------------------------------------------------------- +// trace +// --------------------------------------------------------------------------- + +const TRACE_HELP = ` +weave-claude-plugin trace + +Actions: + recent [--limit N] Show the last N turn entries (default 20) + lookup --session List all turns for a session id + lookup --trace Resolve a (partial) trace id to its turn +`.trim(); + +function fmtTurn(t: { sessionId: string; turnNumber: number; traceId: string; conversationId: string; startedAt: string; endedAt: string; toolCount: number; subagentCount: number; cwd?: string }): string { + // Short-form one-line output. trace_id is the load-bearing column; + // the rest is contextual breadcrumbs. + return [ + t.startedAt, + `trace=${t.traceId}`, + `session=${t.sessionId.slice(0, 8)}…`, + `turn=${t.turnNumber}`, + `conv=${t.conversationId.slice(0, 8)}…`, + `tools=${t.toolCount}`, + t.subagentCount > 0 ? `sub=${t.subagentCount}` : '', + ].filter(Boolean).join(' '); +} + +async function cmdTrace(args: string[]): Promise { + const action = args[0]; + if (!action || action === '--help' || action === '-h') { + console.log(TRACE_HELP); + return; + } + + if (action === 'recent') { + let limit = 20; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--limit' && args[i + 1]) { + const n = parseInt(args[i + 1]!, 10); + if (Number.isFinite(n) && n > 0) limit = n; + i++; + } + } + const turns = recentTurns(limit); + if (turns.length === 0) { + console.log('(no turns recorded yet — trigger a turn through Claude Code first)'); + return; + } + for (const t of turns) console.log(fmtTurn(t)); + return; + } + + if (action === 'lookup') { + // Flag parsing: --session OR --trace + let sessionId: string | undefined; + let tracePrefix: string | undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--session' && args[i + 1]) { sessionId = args[i + 1]; i++; } + else if (args[i] === '--trace' && args[i + 1]) { tracePrefix = args[i + 1]; i++; } + } + if (sessionId) { + const turns = turnsForSession(sessionId); + if (turns.length === 0) { + console.log(`(no turns recorded for session=${sessionId})`); + return; + } + for (const t of turns) console.log(fmtTurn(t)); + return; + } + if (tracePrefix) { + const t = findByTracePrefix(tracePrefix); + if (!t) { + console.error(`No turn found with trace id prefix '${tracePrefix}'`); + process.exit(1); + } + console.log(fmtTurn(t)); + return; + } + console.error('lookup requires --session or --trace '); + process.exit(1); + } + + console.error(`Unknown trace action: ${action}\nRun 'weave-claude-plugin trace --help' for usage.`); + process.exit(1); +} + // --------------------------------------------------------------------------- // uninstall // --------------------------------------------------------------------------- @@ -550,6 +641,11 @@ async function main(): Promise { return; } + if (cmd === 'trace') { + await cmdTrace(args.slice(1)); + return; + } + if (cmd === 'daemon') { await runDaemon(); return; diff --git a/src/daemon.ts b/src/daemon.ts index 1db8345..fa1ed18 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -19,6 +19,7 @@ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; import { loadSettings, VERSION } from './setup.js'; +import { recordTurn } from './traceRegistry.js'; import { appendToLog, deepEqual } from './utils.js'; import { parseSessionFd } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; @@ -1036,9 +1037,29 @@ export class GlobalDaemon { } session.currentTurnSpan.setAttribute(ATTR.WEAVE_TURN_TOOL_COUNT, session.turnToolCalls); + const turnSpanCtx = session.currentTurnSpan.spanContext(); + const turnStartedAt = (() => { + const raw = (session.currentTurnSpan as unknown as { startTime?: [number, number] }).startTime; + if (!Array.isArray(raw)) return new Date().toISOString(); + return new Date(raw[0] * 1000 + Math.floor(raw[1] / 1e6)).toISOString(); + })(); session.currentTurnSpan.end(); session.currentTurnSpan = undefined; + // Record a local breadcrumb so `weave-claude-plugin trace recent` can + // surface the trace_id for this turn without needing DEBUG-level logs. + recordTurn({ + sessionId, + turnNumber: session.turnNumber, + traceId: turnSpanCtx.traceId, + conversationId: session.conversationId, + startedAt: turnStartedAt, + endedAt: new Date().toISOString(), + toolCount: session.turnToolCalls, + subagentCount: session.subagents.size(), + cwd: session.cwd, + }); + this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`); } diff --git a/src/traceRegistry.ts b/src/traceRegistry.ts new file mode 100644 index 0000000..1aa3f30 --- /dev/null +++ b/src/traceRegistry.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-plugin + +import * as fs from 'fs'; +import * as path from 'path'; +import { CONFIG_DIR } from './setup.js'; + +/** + * Per-turn trace registry: the local breadcrumb trail for "what's the + * trace_id for the turn I just had?" + * + * After the May-14 `drop session span, flatten subagents` refactor, each + * turn is its own root trace, but the daemon doesn't surface its trace_id + * anywhere a user can see by default (the DEBUG log line stamps it, but + * DEBUG is off). When a user reports "this turn didn't show up correctly + * in Weave" we'd need a trace_id to investigate — without one, the only + * recourse is full-corpus query filtering. + * + * This module appends one entry per Stop hook (each Claude Code turn) to + * a JSON file in CONFIG_DIR. The file is bounded by entry count (oldest + * dropped first) so it never grows unboundedly. CLI commands surface the + * tail and let users filter by session. + */ + +export const REGISTRY_FILE = path.join(CONFIG_DIR, 'trace-registry.json'); + +/** Hard cap on stored entries. Older entries are FIFO-evicted on write. */ +const MAX_ENTRIES = 1000; + +/** Bump when the on-disk shape changes incompatibly. */ +const SCHEMA_VERSION = 3; + +export interface TurnEntry { + /** Claude Code session id (changes per resume; many turns share one). */ + sessionId: string; + /** Monotonic turn counter within the session (1-based). */ + turnNumber: number; + /** OTel trace id (hex). Each turn is its own root trace. */ + traceId: string; + /** Cross-resume stitching key — `gen_ai.conversation.id` on every span. */ + conversationId: string; + /** ISO timestamps from the daemon's perspective (turn span start/end). */ + startedAt: string; + endedAt: string; + /** Counts captured from the session state at Stop time. */ + toolCount: number; + subagentCount: number; + /** Working directory the session was started in — handy as a label. */ + cwd?: string; +} + +interface RegistryFile { + version: number; + entries: TurnEntry[]; +} + +function readFile(): RegistryFile { + try { + const text = fs.readFileSync(REGISTRY_FILE, 'utf8'); + const parsed = JSON.parse(text) as Partial; + if (parsed && typeof parsed === 'object' && parsed.version === SCHEMA_VERSION && Array.isArray(parsed.entries)) { + return parsed as RegistryFile; + } + } catch { + // Missing, unreadable, or older schema — start fresh. We treat the + // registry as best-effort breadcrumbs, not durable state, so silently + // resetting is acceptable. + } + return { version: SCHEMA_VERSION, entries: [] }; +} + +function writeFile(state: RegistryFile): void { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + // Atomic-ish: write to tmp then rename. Rename is atomic within the + // same filesystem on POSIX, so a concurrent reader sees either the old + // file or the new one — never a half-written file. + const tmp = REGISTRY_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 }); + fs.renameSync(tmp, REGISTRY_FILE); + } catch { + // Failing to persist a breadcrumb is non-fatal; the daemon already + // logs its own trace_id at DEBUG and the turn span itself is exported. + } +} + +/** + * Append a turn entry. Best-effort: silent on I/O errors. + * + * Bounded by `MAX_ENTRIES` — oldest entries drop FIFO so the file stays + * small. Called synchronously from `handleStop` (which is already async + * and not on a latency-critical path), so we don't bother with batching. + */ +export function recordTurn(entry: TurnEntry): void { + const state = readFile(); + state.entries.push(entry); + if (state.entries.length > MAX_ENTRIES) { + state.entries.splice(0, state.entries.length - MAX_ENTRIES); + } + writeFile(state); +} + +/** Return the last `limit` entries (newest last). */ +export function recentTurns(limit: number): TurnEntry[] { + const { entries } = readFile(); + if (limit <= 0) return []; + return entries.slice(-limit); +} + +/** Return every entry for a given session id, oldest first. */ +export function turnsForSession(sessionId: string): TurnEntry[] { + const { entries } = readFile(); + return entries.filter((e) => e.sessionId === sessionId); +} + +/** Return the most recent entry whose trace_id starts with `prefix`. */ +export function findByTracePrefix(prefix: string): TurnEntry | undefined { + if (!prefix) return undefined; + const { entries } = readFile(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry && entry.traceId.startsWith(prefix)) return entry; + } + return undefined; +}