diff --git a/package-lock.json b/package-lock.json index 32c0bc3..9a69371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,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" }, "bin": { "weave-claude-code": "dist/cli.js" @@ -29,6 +30,48 @@ "node": ">=18.19.0" } }, + "../weave/sdks/node": { + "name": "weave", + "version": "0.15.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-proto": "^0.53.0", + "@opentelemetry/resources": "^1.26.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "cli-progress": "^3.12.0", + "cross-spawn": "^7.0.5", + "form-data": "^4.0.4", + "import-in-the-middle": "^1.13.2", + "ini": "^5.0.0", + "module-details-from-path": "^1.0.4", + "semifies": "^1.0.0", + "uuidv7": "^1.0.1" + }, + "devDependencies": { + "@openai/agents": "^0.11.4", + "@openai/agents-realtime": "^0.11.5", + "@types/cli-progress": "^3.11.6", + "@types/ini": "^1.3.3", + "@types/jest": "^29.5.13", + "@types/node": "^22.5.1", + "jest": "^30.4.0", + "nyc": "^17.1.0", + "openai": "^6.39.0", + "prettier": "^3.3.3", + "source-map-support": "^0.5.21", + "swagger-typescript-api": "^13.9.3", + "ts-jest": "^29.4.1", + "ts-node": "^10.9.2", + "tsc-multi": "^1.1.0", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.1", + "typedoc": "^0.28", + "typedoc-plugin-markdown": "^4.2.9", + "typescript": "^5.3.3", + "zod": "^4.4.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -782,6 +825,10 @@ "bin": { "uuidv7": "cli.js" } + }, + "node_modules/weave": { + "resolved": "../weave/sdks/node", + "link": true } } } diff --git a/package.json b/package.json index 43ac9ad..550f666 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/daemon.ts b/src/daemon.ts index 6b79942..8613a7f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -14,10 +14,7 @@ import { 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'; import { loadSettings, VERSION } from './setup.js'; import { appendToLog, deepEqual } from './utils.js'; import { parseSessionFd, extractAssistantTextBlocks } from './parser.js'; @@ -333,7 +330,7 @@ export class GlobalDaemon { private readonly inactivityMs = Number(process.env.WEAVE_INACTIVITY_MS) || INACTIVITY_TIMEOUT_MS; private sessions = new Map(); private sessionQueues = new Map>(); - 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 @@ -355,16 +352,16 @@ export class GlobalDaemon { // 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 @@ -418,40 +415,26 @@ export class GlobalDaemon { // ── tracer initialization ─────────────────────────────────────────────── - private initTracer(): void { + private async initTracer(): Promise { 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 ─────────────────────────────────────────────────── @@ -529,50 +512,52 @@ export class GlobalDaemon { 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 ──────────────────────────────────────────────────────── @@ -1481,11 +1466,11 @@ export class GlobalDaemon { } } 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()) { diff --git a/tests/migration-snapshot.test.ts b/tests/migration-snapshot.test.ts new file mode 100644 index 0000000..2cc7725 --- /dev/null +++ b/tests/migration-snapshot.test.ts @@ -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, + ); +});