From fd74951abc65f594357b1e5085704dea41fd5fb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:52:31 +0000 Subject: [PATCH 1/6] Initial plan From 225453f9b7a84c74893500ae10589c22e40900c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:00:51 +0000 Subject: [PATCH 2/6] Add Azure Storage configuration panel to diagnostics Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> --- src/extension.ts | 87 +++++++++++++++- src/webview/diagnostics/main.ts | 176 ++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 62558c6..66a97f5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3121,7 +3121,8 @@ class CopilotTokenTracker implements vscode.Disposable { dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1); } const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorTypeFromPath(dir) })); - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders); + const backendStorageInfo = await this.getBackendStorageInfo(); + this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles); return; } @@ -3160,6 +3161,8 @@ class CopilotTokenTracker implements vscode.Disposable { } const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorNameFromRoot(dir) })); + const backendStorageInfo = await this.getBackendStorageInfo(); + this.diagnosticsPanel = vscode.window.createWebviewPanel( 'copilotTokenDiagnostics', 'Diagnostic Report', @@ -3177,7 +3180,7 @@ class CopilotTokenTracker implements vscode.Disposable { this.log('✅ Diagnostic Report created successfully'); // Set the HTML content immediately with empty session files (shows loading state) - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders); + this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); // Handle messages from the webview this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => { @@ -3254,6 +3257,27 @@ class CopilotTokenTracker implements vscode.Disposable { await this.showDiagnosticReport(); } break; + case 'configureBackend': + this.log('[DEBUG] configureBackend message received from diagnostics webview'); + // Execute the configureBackend command if it exists + try { + await vscode.commands.executeCommand('copilot-token-tracker.configureBackend'); + } catch (err) { + // If command is not registered, show settings + vscode.window.showInformationMessage( + 'Backend configuration is available in settings. Search for "Copilot Token Tracker: Backend" in settings.', + 'Open Settings' + ).then(choice => { + if (choice === 'Open Settings') { + vscode.commands.executeCommand('workbench.action.openSettings', 'copilotTokenTracker.backend'); + } + }); + } + break; + case 'openSettings': + this.log('[DEBUG] openSettings message received from diagnostics webview'); + await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotTokenTracker.backend'); + break; } }); @@ -3330,12 +3354,67 @@ class CopilotTokenTracker implements vscode.Disposable { } } + /** + * Get backend storage information for diagnostics + */ + private async getBackendStorageInfo(): Promise { + const config = vscode.workspace.getConfiguration('copilotTokenTracker'); + const enabled = config.get('backend.enabled', false); + const storageAccount = config.get('backend.storageAccount', ''); + const subscriptionId = config.get('backend.subscriptionId', ''); + const resourceGroup = config.get('backend.resourceGroup', ''); + const aggTable = config.get('backend.aggTable', 'usageAggDaily'); + const eventsTable = config.get('backend.eventsTable', 'usageEvents'); + const authMode = config.get('backend.authMode', 'entraId'); + const sharingProfile = config.get('backend.sharingProfile', 'off'); + + // Get last sync time from global state + const lastSyncAt = this.context.globalState.get('backend.lastSyncAt'); + const lastSyncTime = lastSyncAt ? new Date(lastSyncAt).toISOString() : null; + + // Check if backend is configured (has required settings) + const isConfigured = enabled && storageAccount && subscriptionId && resourceGroup; + + // Get unique device count from session files (estimate based on unique workspace roots) + const sessionFiles = await this.getCopilotSessionFiles(); + const workspaceIds = new Set(); + const pathModule = require('path'); + + for (const file of sessionFiles) { + const parts = file.split(/[\\\/]/); + const workspaceStorageIdx = parts.findIndex(p => p.toLowerCase() === 'workspacestorage'); + if (workspaceStorageIdx >= 0 && workspaceStorageIdx < parts.length - 1) { + const workspaceId = parts[workspaceStorageIdx + 1]; + if (workspaceId && workspaceId.length > 10) { + workspaceIds.add(workspaceId); + } + } + } + + return { + enabled, + isConfigured, + storageAccount, + subscriptionId: subscriptionId ? subscriptionId.substring(0, 8) + '...' : '', + resourceGroup, + aggTable, + eventsTable, + authMode, + sharingProfile, + lastSyncTime, + deviceCount: workspaceIds.size, + sessionCount: sessionFiles.length, + recordCount: null // Will be populated from Azure if configured + }; + } + private getDiagnosticReportHtml( webview: vscode.Webview, report: string, sessionFiles: { file: string; size: number; modified: string }[], detailedSessionFiles: SessionFileDetails[], - sessionFolders: { dir: string; count: number }[] = [] + sessionFolders: { dir: string; count: number }[] = [], + backendStorageInfo: any = null ): string { const nonce = this.getNonce(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview', 'diagnostics.js')); @@ -3366,7 +3445,7 @@ class CopilotTokenTracker implements vscode.Disposable { location: 'VS Code Global State - extension.globalState.get sessionFileCache' }; - const initialData = JSON.stringify({ report, sessionFiles, detailedSessionFiles, sessionFolders, cacheInfo }).replace(/ diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index f97e77e..62fe843 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -32,11 +32,28 @@ type CacheInfo = { location: string; }; +type BackendStorageInfo = { + enabled: boolean; + isConfigured: boolean; + storageAccount: string; + subscriptionId: string; + resourceGroup: string; + aggTable: string; + eventsTable: string; + authMode: string; + sharingProfile: string; + lastSyncTime: string | null; + deviceCount: number; + sessionCount: number; + recordCount: number | null; +}; + type DiagnosticsData = { report: string; sessionFiles: { file: string; size: number; modified: string }[]; detailedSessionFiles?: SessionFileDetails[]; cacheInfo?: CacheInfo; + backendStorageInfo?: BackendStorageInfo; }; declare function acquireVsCodeApi(): { @@ -281,6 +298,149 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool `; } +function renderBackendStoragePanel(backendInfo: BackendStorageInfo | undefined): string { + if (!backendInfo) { + return ` +
+
☁️ Azure Storage Backend
+
+ Backend storage information is not available. This may be a temporary issue. +
+
+ `; + } + + const statusColor = backendInfo.isConfigured ? '#2d6a4f' : (backendInfo.enabled ? '#d97706' : '#666'); + const statusIcon = backendInfo.isConfigured ? '✅' : (backendInfo.enabled ? '⚠️' : '⚪'); + const statusText = backendInfo.isConfigured ? 'Configured & Enabled' : (backendInfo.enabled ? 'Enabled but Not Configured' : 'Disabled'); + + const configButtonText = backendInfo.isConfigured ? '⚙️ Manage Backend' : '🔧 Configure Backend'; + const configButtonStyle = backendInfo.isConfigured ? 'secondary' : ''; + + return ` +
+
☁️ Azure Storage Backend
+
+ Sync your token usage data to Azure Storage Tables for team-wide reporting and multi-device access. + Configure Azure resources and authentication settings to enable cloud synchronization. +
+
+ +
+
+
${statusIcon} Backend Status
+
${statusText}
+
+
+
🔐 Auth Mode
+
${backendInfo.authMode === 'entraId' ? 'Entra ID' : 'Shared Key'}
+
+
+
👥 Sharing Profile
+
${escapeHtml(backendInfo.sharingProfile)}
+
+
+
🕒 Last Sync
+
${backendInfo.lastSyncTime ? getTimeSince(backendInfo.lastSyncTime) : 'Never'}
+
+
+ + ${backendInfo.isConfigured ? ` +
+

📊 Configuration Details

+ + + + + + + + + + + + + + + + + + + + + + + +
Storage Account${escapeHtml(backendInfo.storageAccount)}
Subscription ID${escapeHtml(backendInfo.subscriptionId)}
Resource Group${escapeHtml(backendInfo.resourceGroup)}
Aggregation Table${escapeHtml(backendInfo.aggTable)}
Events Table${escapeHtml(backendInfo.eventsTable)}
+
+ +
+

📈 Local Session Statistics

+
+
+
💻 Unique Devices
+
${backendInfo.deviceCount}
+
Based on workspace IDs
+
+
+
📁 Total Sessions
+
${backendInfo.sessionCount}
+
Local session files
+
+
+
☁️ Cloud Records
+
${backendInfo.recordCount !== null ? backendInfo.recordCount : '—'}
+
Azure Storage records
+
+
+
🔄 Sync Status
+
${backendInfo.lastSyncTime ? formatDate(backendInfo.lastSyncTime) : 'Never'}
+
+
+
+ +
+

ℹ️ About Azure Storage Backend

+

+ The Azure Storage backend enables: +

+
    +
  • Team-wide token usage reporting and analytics
  • +
  • Multi-device synchronization of your usage data
  • +
  • Long-term storage and historical analysis
  • +
  • Configurable privacy levels (anonymous, pseudonymous, or identified)
  • +
+
+ ` : ` +
+

🚀 Get Started with Azure Storage

+

+ To enable cloud synchronization, you'll need to configure an Azure Storage account. + The setup wizard will guide you through the process. +

+
    +
  • Azure subscription with Storage Account access
  • +
  • Appropriate permissions (Storage Table Data Contributor or Storage Account Key)
  • +
  • VS Code signed in with your Azure account (for Entra ID auth)
  • +
+
+ `} + +
+ + ${backendInfo.isConfigured ? ` + + ` : ''} +
+ `; +} + function renderLayout(data: DiagnosticsData): void { const root = document.getElementById('root'); if (!root) { @@ -607,6 +767,7 @@ function renderLayout(data: DiagnosticsData): void { +
@@ -691,6 +852,10 @@ function renderLayout(data: DiagnosticsData): void {
+ +
+ ${renderBackendStoragePanel(data.backendStorageInfo)} +
`; @@ -931,6 +1096,17 @@ function renderLayout(data: DiagnosticsData): void { document.getElementById('btn-usage')?.addEventListener('click', () => vscode.postMessage({ command: 'showUsageAnalysis' })); document.getElementById('btn-details')?.addEventListener('click', () => vscode.postMessage({ command: 'showDetails' })); + // Backend configuration buttons + document.getElementById('btn-configure-backend')?.addEventListener('click', () => { + console.log('[DEBUG] Configure backend button clicked'); + vscode.postMessage({ command: 'configureBackend' }); + }); + + document.getElementById('btn-open-settings')?.addEventListener('click', () => { + console.log('[DEBUG] Open settings button clicked'); + vscode.postMessage({ command: 'openSettings' }); + }); + setupSortHandlers(); setupEditorFilterHandlers(); setupFileLinks(); From db615c26b6f39cbf3acc570ddf82fad1d684875a Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 31 Jan 2026 22:55:50 +0100 Subject: [PATCH 3/6] Hooked up the Azure storage wizard better --- .github/copilot-instructions.md | 15 +++++++++ src/extension.ts | 54 ++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 960895d..b0e644e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,6 +36,21 @@ The entire extension's logic is contained within the `CopilotTokenTracker` class - **Focused Modifications**: Make surgical, precise changes that address the specific requirements without affecting other functionality. - **Preserve Existing Structure**: Maintain the existing code organization and file structure. Don't refactor or reorganize code unless it's essential for the task. +## Logging Best Practices + +**CRITICAL**: Do NOT add debug logging statements like `log('[DEBUG] message')` for troubleshooting during development. This approach has been found to interfere with the output channel and can hide existing log messages from appearing. + +- **Use Existing Logs**: The extension already has comprehensive logging throughout. Review existing log statements to understand what's being tracked. +- **Minimal Logging**: Only add logging if absolutely necessary for a new feature. Keep messages concise and informative. +- **Remove Debug Logs**: Any temporary debug logging added during development MUST be removed before committing code. +- **Log Methods**: Use the appropriate method for the severity: + - `log(message)` - Standard informational messages + - `warn(message)` - Warnings or recoverable errors + - `error(message)` - Critical errors +- **No Debug Prefixes**: Avoid prefixing messages with `[DEBUG]` or similar markers. The log output is already timestamped and categorized. + +If you need to troubleshoot execution flow, prefer using VS Code's debugger with breakpoints rather than adding log statements. + ## Key Files & Conventions - **`src/extension.ts`**: The single source file containing all logic. diff --git a/src/extension.ts b/src/extension.ts index 66e790e..a93f5a6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,8 @@ import * as path from 'path'; import * as os from 'os'; import tokenEstimatorsData from './tokenEstimators.json'; import modelPricingData from './modelPricing.json'; +import { BackendFacade } from './backend/facade'; +import { BackendCommandHandler } from './backend/commands'; import * as packageJson from '../package.json'; import {getModelDisplayName} from './webview/shared/modelUtils'; @@ -397,6 +399,8 @@ class CopilotTokenTracker implements vscode.Disposable { this.context = context; // Create output channel for extension logs this.outputChannel = vscode.window.createOutputChannel('GitHub Copilot Token Tracker'); + // CRITICAL: Add output channel to context.subscriptions so VS Code doesn't dispose it + context.subscriptions.push(this.outputChannel); this.log('Constructor called'); // Load persisted cache from storage @@ -3084,6 +3088,8 @@ class CopilotTokenTracker implements vscode.Disposable { public async showDiagnosticReport(): Promise { this.log('🔍 Opening Diagnostic Report'); + // Keep the output channel visible to prevent VS Code from switching to other channels + this.outputChannel.show(true); // If panel already exists, just reveal it and update content if (this.diagnosticsPanel) { @@ -3265,7 +3271,6 @@ class CopilotTokenTracker implements vscode.Disposable { } break; case 'configureBackend': - this.log('[DEBUG] configureBackend message received from diagnostics webview'); // Execute the configureBackend command if it exists try { await vscode.commands.executeCommand('copilot-token-tracker.configureBackend'); @@ -3693,6 +3698,53 @@ export function activate(context: vscode.ExtensionContext) { // Create the token tracker const tokenTracker = new CopilotTokenTracker(context.extensionUri, context); + // Wire up backend facade and commands so the diagnostics webview can launch the + // configuration wizard. Uses tokenTracker logging and helpers via casting to any. + try { + const backendFacade = new BackendFacade({ + context, + log: (m: string) => (tokenTracker as any).log(m), + warn: (m: string) => (tokenTracker as any).warn(m), + updateTokenStats: () => (tokenTracker as any).updateTokenStats(), + calculateEstimatedCost: (modelUsage: any) => { + let total = 0; + const pricing = (modelPricingData as any).pricing || {}; + for (const [model, usage] of Object.entries(modelUsage || {})) { + const p = pricing[model] || pricing['gpt-4o-mini']; + if (!p) { continue; } + const usageData = usage as { inputTokens?: number; outputTokens?: number }; + total += ((usageData.inputTokens || 0) / 1_000_000) * p.inputCostPerMillion; + total += ((usageData.outputTokens || 0) / 1_000_000) * p.outputCostPerMillion; + } + return total; + }, + co2Per1kTokens: 0.2, + waterUsagePer1kTokens: 0.3, + co2AbsorptionPerTreePerYear: 21000, + getCopilotSessionFiles: () => (tokenTracker as any).getCopilotSessionFiles(), + estimateTokensFromText: (text: string, model?: string) => (tokenTracker as any).estimateTokensFromText(text, model), + getModelFromRequest: (req: any) => (tokenTracker as any).getModelFromRequest(req), + getSessionFileDataCached: (p: string, m: number) => (tokenTracker as any).getSessionFileDataCached(p, m) + }); + + const backendHandler = new BackendCommandHandler({ + facade: backendFacade as any, + integration: undefined, + calculateEstimatedCost: (mu: any) => 0, + warn: (m: string) => (tokenTracker as any).warn(m), + log: (m: string) => (tokenTracker as any).log(m) + }); + + const configureBackendCommand = vscode.commands.registerCommand('copilot-token-tracker.configureBackend', async () => { + await backendHandler.handleConfigureBackend(); + }); + + context.subscriptions.push(configureBackendCommand); + } catch (err) { + // If backend wiring fails for any reason, don't block activation - fall back to settings behavior. + (tokenTracker as any).warn('Failed to wire backend commands: ' + String(err)); + } + // Register the refresh command const refreshCommand = vscode.commands.registerCommand('copilot-token-tracker.refresh', async () => { tokenTracker.log('Refresh command called'); From 1ff0358f15781f79cfc0a7dae05f734780632628 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 31 Jan 2026 23:52:49 +0100 Subject: [PATCH 4/6] Fix the syncing run to find all files --- src/backend/facade.ts | 6 +- src/backend/services/syncService.ts | 193 +++++++++++++++++++++++----- src/extension.ts | 7 +- 3 files changed, 165 insertions(+), 41 deletions(-) diff --git a/src/backend/facade.ts b/src/backend/facade.ts index acebb69..5b8d47b 100644 --- a/src/backend/facade.ts +++ b/src/backend/facade.ts @@ -76,7 +76,8 @@ export class BackendFacade { getCopilotSessionFiles: deps.getCopilotSessionFiles, estimateTokensFromText: deps.estimateTokensFromText, getModelFromRequest: deps.getModelFromRequest, - getSessionFileDataCached: deps.getSessionFileDataCached + getSessionFileDataCached: deps.getSessionFileDataCached, + updateTokenStats: deps.updateTokenStats }, this.credentialService, this.dataPlaneService, @@ -226,6 +227,7 @@ export class BackendFacade { const settings = this.getSettings(); const result = await this.syncService.syncToBackendStore(force, settings, this.isConfigured(settings)); this.clearQueryCache(); + // UI update is now handled by syncService after successful completion return result; } @@ -478,8 +480,8 @@ export class BackendFacade { const next = applyDraftToSettings(previousSettings, draft, consentAt); await this.updateConfiguration(next); this.startTimerIfEnabled(); - this.deps.updateTokenStats?.(); this.clearQueryCache(); + // UI update happens automatically after sync completes via syncService callback return { state: await this.getConfigPanelState(), message: 'Settings saved.' }; } diff --git a/src/backend/services/syncService.ts b/src/backend/services/syncService.ts index 0ab2cfc..d862940 100644 --- a/src/backend/services/syncService.ts +++ b/src/backend/services/syncService.ts @@ -60,6 +60,8 @@ export interface SyncServiceDeps { getModelFromRequest: (request: ChatRequest) => string; // Cache integration for performance getSessionFileDataCached?: (sessionFilePath: string, mtime: number) => Promise; + // UI refresh callback after successful sync + updateTokenStats?: () => Promise; } /** @@ -128,8 +130,8 @@ export class SyncService { } }); }, intervalMs); - // Immediate initial sync - this.syncToBackendStore(false, settings, isConfigured).catch((e) => { + // Immediate initial sync (forced to ensure settings changes take effect) + this.syncToBackendStore(true, settings, isConfigured).catch((e) => { this.deps.warn(`Backend sync initial sync failed: ${e?.message ?? e}`); }); } catch (e) { @@ -163,9 +165,13 @@ export class SyncService { } /** - * Process a session file using cached data. + * Process a session file using cached data for token counts but extracting accurate timestamps. * Returns true if successful, false if cache miss (caller should parse file). * Validates all cached data at runtime to prevent injection/corruption. + * + * CRITICAL: We parse the file to extract actual interaction timestamps and create per-day + * rollups, but use cached token counts for performance. This ensures accurate day assignment + * while still benefiting from cached calculations. */ private async processCachedSessionFile( sessionFile: string, @@ -173,7 +179,9 @@ export class SyncService { workspaceId: string, machineId: string, userId: string | undefined, - rollups: Map + rollups: Map, + startMs: number, + now: Date ): Promise { try { const cachedData = await this.deps.getSessionFileDataCached!(sessionFile, fileMtimeMs); @@ -192,39 +200,120 @@ export class SyncService { return false; } - // Expand cached modelUsage into rollups - const dayKey = this.utility.toUtcDayKey(new Date(fileMtimeMs)); + // Parse the session file to get actual request timestamps and create per-day rollups + // This ensures accurate day assignment while using cached token counts + const content = await fs.promises.readFile(sessionFile, 'utf8'); - // CRITICAL FIX: Only assign interactions to first model to prevent inflation - // When a file has multiple models, interactions should be counted once, not per-model - let interactionsAssigned = false; + // Map to track per-day per-model interactions for proper distribution + const dayModelInteractions = new Map>(); - for (const [model, usage] of Object.entries(cachedData.modelUsage)) { - // Validate usage object structure - if (!usage || typeof usage !== 'object') { - this.deps.warn(`Backend sync: invalid usage object for model ${model} in ${sessionFile}`); - continue; + // Handle JSONL format (Copilot CLI) + if (sessionFile.endsWith('.jsonl')) { + const lines = content.trim().split('\n'); + for (const line of lines) { + if (!line.trim()) { continue; } + try { + const event = JSON.parse(line); + if (!event || typeof event !== 'object') { continue; } + + const normalizedTs = this.utility.normalizeTimestampToMs(event.timestamp); + const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; + if (!eventMs || eventMs < startMs) { continue; } + + const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); + const model = (event.model || 'gpt-4o').toString(); + + // Log first few interactions from today's files for debugging + if (isFileFromToday && processedLines < 3) { + this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} line ${lineCount}: eventMs=${new Date(eventMs).toISOString()}, dayKey=${dayKey}, type=${event.type}`); + processedLines++; + } + + // Track interaction for this day+model (count all events, not just user.message) + if (!dayModelInteractions.has(dayKey)) { + dayModelInteractions.set(dayKey, new Map()); + } + const dayMap = dayModelInteractions.get(dayKey)!; + dayMap.set(model, (dayMap.get(model) || 0) + 1); + } catch { + // skip malformed line + } } - if (!Number.isFinite(usage.inputTokens) || usage.inputTokens < 0) { - this.deps.warn(`Backend sync: invalid inputTokens for model ${model} in ${sessionFile}`); - continue; + } else { + // Handle JSON format (VS Code Copilot Chat) + try { + const sessionJson = JSON.parse(content); + if (!sessionJson || typeof sessionJson !== 'object') { + return false; + } + const sessionObj = sessionJson as Record; + const requests = Array.isArray(sessionObj.requests) ? (sessionObj.requests as unknown[]) : []; + + for (const request of requests) { + const req = request as ChatRequest; + const normalizedTs = this.utility.normalizeTimestampToMs( + typeof req.timestamp !== 'undefined' ? req.timestamp : (sessionObj.lastMessageDate as unknown) + ); + const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; + if (!eventMs || eventMs < startMs) { continue; } + + const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); + const model = this.deps.getModelFromRequest(req); + + + // Track interaction for this day+model + if (!dayModelInteractions.has(dayKey)) { + dayModelInteractions.set(dayKey, new Map()); + } + const dayMap = dayModelInteractions.get(dayKey)!; + dayMap.set(model, (dayMap.get(model) || 0) + 1); + } + } catch (e) { + this.deps.warn(`Backend sync: failed to parse JSON for ${sessionFile}: ${e}`); + return false; } - if (!Number.isFinite(usage.outputTokens) || usage.outputTokens < 0) { - this.deps.warn(`Backend sync: invalid outputTokens for model ${model} in ${sessionFile}`); - continue; + } + + // Now distribute cached token counts proportionally across day+model combinations + // based on the actual interaction distribution we just calculated + for (const [dayKey, modelMap] of dayModelInteractions) { + for (const [model, interactions] of modelMap) { + const cachedUsage = cachedData.modelUsage[model]; + if (!cachedUsage) { continue; } + + // Validate usage object structure + if (!Number.isFinite(cachedUsage.inputTokens) || cachedUsage.inputTokens < 0) { + this.deps.warn(`Backend sync: invalid inputTokens for model ${model}`); + continue; + } + if (!Number.isFinite(cachedUsage.outputTokens) || cachedUsage.outputTokens < 0) { + this.deps.warn(`Backend sync: invalid outputTokens for model ${model}`); + continue; + } + + const key: DailyRollupKey = { day: dayKey, model, workspaceId, machineId, userId }; + + // For simplicity, if a file spans multiple days, distribute tokens proportionally + // In practice, most session files are from a single day, so this is accurate + const totalInteractionsForModel = Array.from(dayModelInteractions.values()) + .reduce((sum, m) => sum + (m.get(model) || 0), 0); + + const tokenRatio = totalInteractionsForModel > 0 ? interactions / totalInteractionsForModel : 1; + + upsertDailyRollup(rollups, key, { + inputTokens: Math.round(cachedUsage.inputTokens * tokenRatio), + outputTokens: Math.round(cachedUsage.outputTokens * tokenRatio), + interactions: interactions + }); } - - const key: DailyRollupKey = { day: dayKey, model, workspaceId, machineId, userId }; - // Only assign interactions to the first valid model to prevent inflation - const interactionsForThisModel = interactionsAssigned ? 0 : cachedData.interactions; - interactionsAssigned = true; - - upsertDailyRollup(rollups, key, { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - interactions: interactionsForThisModel - }); } + + // Log if this file had data for multiple days (for debugging) + if (dayModelInteractions.size > 1) { + const days = Array.from(dayModelInteractions.keys()).sort(); + this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} spans ${days.length} days: ${days.join(', ')}`); + } + return true; } catch (e) { // Differentiate between cache miss (expected) and errors (unexpected) @@ -308,6 +397,11 @@ export class SyncService { start.setUTCHours(0, 0, 0, 0); start.setUTCDate(start.getUTCDate() - (lookbackDays - 1)); const startMs = start.getTime(); + + // Log the date range being processed + const todayKey = this.utility.toUtcDayKey(now); + const startKey = this.utility.toUtcDayKey(start); + this.deps.log(`Backend sync: processing sessions from ${startKey} to ${todayKey} (lookback ${lookbackDays} days)`); const machineId = vscode.env.machineId; const rollups = new Map(); @@ -322,6 +416,10 @@ export class SyncService { const useCachedData = !!this.deps.getSessionFileDataCached; let cacheHits = 0; let cacheMisses = 0; + let filesSkipped = 0; + let filesProcessed = 0; + + this.deps.log(`Backend sync: analyzing ${sessionFiles.length} session files`); for (const sessionFile of sessionFiles) { let fileMtimeMs: number | undefined; @@ -330,10 +428,13 @@ export class SyncService { const fileStat = await fs.promises.stat(sessionFile); fileMtimeMs = fileStat.mtimeMs; + // Skip files older than lookback period if (fileMtimeMs < startMs) { + filesSkipped++; continue; } + filesProcessed++; } catch (e) { this.deps.warn(`Backend sync: failed to stat session file ${sessionFile}: ${e}`); continue; @@ -342,7 +443,9 @@ export class SyncService { const workspaceId = this.utility.extractWorkspaceIdFromSessionPath(sessionFile); await this.ensureWorkspaceNameResolved(workspaceId, sessionFile, workspaceNamesById); - // Try to use cached data first (much faster than parsing) + // Try to use cached data first (faster than full recomputation) + // Note: We still parse the file to get accurate day keys from timestamps, + // but use cached token counts for performance if (useCachedData) { const cacheSuccess = await this.processCachedSessionFile( sessionFile, @@ -350,7 +453,9 @@ export class SyncService { workspaceId, machineId, userId, - rollups + rollups, + startMs, + now ); if (cacheSuccess) { @@ -474,6 +579,8 @@ export class SyncService { if (useCachedData) { this.logCachePerformance(cacheHits, cacheMisses); } + + this.deps.log(`Backend sync: processed ${filesProcessed} files, skipped ${filesSkipped} files outside lookback period`); return { rollups, workspaceNamesById, machineNamesById }; } @@ -502,6 +609,8 @@ export class SyncService { // Avoid excessive syncing when UI refreshes frequently. const lastSyncAt = this.deps.context?.globalState.get('backend.lastSyncAt'); if (!force && lastSyncAt && Date.now() - lastSyncAt < BACKEND_SYNC_MIN_INTERVAL_MS) { + const secondsSinceLastSync = Math.round((Date.now() - lastSyncAt) / 1000); + this.deps.log(`Backend sync: skipping (last sync was ${secondsSinceLastSync}s ago, minimum interval is ${BACKEND_SYNC_MIN_INTERVAL_MS / 1000}s)`); return; } @@ -518,6 +627,17 @@ export class SyncService { const resolvedIdentity = await this.resolveEffectiveUserIdentityForSync(settings, sharingPolicy.includeUserDimension); const { rollups, workspaceNamesById, machineNamesById } = await this.computeDailyRollupsFromLocalSessions({ lookbackDays: settings.lookbackDays, userId: resolvedIdentity.userId }); + + // Log day keys being synced for better visibility + const dayKeys = new Set(); + for (const { key } of rollups.values()) { + dayKeys.add(key.day); + } + const sortedDays = Array.from(dayKeys).sort(); + if (sortedDays.length > 0) { + this.deps.log(`Backend sync: processing data for ${sortedDays.length} days: ${sortedDays.join(', ')}`); + } + this.deps.log(`Backend sync: upserting ${rollups.size} rollup entities (lookback ${settings.lookbackDays} days)`); const tableClient = this.dataPlaneService.createTableClient(settings, creds.tableCredential); @@ -570,6 +690,13 @@ export class SyncService { } this.deps.log('Backend sync: completed'); + + // Trigger UI refresh to update status bar and panels with latest sync data + try { + await this.deps.updateTokenStats?.(); + } catch (e) { + this.deps.warn(`Backend sync: failed to update UI: ${e}`); + } } catch (e: any) { // Keep local mode functional. const secretsToRedact = await this.credentialService.getBackendSecretsToRedactForError(settings); diff --git a/src/extension.ts b/src/extension.ts index a93f5a6..be25ca1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -368,8 +368,6 @@ class CopilotTokenTracker implements vscode.Disposable { public async clearCache(): Promise { try { - // Show the output channel so users can see what's happening - this.outputChannel.show(true); this.log('[DEBUG] clearCache() called'); this.log('Clearing session file cache...'); @@ -388,7 +386,6 @@ class CopilotTokenTracker implements vscode.Disposable { await this.updateTokenStats(); this.log('Token statistics reloaded successfully.'); } catch (error) { - this.outputChannel.show(true); this.error('Error clearing cache:', error); vscode.window.showErrorMessage('Failed to clear cache: ' + error); } @@ -3088,8 +3085,6 @@ class CopilotTokenTracker implements vscode.Disposable { public async showDiagnosticReport(): Promise { this.log('🔍 Opening Diagnostic Report'); - // Keep the output channel visible to prevent VS Code from switching to other channels - this.outputChannel.show(true); // If panel already exists, just reveal it and update content if (this.diagnosticsPanel) { @@ -3197,7 +3192,7 @@ class CopilotTokenTracker implements vscode.Disposable { // Handle messages from the webview this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => { - this.log(`[DEBUG] Diagnostics webview message: ${JSON.stringify(message)}`); + this.log(`Diagnostics webview message: ${JSON.stringify(message)}`); switch (message.command) { case 'copyReport': await vscode.env.clipboard.writeText(report); From 6b2cbf60c29fa9f2d11422fa5c1b0a97c7f36ce7 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 1 Feb 2026 01:00:38 +0100 Subject: [PATCH 5/6] fix type issues --- src/backend/services/syncService.ts | 33 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/backend/services/syncService.ts b/src/backend/services/syncService.ts index d862940..efbacd2 100644 --- a/src/backend/services/syncService.ts +++ b/src/backend/services/syncService.ts @@ -210,21 +210,24 @@ export class SyncService { // Handle JSONL format (Copilot CLI) if (sessionFile.endsWith('.jsonl')) { const lines = content.trim().split('\n'); - for (const line of lines) { - if (!line.trim()) { continue; } - try { - const event = JSON.parse(line); - if (!event || typeof event !== 'object') { continue; } - - const normalizedTs = this.utility.normalizeTimestampToMs(event.timestamp); - const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; - if (!eventMs || eventMs < startMs) { continue; } - - const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); - const model = (event.model || 'gpt-4o').toString(); - - // Log first few interactions from today's files for debugging - if (isFileFromToday && processedLines < 3) { + const todayKey = this.utility.toUtcDayKey(now); + let lineCount = 0; + let processedLines = 0; + + for (const line of lines) { + lineCount++; + if (!line.trim()) { continue; } + try { + const event = JSON.parse(line); + if (!event || typeof event !== 'object') { continue; } + + const normalizedTs = this.utility.normalizeTimestampToMs(event.timestamp); + const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; + if (!eventMs || eventMs < startMs) { continue; } + + const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); + const model = (event.model || 'gpt-4o').toString(); + const isFileFromToday = dayKey === todayKey; this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} line ${lineCount}: eventMs=${new Date(eventMs).toISOString()}, dayKey=${dayKey}, type=${event.type}`); processedLines++; } From 842983ad49ee327cda7ab4e178dfad2e9959dcf3 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 1 Feb 2026 01:10:38 +0100 Subject: [PATCH 6/6] fix build issues --- src/backend/services/syncService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/services/syncService.ts b/src/backend/services/syncService.ts index efbacd2..c2c1c2a 100644 --- a/src/backend/services/syncService.ts +++ b/src/backend/services/syncService.ts @@ -228,10 +228,10 @@ export class SyncService { const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); const model = (event.model || 'gpt-4o').toString(); const isFileFromToday = dayKey === todayKey; - this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} line ${lineCount}: eventMs=${new Date(eventMs).toISOString()}, dayKey=${dayKey}, type=${event.type}`); - processedLines++; - } - + if (isFileFromToday && processedLines < 3) { + this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} line ${lineCount}: eventMs=${new Date(eventMs).toISOString()}, dayKey=${dayKey}, type=${event.type}`); + processedLines++; + } // Track interaction for this day+model (count all events, not just user.message) if (!dayModelInteractions.has(dayKey)) { dayModelInteractions.set(dayKey, new Map());