Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4614,6 +4614,7 @@
"type": "boolean",
"default": false,
"markdownDescription": "%github.copilot.config.chat.agentDebugLog.enabled%",
"deprecationMessage": "%github.copilot.config.chat.agentDebugLog.enabled.deprecated%",
"tags": [
"advanced",
"experimental"
Expand Down
5 changes: 3 additions & 2 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,9 @@
"github.copilot.config.editRecording.enabled": "Enable edit recording for analysis.",
"github.copilot.config.inlineChat.selectionRatioThreshold": "Threshold at which to switch editing strategies for inline chat. When a selection portion of code matches a parse tree node, only that is presented to the language model. This speeds up response times but might have lower quality results. Requires having a parse tree for the document and the `inlineChat.enableV2` setting. Values must be between 0 and 1, where 0 means off and 1 means the selection perfectly matches a parse tree node.",
"github.copilot.config.debug.requestLogger.maxEntries": "Maximum number of entries to keep in the request logger for debugging purposes.",
"github.copilot.config.chat.agentDebugLog.enabled": "When enabled, collect agent request information (tool calls, LLM requests, token usage, and errors) for viewing and troubleshooting in VS Code. Requires window reload to take effect.",
"github.copilot.config.chat.agentDebugLog.fileLogging.enabled": "Enable writing chat debug events to JSONL files on disk for diagnostics. When disabled, the built-in `troubleshoot` skill is also disabled. Requires window reload to take effect.",
"github.copilot.config.chat.agentDebugLog.enabled": "Deprecated: use `#github.copilot.chat.agentDebugLog.fileLogging.enabled#` instead. Both settings are honored during the transition.",
"github.copilot.config.chat.agentDebugLog.enabled.deprecated": "This setting has been merged into `#github.copilot.chat.agentDebugLog.fileLogging.enabled#`. Either setting enables debug logging during the transition.",
"github.copilot.config.chat.agentDebugLog.fileLogging.enabled": "Enable agent debug logging: write chat debug events (tool calls, LLM requests, token usage, errors) to JSONL files for the debug panel and troubleshoot skill. Requires window reload to take effect.",
"github.copilot.config.chat.agentDebugLog.fileLogging.flushIntervalMs": "How often (in milliseconds) buffered debug log entries are flushed to disk. Lower values provide more up-to-date logs at the cost of more frequent disk writes.",
"github.copilot.config.chat.agentDebugLog.fileLogging.maxRetainedSessionLogs": "Maximum number of chat debug session log directories to retain on disk. Each chat session produces one directory. Older session logs are automatically deleted when this limit is exceeded.",
"github.copilot.config.chat.agentDebugLog.fileLogging.maxSessionLogSizeMB": "Maximum size in megabytes for a single chat debug session log file. When the log exceeds this size, older entries are truncated to retain the most recent data. Defaults to 100 MB.",
Expand Down
238 changes: 186 additions & 52 deletions src/extension/chat/vscode-node/chatDebugFileLoggerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import * as fs from 'fs';
import * as vscode from 'vscode';
import { IChatDebugFileLoggerService, sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { IChatDebugFileLoggerService, IDebugLogEntry, sessionResourceToId } from '../../../platform/chat/common/chatDebugFileLoggerService';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IEnvService } from '../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
Expand All @@ -15,6 +15,7 @@ import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../platfor
import { ICompletedSpanData, IOTelService, ISpanEventData, SpanStatusCode } from '../../../platform/otel/common/otelService';
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
import { Emitter } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
Expand Down Expand Up @@ -59,37 +60,24 @@ interface IActiveLogSession {
toolsIndex: number;
/** File name of the most recently written tools file (e.g., 'tools_0.json') */
currentToolsFile: string | undefined;
/** Pending tool definitions received before the session was promoted to hasOwnSpans */
pendingToolDefs: string | undefined;
/** Whether we've already checked disk for a previous session directory (prevents repeated sync FS calls) */
resumeChecked: boolean;
}

/**
* A single JSONL debug log entry.
*/
interface IDebugLogEntry {
/** Epoch ms timestamp */
readonly ts: number;
/** Duration in ms (0 for instant events) */
readonly dur: number;
/** Chat session ID */
readonly sid: string;
/** Event type */
readonly type: 'session_start' | 'tool_call' | 'llm_request' | 'user_message' | 'agent_response' | 'subagent' | 'discovery' | 'error' | 'generic' | 'child_session_ref' | 'hook' | 'turn_start' | 'turn_end';
/** Descriptive name */
readonly name: string;
/** Span or event ID */
readonly spanId: string;
/** Parent span ID for hierarchy */
readonly parentSpanId?: string;
/** Status */
readonly status: 'ok' | 'error';
/** Type-specific attributes */
readonly attrs: Record<string, string | number | boolean | undefined>;
}
// IDebugLogEntry is imported from and defined in the platform layer.
// Re-export for consumers that import from this file.
export type { IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService';

export class ChatDebugFileLoggerService extends Disposable implements IChatDebugFileLoggerService {
declare readonly _serviceBrand: undefined;

public readonly id = 'chatDebugFileLogger';

private readonly _onDidEmitEntry = this._register(new Emitter<{ sessionId: string; entry: IDebugLogEntry }>());
readonly onDidEmitEntry = this._onDidEmitEntry.event;

private readonly _activeSessions = new Map<string, IActiveLogSession>();
/** Maps child session ID → { parentSessionId, label } for child session routing */
private readonly _childSessionMap = new Map<string, { parentSessionId: string; label: string }>();
Expand All @@ -116,7 +104,8 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
) {
super();

const enabled = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ChatDebugFileLogging, this._experimentationService);
const enabled = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ChatDebugFileLogging, this._experimentationService)
|| this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AgentDebugLogEnabled, this._experimentationService);
if (!enabled) {
/* __GDPR__
"chatDebugFileLogger.disabled" : {
Expand Down Expand Up @@ -291,6 +280,8 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
toolsKey: undefined,
toolsIndex: 0,
currentToolsFile: undefined,
pendingToolDefs: undefined,
resumeChecked: false,
};
this._activeSessions.set(sessionId, session);

Expand Down Expand Up @@ -361,7 +352,13 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
}

getLogPath(sessionId: string): URI | undefined {
return this._activeSessions.get(sessionId)?.uri;
const active = this._activeSessions.get(sessionId);
if (active) {
return active.uri;
}
// For historical sessions (after restart), construct the default path
const sessionDir = this.getSessionDir(sessionId);
return sessionDir ? URI.joinPath(sessionDir, 'main.jsonl') : undefined;
}

getSessionDir(sessionId: string): URI | undefined {
Expand Down Expand Up @@ -476,6 +473,14 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
if (this._modelSnapshot) {
this._enqueueModelSnapshotWrite(session);
}
// Write pending tool definitions that arrived before the session was promoted
if (session.pendingToolDefs) {
const fileName = `tools_${session.toolsIndex}.json`;
session.toolsIndex++;
session.currentToolsFile = fileName;
this._enqueueFileWrite(session, session.pendingToolDefs, fileName);
session.pendingToolDefs = undefined;
}
}

// ── OTel span handling ──
Expand Down Expand Up @@ -518,8 +523,38 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
// 1. startSession() is called explicitly, or
// 2. A child span references it via PARENT_CHAT_SESSION_ID
// (handled in _ensureSession's child branch).
// 3. The session directory already exists on disk (resumed after restart).
this._ensureSession(sessionId);

// Auto-promote resumed sessions: if the session was just created with
// hasOwnSpans = false but has an existing JSONL directory from a previous
// extension lifecycle, promote it. This handles sessions continued after
// VS Code restart where title/categorization won't re-fire.
const session = this._activeSessions.get(sessionId);
if (session && !session.hasOwnSpans && !session.parentSessionId && !session.resumeChecked) {
session.resumeChecked = true;
const mainJsonl = URI.joinPath(session.sessionDir, 'main.jsonl');
try {
fs.accessSync(mainJsonl.fsPath);
Comment thread
vijayupadya marked this conversation as resolved.
// Directory exists from a previous run — this is a resumed session
session.hasOwnSpans = true;
session.dirEnsured = true;

// Find the next available indices for companion files to avoid
// overwriting ones from the previous run. Single readdir + scan.
try {
for (const f of fs.readdirSync(session.sessionDir.fsPath)) {
const spIdx = f.startsWith('system_prompt_') ? parseInt(f.slice(14), 10) : -1;
if (spIdx >= session.systemPromptIndex) { session.systemPromptIndex = spIdx + 1; }
const tIdx = f.startsWith('tools_') ? parseInt(f.slice(6), 10) : -1;
if (tIdx >= session.toolsIndex) { session.toolsIndex = tIdx + 1; }
}
} catch { /* readdir failed — indices stay at 0 */ }
} catch {
// No existing directory — leave as is
}
}

// Write system_prompt JSON when model or mode changes (before buffering so llm_request gets the file ref)
if (opName === GenAiOperationName.CHAT) {
const session = this._activeSessions.get(sessionId);
Expand All @@ -536,16 +571,6 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
session.systemPromptIndex++;
session.currentSystemPromptFile = fileName;
this._enqueueFileWrite(session, systemInstructions, fileName);
this._bufferEntry(sessionId, {
ts: span.startTime,
dur: 0,
sid: sessionId,
type: 'generic',
name: 'system_prompt_ref',
spanId: `sys-prompt-${span.spanId}`,
status: 'ok',
attrs: { file: fileName, model },
});
}
}
}
Expand Down Expand Up @@ -713,37 +738,37 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
return;
}

// Ensure session exists — tools_available fires early, before any span completes
this._ensureSession(sessionId);

// Do NOT create sessions from tools_available events — they can carry tool call IDs
// (e.g., toolu_xxx, call_xxx) as conversation IDs, which are not valid session IDs.
const session = this._activeSessions.get(sessionId);
if (!session || session.parentSessionId) {
return;
}

// If the session isn't promoted yet, cache the tools for later replay
if (!session.hasOwnSpans) {
const toolDefs = typeof event.attributes.toolDefinitions === 'string' ? event.attributes.toolDefinitions : undefined;
if (toolDefs) {
session.pendingToolDefs = toolDefs;
}
return;
}

const toolDefs = typeof event.attributes.toolDefinitions === 'string' ? event.attributes.toolDefinitions : undefined;
if (!toolDefs) {
return;
}

// Use the current systemPromptKey to detect model/mode — tools change when the prompt changes
const key = session.systemPromptKey ?? 'unknown';
// Use the content length to detect changes. Different tool sets (from model
// or mode switches) will have different lengths. A false negative (same length,
// different content) just means we skip writing a redundant file — harmless.
const key = `tools:${toolDefs.length}`;
Comment thread
vijayupadya marked this conversation as resolved.
if (key !== session.toolsKey) {
const fileName = `tools_${session.toolsIndex}.json`;
session.toolsKey = key;
session.toolsIndex++;
session.currentToolsFile = fileName;
this._enqueueFileWrite(session, toolDefs, fileName);
this._bufferEntry(sessionId, {
ts: event.timestamp,
dur: 0,
sid: sessionId,
type: 'generic',
name: 'tools_ref',
spanId: `tools-${event.spanId}`,
status: 'ok',
attrs: { file: fileName },
});
}
}

Expand Down Expand Up @@ -959,7 +984,116 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
if (!session) {
return;
}
session.buffer.push(JSON.stringify(entry) + '\n');
const versionedEntry = entry.v ? entry : { v: 1, ...entry };
session.buffer.push(JSON.stringify(versionedEntry) + '\n');
this._onDidEmitEntry.fire({ sessionId, entry: versionedEntry });
}

async readEntries(sessionId: string): Promise<IDebugLogEntry[]> {
const entries: IDebugLogEntry[] = [];
await this.streamEntries(sessionId, entry => entries.push(entry));
return entries;
}
Comment thread
vijayupadya marked this conversation as resolved.

async readTailEntries(sessionId: string, count: number): Promise<IDebugLogEntry[]> {
const session = this._activeSessions.get(sessionId);
const logPath = session?.uri ?? this.getLogPath(sessionId);
let entries: IDebugLogEntry[] = [];

if (logPath) {
try {
const stat = await fs.promises.stat(logPath.fsPath);
// Start with a read size that should cover `count` entries.
// Average JSONL entry is ~1-2KB, so count * 4KB is generous.
const readSize = Math.min(stat.size, count * 4096);
const startOffset = Math.max(0, stat.size - readSize);

const fd = await fs.promises.open(logPath.fsPath, 'r');
try {
const buffer = Buffer.alloc(stat.size - startOffset);
const { bytesRead } = await fd.read(buffer, 0, buffer.length, startOffset);

const text = buffer.subarray(0, bytesRead).toString('utf-8');
const lines = text.split('\n');
// Skip the first line if we started mid-file (likely partial)
const startIdx = startOffset > 0 ? 1 : 0;
for (let i = startIdx; i < lines.length; i++) {
if (!lines[i]) { continue; }
try {
entries.push(JSON.parse(lines[i]) as IDebugLogEntry);
} catch { /* skip malformed */ }
}
} finally {
await fd.close();
}

// Keep only the last `count` entries
if (entries.length > count) {
entries = entries.slice(-count);
}
} catch {
// File may not exist — that's fine
}
}

// Append unflushed buffer entries
if (session) {
for (const line of session.buffer) {
try {
entries.push(JSON.parse(line) as IDebugLogEntry);
} catch { /* skip malformed */ }
}
}

Comment thread
vijayupadya marked this conversation as resolved.
return entries;
}

async streamEntries(sessionId: string, onEntry: (entry: IDebugLogEntry) => void): Promise<void> {
const session = this._activeSessions.get(sessionId);
const logPath = session?.uri ?? this.getLogPath(sessionId);

if (logPath) {
try {
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(logPath.fsPath, { encoding: 'utf-8' });
let partial = '';
stream.on('data', (chunk) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
partial += text;
const lines = partial.split('\n');
// Last element may be a partial line — keep it for next chunk
partial = lines.pop() ?? '';
for (const line of lines) {
if (!line) { continue; }
try {
onEntry(JSON.parse(line) as IDebugLogEntry);
} catch { /* skip malformed */ }
}
});
stream.on('end', () => {
// Process any remaining partial line
if (partial) {
try {
onEntry(JSON.parse(partial) as IDebugLogEntry);
} catch { /* skip malformed */ }
}
resolve();
});
stream.on('error', reject);
});
} catch {
// File may not exist — that's fine
}
}

// Append unflushed buffer entries
if (session) {
for (const line of session.buffer) {
try {
onEntry(JSON.parse(line) as IDebugLogEntry);
} catch { /* skip malformed */ }
}
}
}

private async _writeToFile(session: IActiveLogSession, content: string): Promise<void> {
Expand Down
Loading
Loading