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
49 changes: 48 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.41.1",
"uuidv7": "1.2.1"
"uuidv7": "1.2.1",
"weave": "file:../weave/sdks/node"
},
"devDependencies": {
"@types/node": "^18.19.0",
Expand Down
153 changes: 69 additions & 84 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@
DiagConsoleLogger,
DiagLogLevel,
} from '@opentelemetry/api';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import * as weave from 'weave';

Check failure on line 17 in src/daemon.ts

View workflow job for this annotation

GitHub Actions / checks

Cannot find module 'weave' or its corresponding type declarations.
import { loadSettings, VERSION } from './setup.js';
import { appendToLog, deepEqual } from './utils.js';
import { parseSessionFd, extractAssistantTextBlocks } from './parser.js';
Expand Down Expand Up @@ -333,7 +330,7 @@
private readonly inactivityMs = Number(process.env.WEAVE_INACTIVITY_MS) || INACTIVITY_TIMEOUT_MS;
private sessions = new Map<string, SessionState>();
private sessionQueues = new Map<string, Promise<void>>();
private provider: NodeTracerProvider | null = null;
private weaveInitialized = false;
private tracer: Tracer | null = null;
/** Cross-session team correlation, keyed by `${team_name}::${name}`. Bridges
* the coordinator's PreToolUse(Agent) to each teammate's TeammateIdle. The
Expand All @@ -355,16 +352,16 @@
// Initialize the OTel tracer if Weave is configured
if (this.weaveProject && this.apiKey) {
try {
this.initTracer();
this.log('INFO', `OTel tracer initialized project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`);
await this.initTracer();
this.log('INFO', `OTel tracer initialized - project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`);
this.log('INFO', `View traces: https://wandb.ai/${this.weaveProject}/weave/agents`);
} catch (err) {
this.log('ERROR', `Failed to initialize OTel tracer: ${err} continuing without tracing`);
this.provider = null;
this.log('ERROR', `Failed to initialize OTel tracer: ${err} - continuing without tracing`);
this.weaveInitialized = false;
this.tracer = null;
}
} else {
this.log('INFO', 'No weave_project / API key configured tracing disabled');
this.log('INFO', 'No weave_project / API key configured - tracing disabled');
}

// Herd prevention: probe the socket before removing it. Concurrent hook
Expand Down Expand Up @@ -418,40 +415,26 @@

// ── tracer initialization ───────────────────────────────────────────────

private initTracer(): void {
private async initTracer(): Promise<void> {
if (!this.weaveProject) throw new Error('weaveProject required to init tracer');
if (!this.apiKey) throw new Error('apiKey required to init tracer');

const [entity, project] = this.weaveProject.split('/', 2);
if (!entity || !project) {
throw new Error(`Invalid weave_project format: '${this.weaveProject}' (expected entity/project)`);
}

// Route OTel diagnostics into the daemon log so exporter errors surface.
if (this.debugEnabled) {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN);
}

const resource = resourceFromAttributes({
// service.name has always mirrored the agent name; keep that coupling
// so a custom agent_name renames the OTel service too.
'service.name': this.agentName,
'service.version': VERSION,
'wandb.entity': entity,
'wandb.project': project,
});

const exporter = new OTLPTraceExporter({
url: `${this.baseUrl}/agents/otel/v1/traces`,
headers: { 'wandb-api-key': this.apiKey },
});

this.provider = new NodeTracerProvider({
resource,
spanProcessors: [new BatchSpanProcessor(exporter)],
});
this.provider.register();
this.tracer = this.provider.getTracer('weave-claude-code', VERSION);
// The Weave SDK reads WANDB_API_KEY and WANDB_BASE_URL from env when
// building its OTLP exporter target and auth header (see
// weave/sdks/node/src/wandb/settings.ts). weave.login() would also
// perform a synchronous connectivity probe and write to netrc, neither
// of which fit a background daemon. Direct env writes are the
// documented path for programmatic configuration today; revisit if the
// SDK exposes a programmatic settings API.
process.env['WANDB_API_KEY'] = this.apiKey;
process.env['WANDB_BASE_URL'] = this.baseUrl;

await weave.init(this.weaveProject);
this.weaveInitialized = true;
this.tracer = weave.getWeaveTracer('weave-claude-code');
}

// ── connection handling ───────────────────────────────────────────────────
Expand Down Expand Up @@ -529,50 +512,52 @@

this.log('INFO', `${eventName ?? 'unknown'} session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`);

try {
switch (eventName) {
case 'SessionStart':
await this.handleSessionStart(sessionId, payload);
break;
case 'UserPromptSubmit':
await this.handleUserPromptSubmit(sessionId, payload);
break;
case 'PreToolUse':
await this.handlePreToolUse(sessionId, agentId, payload);
break;
case 'PermissionRequest':
await this.handlePermissionRequest(sessionId, payload);
break;
case 'PostToolUse':
await this.handlePostToolUse(sessionId, payload);
break;
case 'PostToolUseFailure':
await this.handlePostToolUseFailure(sessionId, payload);
break;
case 'SubagentStart':
await this.handleSubagentStart(sessionId, payload);
break;
case 'SubagentStop':
await this.handleSubagentStop(sessionId, payload);
break;
case 'TeammateIdle':
await this.handleTeammateIdle(sessionId, payload);
break;
case 'PreCompact':
await this.handlePreCompact(sessionId, payload);
break;
case 'Stop':
await this.handleStop(sessionId, payload);
break;
case 'SessionEnd':
await this.handleSessionEnd(sessionId, payload);
break;
default:
break;
await weave.runIsolated(async () => {
try {
switch (eventName) {
case 'SessionStart':
await this.handleSessionStart(sessionId, payload);
break;
case 'UserPromptSubmit':
await this.handleUserPromptSubmit(sessionId, payload);
break;
case 'PreToolUse':
await this.handlePreToolUse(sessionId, agentId, payload);
break;
case 'PermissionRequest':
await this.handlePermissionRequest(sessionId, payload);
break;
case 'PostToolUse':
await this.handlePostToolUse(sessionId, payload);
break;
case 'PostToolUseFailure':
await this.handlePostToolUseFailure(sessionId, payload);
break;
case 'SubagentStart':
await this.handleSubagentStart(sessionId, payload);
break;
case 'SubagentStop':
await this.handleSubagentStop(sessionId, payload);
break;
case 'TeammateIdle':
await this.handleTeammateIdle(sessionId, payload);
break;
case 'PreCompact':
await this.handlePreCompact(sessionId, payload);
break;
case 'Stop':
await this.handleStop(sessionId, payload);
break;
case 'SessionEnd':
await this.handleSessionEnd(sessionId, payload);
break;
default:
break;
}
} catch (err) {
this.log('ERROR', `Error handling ${eventName ?? 'unknown'}: ${err}`);
}
} catch (err) {
this.log('ERROR', `Error handling ${eventName ?? 'unknown'}: ${err}`);
}
});
}

// ── event handlers ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1481,11 +1466,11 @@
}
}
this.teamMembers.clear();
if (this.provider) {
if (this.weaveInitialized) {
try {
await this.provider.shutdown();
await weave.flushOTel();
} catch (err) {
this.log('ERROR', `Error shutting down OTel provider: ${err}`);
this.log('ERROR', `Error flushing Weave SDK: ${err}`);
}
}
for (const session of this.sessions.values()) {
Expand Down
73 changes: 73 additions & 0 deletions tests/migration-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
// SPDX-License-Identifier: MIT
// SPDX-PackageName: weave-claude-code

// Regression anchor: span shape must stay byte-identical across the
// Weave SDK migration (modulo service.name resource attr and auth
// header, both intentionally changed in this PR). This test exercises
// the genaiSpans helpers directly against an InMemorySpanExporter and
// asserts on the emitted attributes.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import {
startTurnSpan,
startToolSpan,
emitChatSpan,
ATTR,
} from '../src/genaiSpans.ts';

function setupTracer() {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
return { tracer: provider.getTracer('test'), exporter, provider };
}

test('migration baseline: turn + tool + chat shape', async () => {
const { tracer, exporter, provider } = setupTracer();
const turn = startTurnSpan(tracer, {
sessionId: 'sess-1',
conversationId: 'conv-1',
turnNumber: 1,
prompt: 'hello',
cwd: '/tmp',
source: 'fresh',
pluginVersion: '0.0.0-test',
});
const tool = startToolSpan(tracer, turn, {
toolName: 'Bash',
toolUseId: 'tu-1',
toolInput: { command: 'ls' },
displayName: 'Bash: ls',
});
tool.setAttribute(ATTR.TOOL_CALL_RESULT, '"ok"');
tool.end();
emitChatSpan(tracer, turn, {
conversationId: 'conv-1',
model: 'claude-opus-4-7',
startedAt: new Date('2026-01-01T00:00:00Z'),
endedAt: new Date('2026-01-01T00:00:01Z'),
usage: { input_tokens: 100, output_tokens: 50 },
});
turn.end();
await provider.forceFlush();

const spans = exporter.getFinishedSpans();
const byOp = Object.fromEntries(
spans.map(s => [s.attributes[ATTR.OPERATION_NAME] as string, s])
);
assert.equal(byOp['invoke_agent']?.attributes[ATTR.AGENT_NAME], 'claude-code');
assert.equal(byOp['execute_tool']?.attributes[ATTR.TOOL_NAME], 'Bash');
assert.equal(byOp['chat']?.attributes[ATTR.REQUEST_MODEL], 'claude-opus-4-7');
assert.equal(
byOp['chat']?.attributes[ATTR.USAGE_INPUT_TOKENS],
100,
);
});
Loading