diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index a02198e..b667147 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -8,18 +8,38 @@ GitHub Copilot Chat (CLI and VS Code extension transcripts). ## Where it reads from -Two locations. Both are walked on every run; results merge. +Two JSONL locations plus an optional OpenTelemetry SQLite source (see below). All +discovered sources are walked on every run; results merge and dedupe. 1. **Legacy CLI sessions:** `~/.copilot/session-state/` 2. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage//GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux +3. **OTel SQLite store:** VS Code Copilot Chat's `agent-traces.db` (see the OTel section). Preferred when present because it carries full input / output / cache token counts; the JSONL sources only record output tokens. ## Storage format -JSONL in both locations, but the schemas differ. The parser switches by detecting which schema the first event uses (`copilot.ts:83-159` for legacy, `copilot.ts:215-293` for transcripts). +JSONL in the first two locations (schemas differ; the parser switches by detecting which schema the first event uses), and a SQLite DB for the OTel source. + +## OpenTelemetry (OTel) source + +When VS Code Copilot Chat's `agent-traces.db` exists, the parser reads per-LLM-call token +breakdowns (input, output, cache-read, cache-creation) from it, which the JSONL sources do +not record. Discovery is skipped with `CODEBURN_COPILOT_DISABLE_OTEL=1`, and the DB path +can be overridden with `CODEBURN_COPILOT_OTEL_DB`. + +- **Requires Node 22+.** The OTel source uses the built-in `node:sqlite` module (the same + backend as Cursor / OpenCode). On Node 20, or if the DB is missing / locked / corrupt / + wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback. +- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived + cache entries are never evicted when VS Code prunes old spans from the DB, so + month-to-date totals do not drop as the DB rotates. Entries age out after 90 days. +- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot + parse version, which discards the prior copilot cache. Spans already pruned from the DB + before the upgrade cannot be recovered, so monotonicity starts from the upgrade point, + not retroactively. ## Caching -None at the provider level. +None for the JSONL sources. The OTel source uses a durable cache (see above). ## Deduplication diff --git a/src/parser.ts b/src/parser.ts index c9ee828..57f4a5b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1325,10 +1325,6 @@ function buildSessionSummary( totalCacheWrite += call.usage.cacheCreationInputTokens apiCalls++ - if (process.env['DEBUG_OTEL'] && (call.usage.cacheReadInputTokens > 0 || call.usage.cacheCreationInputTokens > 0)) { - console.warn(`[Aggregate] Model=${call.model}, cache_read=${call.usage.cacheReadInputTokens}, cache_write=${call.usage.cacheCreationInputTokens}`) - } - const modelKey = getShortModelName(call.model) if (!modelBreakdown[modelKey]) { modelBreakdown[modelKey] = { @@ -2033,18 +2029,9 @@ async function parseProviderSources( for await (const call of parser.parse()) { providerCalls.push(call) } - if (process.env['DEBUG_OTEL']) { - console.warn(`[Parse] File ${source.path.substring(0, 40)}: Collected ${providerCalls.length} provider calls, ${providerCalls.filter(c => c.cacheReadInputTokens > 0 || c.cacheCreationInputTokens > 0).length} with cache tokens`) - } - if (process.env['DEBUG_OTEL'] && providerCalls.some(c => c.cacheReadInputTokens > 0 || c.cacheCreationInputTokens > 0)) { - console.warn(`[Parse] After conversion: turns to be created...`) - } const canonicalCalls = await Promise.all(providerCalls.map(canonicalizeProviderCallProject)) const turns = providerCallsToCachedTurns(canonicalCalls) - if (process.env['DEBUG_OTEL'] && providerCalls.some(c => c.cacheReadInputTokens > 0 || c.cacheCreationInputTokens > 0)) { - console.warn(`[Parse] After conversion: ${turns.length} turns, calls with cache: ${turns.flatMap(t => t.calls).filter(c => c.usage.cacheReadInputTokens > 0 || c.usage.cacheCreationInputTokens > 0).length}`) - } - + // Store/merge parsed turns into the cache. // Durable providers use a union-by-deduplicationKey merge: existing turns // are NEVER deleted (preserves data for spans pruned from the DB), and @@ -2061,24 +2048,17 @@ async function parseProviderSources( ) existingEntry.turns = [...existingEntry.turns, ...newTurns] existingEntry.fingerprint = fp - if (process.env['DEBUG_OTEL']) { - console.warn(`[Parse] Durable union-merge for ${source.path.substring(0, 40)}: kept ${existingEntry.turns.length - newTurns.length} existing, added ${newTurns.length} new turns`) - } } else { section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns } } } else { // Non-durable: overwrite (clearedPaths already deleted stale entry above) - // or append when multiple sources map to the same path. + // or append when multiple sources map to the same path. NOTE: the append + // path assumes discoverSessions yields a unique path per source, which all + // current providers do; it only fires for same-path multi-source providers. const existingCacheEntry = section.files[source.path] - if (process.env['DEBUG_OTEL']) { - console.warn(`[Parse] Cache entry exists for path: ${!!existingCacheEntry}, turns to merge: ${turns.length}`) - } if (existingCacheEntry) { existingCacheEntry.turns = [...existingCacheEntry.turns, ...turns] - if (process.env['DEBUG_OTEL']) { - console.warn(`[Parse] Merged with existing cache entry for ${source.path.substring(0, 40)}, now has ${existingCacheEntry.turns.length} turns total`) - } } else { section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns } } @@ -2147,31 +2127,13 @@ async function parseProviderSources( // Uses seenKeys (shared across providers) for cross-provider dedup. const sessionMap = new Map() - if (process.env['DEBUG_OTEL']) { - const totalCacheCalls = sources.flatMap(s => section.files[s.path]?.turns ?? []).flatMap(t => t.calls).filter(c => c.usage.cacheReadInputTokens > 0 || c.usage.cacheCreationInputTokens > 0) - console.warn(`[SessionMap] Starting with ${sources.length} sources, ${totalCacheCalls.length} calls with cache tokens`) - - const filesInfo = Object.entries(section.files).map(([path, file]) => ({ - path: path.substring(0, 50), - turns: file.turns.length, - totalCalls: file.turns.flatMap(t => t.calls).length, - cacheTokenCalls: file.turns.flatMap(t => t.calls).filter(c => c.usage.cacheReadInputTokens > 0 || c.usage.cacheCreationInputTokens > 0).length - })) - console.warn(`[SessionMap] Cache files: ${JSON.stringify(filesInfo)}`) - } - for (const source of sources) { const cachedFile = section.files[source.path] if (!cachedFile) continue for (const turn of cachedFile.turns) { const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) - if (hasDup) { - if (process.env['DEBUG_OTEL'] && turn.calls.some(c => c.usage.cacheReadInputTokens > 0 || c.usage.cacheCreationInputTokens > 0)) { - console.warn(`[SessionMap] Skipping turn with cache tokens due to dedup: ${turn.calls.map(c => c.deduplicationKey).join(',')}`) - } - continue - } + if (hasDup) continue for (const c of turn.calls) seenKeys.add(c.deduplicationKey) @@ -2186,10 +2148,6 @@ async function parseProviderSources( const project = turn.calls[0]?.project ?? source.project const key = `${providerName}:${turn.sessionId}:${project}` - if (process.env['DEBUG_OTEL'] && turn.calls.some(c => c.usage.cacheReadInputTokens > 0 || c.usage.cacheCreationInputTokens > 0)) { - console.warn(`[SessionMap] Adding turn with cache tokens to sessionMap key=${key}`) - } - const existing = sessionMap.get(key) if (existing) { existing.turns.push(classified) diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index e5416f0..1f9874b 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -184,19 +184,6 @@ type CopilotEvent = // The Copilot Budget extension reads from this same DB and uses per-span // token counts, confirming this schema is stable enough to depend on. -interface OTelSpanRow { - trace_id: string - span_id: string - parent_span_id: string | null - name: string // e.g. "chat gpt-4o" or "invoke_agent copilot" - start_time: number // nanosecond epoch or millisecond epoch - end_time: number - attributes: string | null // JSON blob of all OTel attributes - // Some DB versions may use separate columns instead of a JSON blob. - // We try JSON parsing first, then fall back to individual column queries. - [key: string]: unknown -} - // Parsed attribute bag from a span interface SpanAttributes { 'gen_ai.operation.name'?: string @@ -285,19 +272,6 @@ function parseCwd(yaml: string): string | null { return raw || null } -/** - * Parse the JSON attributes blob from an OTel span row. - * Returns an empty object if parsing fails. - */ -function parseSpanAttributes(raw: string | null): SpanAttributes { - if (!raw) return {} - try { - return JSON.parse(raw) as SpanAttributes - } catch { - return {} - } -} - /** * Load span attributes from the span_attributes table (key-value pairs). * This handles the modern VS Code Copilot Chat schema where attributes @@ -327,8 +301,7 @@ function loadSpanAttributesFromTable( } } return attrs - } catch (e) { - if (process.env['DEBUG_OTEL']) console.warn(`loadSpanAttributesFromTable error for span ${spanId}:`, e) + } catch { return {} } } @@ -338,6 +311,9 @@ function loadSpanAttributesFromTable( * The OTel spec uses nanoseconds, but some implementations use milliseconds. */ function epochToISO(epoch: number): string { + // Guard malformed rows: new Date(NaN).toISOString() throws. Fall back to the + // epoch (1970) so a bad timestamp is excluded from period totals, not crashing. + if (!Number.isFinite(epoch) || epoch <= 0) return new Date(0).toISOString() // If the value looks like nanoseconds (> 1e15), convert to ms const ms = epoch > 1e15 ? Math.floor(epoch / 1e6) : epoch > 1e12 ? epoch : epoch * 1000 return new Date(ms).toISOString() @@ -529,7 +505,6 @@ function createOtelParser( // One DB open handles ALL conversations — avoids N opens for N conversations. const db = openDatabase(source.path) - if (!db) return try { // --------------------------------------------------------------- @@ -554,8 +529,6 @@ function createOtelParser( ORDER BY min_start DESC` ) - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Found ${conversationRows.length} conversations in DB`) - for (const convRow of conversationRows) { const conversationId = convRow.conversation_id if (!conversationId) continue @@ -578,8 +551,6 @@ function createOtelParser( [conversationId] ) - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Found ${spanIdRows.length} spans for conversation ${conversationId}`) - // Collect trace IDs and span IDs belonging to this conversation const traceIds = new Set() for (const row of spanIdRows) { @@ -587,24 +558,33 @@ function createOtelParser( } if (traceIds.size === 0) { - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] No trace IDs found for conversation`) continue } - // Now query all spans within those traces to find chat and tool spans - const traceIdList = [...traceIds].map((id) => `'${id.replace(/'/g, "''")}'`).join(',') - const traceSpans = db.query<{ span_id: string; trace_id: string; operation_name: string | null }>( - `SELECT span_id, trace_id, operation_name FROM spans WHERE trace_id IN (${traceIdList})` + // Now query all spans within those traces to find chat and tool spans. + // Pull the metadata columns in the same query so we don't re-query the + // spans table once per chat span below (avoids an N+1). + const traceIdArr = [...traceIds] + const tracePlaceholders = traceIdArr.map(() => '?').join(',') + const traceSpans = db.query<{ + span_id: string + trace_id: string + operation_name: string | null + start_time_ms: number + response_model: string | null + }>( + `SELECT span_id, trace_id, operation_name, start_time_ms, response_model FROM spans WHERE trace_id IN (${tracePlaceholders})`, + traceIdArr ) - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Found ${traceSpans.length} total spans across ${traceIds.size} traces`) - // Collect tool names from execute_tool spans for each trace const toolsByTrace = new Map() const chatSpanIds: string[] = [] + const spanMetaById = new Map() for (const span of traceSpans) { const opName = span.operation_name || '' + spanMetaById.set(span.span_id, span) if (opName === 'chat') { chatSpanIds.push(span.span_id) @@ -622,22 +602,12 @@ function createOtelParser( } } - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Found ${chatSpanIds.length} chat spans`) - // Yield one ParsedProviderCall per chat span for (const spanId of chatSpanIds) { const attrs = loadSpanAttributesFromTable(db, spanId) - // Get span metadata from the spans table - const spanMetadata = db.query<{ trace_id: string; start_time_ms: number; response_model: string | null }>( - `SELECT trace_id, start_time_ms, response_model FROM spans WHERE span_id = ?`, - [spanId] - )?.[0] - - if (!spanMetadata) { - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] No metadata for span ${spanId}`) - continue - } + const spanMetadata = spanMetaById.get(spanId) + if (!spanMetadata) continue const model = (attrs['gen_ai.response.model'] as string | undefined) ?? @@ -650,10 +620,7 @@ function createOtelParser( const cacheReadTokens = Number(attrs['gen_ai.usage.cache_read.input_tokens'] ?? 0) const cacheCreationTokens = Number(attrs['gen_ai.usage.cache_creation.input_tokens'] ?? 0) - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Span ${spanId.substring(0, 8)}: model=${model}, input=${inputTokens}, output=${outputTokens}, cache_read=${cacheReadTokens}, cache_creation=${cacheCreationTokens}`) - if (inputTokens === 0 && outputTokens === 0) { - if (process.env['DEBUG_OTEL']) console.warn(`[OTel] Skipping span with 0 tokens`) continue } @@ -935,17 +902,12 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage if (!disableOtel) { const dbPath = getAgentTracesDbPath() if (dbPath) { - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] Discovering OTel sessions from ${dbPath}`) try { const otelSources = await discoverOtelSessions(dbPath) - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] Got ${otelSources.length} OTel sources`) sources.push(...otelSources) - } catch (e) { + } catch { // OTel discovery failed — fall through to JSONL - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] OTel discovery error:`, e) } - } else { - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] No OTel DB path found`) } } @@ -953,7 +915,6 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage try { const jsonlDir = getCopilotSessionStateDir(sessionStateDir) const jsonlSources = await discoverJsonlSessions(jsonlDir) - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] Got ${jsonlSources.length} JSONL sources`) sources.push(...jsonlSources) } catch { // JSONL discovery failed @@ -962,13 +923,11 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage // 3. Discover VS Code workspace transcript sessions try { const transcriptSources = await discoverTranscriptSessions(getWsDirs()) - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] Got ${transcriptSources.length} transcript sources`) sources.push(...transcriptSources) } catch { // Transcript discovery failed } - if (process.env['DEBUG_OTEL']) console.warn(`[Provider] Total sources: ${sources.length}`) return sources }, @@ -980,10 +939,6 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage // The dedup key set (seenKeys) is shared across both parsers, // so if OTel already yielded a span, the JSONL parser will skip // the matching assistant.message (and vice versa). - if (process.env['DEBUG_OTEL']) { - const isOtel = isOtelSource(source) - console.warn(`[Provider] Creating ${isOtel ? 'OTel' : 'JSONL'} parser for source: ${source.path}`) - } if (isOtelSource(source)) { return createOtelParser(source, seenKeys) }