Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions scripts/smoke/verify-trace-registry.mjs
Original file line number Diff line number Diff line change
@@ -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);
96 changes: 96 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +44,7 @@ Commands:
config <action> Manage configuration (show | get <key> | set <key> <value>)
status Check installation status
logs Display daemon logs (--tail N, --follow)
trace <action> Inspect recent turn trace ids (recent | lookup)
daemon Start the background daemon (used by hook handler)
uninstall Remove the plugin and all associated files

Expand All @@ -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 <session-id>
weave-claude-plugin trace lookup --trace <prefix>
`.trim();

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -418,6 +423,92 @@ async function cmdLogs(tail: number, follow: boolean): Promise<void> {
}
}

// ---------------------------------------------------------------------------
// trace
// ---------------------------------------------------------------------------

const TRACE_HELP = `
weave-claude-plugin trace <action>

Actions:
recent [--limit N] Show the last N turn entries (default 20)
lookup --session <session-id> List all turns for a session id
lookup --trace <prefix> 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<void> {
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 <id> OR --trace <prefix>
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 <id> or --trace <prefix>');
process.exit(1);
}

console.error(`Unknown trace action: ${action}\nRun 'weave-claude-plugin trace --help' for usage.`);
process.exit(1);
}

// ---------------------------------------------------------------------------
// uninstall
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -550,6 +641,11 @@ async function main(): Promise<void> {
return;
}

if (cmd === 'trace') {
await cmdTrace(args.slice(1));
return;
}

if (cmd === 'daemon') {
await runDaemon();
return;
Expand Down
21 changes: 21 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)`);
}

Expand Down
Loading
Loading