diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b9fde1bd2..da5357bc7 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -27,6 +27,11 @@ const ORCA_POSTHOG_WRITE_KEY_LITERAL = typeof orcaPostHogWriteKey === 'string' && orcaPostHogWriteKey.length > 0 ? JSON.stringify(orcaPostHogWriteKey) : 'null' +const orcaDiagnosticsTokenUrl = process.env.ORCA_DIAGNOSTICS_TOKEN_URL +const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL = + typeof orcaDiagnosticsTokenUrl === 'string' && orcaDiagnosticsTokenUrl.length > 0 + ? JSON.stringify(orcaDiagnosticsTokenUrl) + : 'null' export default defineConfig({ main: { @@ -51,7 +56,8 @@ export default defineConfig({ // above for the full rationale. define: { ORCA_BUILD_IDENTITY: ORCA_BUILD_IDENTITY_LITERAL, - ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL + ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL, + ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL }, // Why: @xterm/headless declares "exports": null in package.json, which // prevents Vite's default resolver from finding the CJS entry. Point diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index b872389b8..e7dbc07e7 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -11,6 +11,7 @@ consistent across every repo-scoped subprocess call. */ */ import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process' import { promisify } from 'util' +import { withGitSpan } from '../observability/instrumentation' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' @@ -363,15 +364,24 @@ export async function gitExecFileAsync( args: string[], options: GitExecOptions ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand('git', args, options.cwd) - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env - }) - return { stdout: stdout as string, stderr: stderr as string } + // Why wrap here: the resolved binary path / WSL detection is internal + // detail; the span attributes track the user-visible `git + // ` form so dashboards group cleanly by intent rather than by + // platform-conditional binary path. + return withGitSpan( + { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, + async () => { + const resolved = resolveCommand('git', args, options.cwd) + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } + } + ) } /** diff --git a/src/main/index.ts b/src/main/index.ts index 8f486146a..5b752a82c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,8 @@ import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' +import { initObservability, shutdownObservability } from './observability' +import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' @@ -518,6 +520,25 @@ function recordProcessGoneCrash( return } recentCrashKeys.set(key, now) + const span = startSpan('electron.process_gone', { + attributes: { + 'crash.source': source, + 'crash.process_type': processType, + 'crash.reason': reason, + ...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}), + 'app.version': app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + details, + breadcrumbs: getCrashBreadcrumbSnapshot() + } + }) + // Why: renderer/child crashes belong in the local trace lane so the + // diagnostic bundle has the same process-gone signal as the startup prompt. + span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`) void crashReports .record({ source, @@ -827,6 +848,13 @@ app.whenReady().then(async () => { // the Store reference, seeds common props, and resets per-session burst // caps. Actual transport initialization is still gated by both flags. initTelemetry(store) + // Why: the error-tracking lane (telemetry-error-tracking.md) is its own + // composition root — independent of product telemetry — and must + // initialize before any IPC handler / runtime span is created so the + // tracer's active sink is populated at the moment the first span fires. + // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED + // / CI internally; those gates do not need to be re-checked here. + initObservability() // Why: cohort-classifier reads the repo count synchronously at every emit // for cohort-extended events. The Store has been sync-loaded above, and // this init runs before any IPC handler is registered and before any @@ -1184,6 +1212,7 @@ app.on('will-quit', (e) => { // quit chain. Promise.allSettled([disconnectDaemon(), rpcStopAndClear, watcherShutdown]) .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) .catch(() => { /* swallow — telemetry must never prevent app.quit() */ }) diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 049f6e08a..6ea6c300a 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -18,7 +18,7 @@ vi.mock('electron', () => ({ })) vi.mock('./feedback', () => ({ - submitFeedback: (...args: unknown[]) => submitFeedbackMock(...args) + submitFeedback: submitFeedbackMock })) import { registerCrashReportingHandlers } from './crash-reporting' @@ -56,10 +56,10 @@ describe('registerCrashReportingHandlers', () => { it('copies the latest pending diagnostic text to the clipboard', async () => { const latest = report() registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => latest), getById: vi.fn(async () => latest), dismiss: vi.fn(), markSent: vi.fn(), + markDismissedSent: vi.fn(), listRecent: vi.fn(async () => [latest]), record: vi.fn(), formatDiagnosticText: vi.fn() @@ -76,12 +76,60 @@ describe('registerCrashReportingHandlers', () => { ) }) - it('submits a dismissed report when the already-open prompt sends it', async () => { - const dismissed = report('dismissed', 'crash-already-dismissed') + it('returns dismissed unsent reports for the manual Help menu entry', async () => { + const dismissed = report('dismissed', 'crash-help-menu') + registerCrashReportingHandlers({ + getById: vi.fn(async () => dismissed), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [report('sent', 'crash-sent'), dismissed]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + await expect(handlers.get('crashReports:getLatestPending')?.(null)).resolves.toBeNull() + await expect(handlers.get('crashReports:getLatestReport')?.(null)).resolves.toEqual(dismissed) + }) + + it('submits a pending report through feedback and marks it sent', async () => { + const pending = report('pending', 'crash-pending') + const sent = report('sent', pending.id) + const markSent = vi.fn(async () => sent) + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent, + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: pending.id, + notes: 'extra /Users/alice/project', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: null + }) + + expect(result).toEqual({ ok: true, report: sent }) + expect(submitFeedbackMock).toHaveBeenCalledWith({ + feedback: expect.stringContaining('extra [redacted-path]'), + submissionType: 'crash', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: null + }) + expect(markSent).toHaveBeenCalledWith(pending.id) + }) + + it('submits a dismissed startup prompt through feedback and marks it sent', async () => { + const dismissed = report('dismissed', 'crash-dismissed') const sent = report('sent', dismissed.id) const markDismissedSent = vi.fn(async () => sent) registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => null), getById: vi.fn(async () => dismissed), dismiss: vi.fn(), markSent: vi.fn(), @@ -93,137 +141,77 @@ describe('registerCrashReportingHandlers', () => { const result = await handlers.get('crashReports:submit')?.(null, { reportId: dismissed.id, + notes: 'sent from startup prompt', + submitAnonymously: true, githubLogin: null, githubEmail: null }) expect(result).toEqual({ ok: true, report: sent }) - expect(submitFeedbackMock).toHaveBeenCalledWith( - expect.objectContaining({ feedback: expect.stringContaining('[Crash Report]') }) - ) + expect(submitFeedbackMock).toHaveBeenCalledWith({ + feedback: expect.stringContaining('sent from startup prompt'), + submissionType: 'crash', + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id) }) - it('submits through feedback and marks the report sent only after success', async () => { - const latest = report('pending', 'crash-submit-success') - const sent = report('sent', latest.id) - const markSent = vi.fn(async () => sent) + it('dismisses a pending report locally without any network submission', async () => { + const latest = report('pending', 'crash-dismiss') + const dismissed = report('dismissed', latest.id) + const dismiss = vi.fn(async () => dismissed) registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => latest), - getById: vi.fn(async () => latest), - dismiss: vi.fn(), - markSent, - listRecent: vi.fn(async () => [latest]), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - const result = await handlers.get('crashReports:submit')?.(null, { - reportId: latest.id, - githubLogin: 'me', - githubEmail: 'me@example.com', - notes: 'extra' - }) - - expect(result).toEqual({ ok: true, report: sent }) - expect(submitFeedbackMock).toHaveBeenCalledWith( - expect.objectContaining({ - feedback: expect.stringContaining('[Crash Report]'), - submissionType: 'crash', - githubLogin: 'me', - githubEmail: 'me@example.com' - }) - ) - expect(markSent).toHaveBeenCalledWith(latest.id) - }) - - it('does not surface a successful upload as failed if marking sent fails locally', async () => { - const latest = report('pending', 'crash-mark-sent-fails') - const markSent = vi.fn(async () => { - throw new Error('disk unavailable') - }) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => latest), - getById: vi.fn(async () => latest), - dismiss: vi.fn(), - markSent, - listRecent: vi.fn(async () => [latest]), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - try { - const result = await handlers.get('crashReports:submit')?.(null, { - reportId: latest.id, - githubLogin: null, - githubEmail: null - }) - - expect(result).toEqual({ ok: true, report: { ...latest, status: 'sent' } }) - expect(markSent).toHaveBeenCalledWith(latest.id) - await expect(handlers.get('crashReports:getLatestPending')?.(null)).resolves.toBeNull() - } finally { - consoleError.mockRestore() - } - }) - - it('keeps the report pending on feedback failure', async () => { - submitFeedbackMock.mockResolvedValue({ ok: false, status: 500, error: 'status 500' }) - const latest = report('pending', 'crash-submit-failure') - const markSent = vi.fn() - registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => latest), - getById: vi.fn(async () => latest), - dismiss: vi.fn(), - markSent, - listRecent: vi.fn(async () => [latest]), - record: vi.fn(), - formatDiagnosticText: vi.fn() - } as never) - - const result = await handlers.get('crashReports:submit')?.(null, { - githubLogin: null, - githubEmail: null - }) - - expect(result).toMatchObject({ ok: false, status: 500, report: latest }) - expect(markSent).not.toHaveBeenCalled() - }) - - it('does not dismiss a report while submission is in flight', async () => { - let resolveSubmit: (value: { ok: true }) => void = () => {} - submitFeedbackMock.mockReturnValue( - new Promise((resolve) => { - resolveSubmit = resolve - }) - ) - const latest = report('pending', 'crash-in-flight') - const dismiss = vi.fn() - registerCrashReportingHandlers({ - getLatestPending: vi.fn(async () => latest), getById: vi.fn(async () => latest), dismiss, - markSent: vi.fn(async () => report('sent', latest.id)), - listRecent: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [latest]), record: vi.fn(), formatDiagnosticText: vi.fn() } as never) - const submitPromise = handlers.get('crashReports:submit')?.(null, { - reportId: latest.id, - githubLogin: null, - githubEmail: null - }) - await vi.waitFor(() => expect(submitFeedbackMock).toHaveBeenCalled()) - - const dismissResult = await handlers.get('crashReports:dismiss')?.(null, { + const result = await handlers.get('crashReports:dismiss')?.(null, { reportId: latest.id }) - expect(dismissResult).toEqual(latest) - expect(dismiss).not.toHaveBeenCalled() - resolveSubmit({ ok: true }) - await expect(submitPromise).resolves.toMatchObject({ ok: true }) + expect(result).toEqual(dismissed) + expect(dismiss).toHaveBeenCalledWith(latest.id) + expect(submitFeedbackMock).not.toHaveBeenCalled() + }) + + it('keeps a pending report available if feedback submission fails', async () => { + const pending = report('pending', 'crash-failed') + const markSent = vi.fn() + submitFeedbackMock.mockResolvedValue({ + ok: false, + status: 500, + error: 'status 500' + }) + registerCrashReportingHandlers({ + getById: vi.fn(async () => pending), + dismiss: vi.fn(), + markSent, + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => [pending]), + record: vi.fn(), + formatDiagnosticText: vi.fn() + } as never) + + const result = await handlers.get('crashReports:submit')?.(null, { + reportId: pending.id, + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + + expect(result).toEqual({ + ok: false, + status: 500, + error: 'status 500', + report: pending + }) + expect(markSent).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 4d16e1acb..9de812035 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -8,28 +8,44 @@ import { submitFeedback } from './feedback' import type { CrashReportStore } from '../crash-reporting/crash-report-store' const inFlightSubmissions = new Set() -const uploadedReportIds = new Set() +const submittedReportIds = new Set() async function getLatestPendingReport( store: CrashReportStore ): Promise>> { const reports = await store.listRecent() return ( - reports.find((report) => report.status === 'pending' && !uploadedReportIds.has(report.id)) ?? + reports.find((report) => report.status === 'pending' && !submittedReportIds.has(report.id)) ?? null ) } +async function getLatestSendableReport( + store: CrashReportStore +): Promise>> { + const reports = await store.listRecent() + return ( + reports.find( + (report) => + (report.status === 'pending' || report.status === 'dismissed') && + !submittedReportIds.has(report.id) + ) ?? null + ) +} + export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeHandler('crashReports:getLatestPending') ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store)) + ipcMain.removeHandler('crashReports:getLatestReport') + ipcMain.handle('crashReports:getLatestReport', () => getLatestSendableReport(store)) + ipcMain.removeHandler('crashReports:dismiss') ipcMain.handle('crashReports:dismiss', async (_event, args: { reportId: string }) => { if (inFlightSubmissions.has(args.reportId)) { return store.getById(args.reportId) } - if (uploadedReportIds.has(args.reportId)) { + if (submittedReportIds.has(args.reportId)) { const report = await store.getById(args.reportId) return report ? { ...report, status: 'sent' as const } : null } @@ -64,11 +80,11 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed') if ( (!canSubmitDismissedReport && report.status !== 'pending') || - uploadedReportIds.has(report.id) + submittedReportIds.has(report.id) ) { return { ok: true, - report: uploadedReportIds.has(report.id) ? { ...report, status: 'sent' } : report + report: submittedReportIds.has(report.id) ? { ...report, status: 'sent' } : report } } if (inFlightSubmissions.has(report.id)) { @@ -92,7 +108,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { if (!result.ok) { return { ...result, report } } - uploadedReportIds.add(report.id) + submittedReportIds.add(report.id) if (report.status === 'dismissed') { try { // Why: startup prompts are dismissed before the user can send from diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts new file mode 100644 index 000000000..3d05ffa75 --- /dev/null +++ b/src/main/ipc/diagnostics.test.ts @@ -0,0 +1,347 @@ +/* oxlint-disable max-lines -- Why: diagnostics IPC tests share mocked Electron handler setup; splitting would duplicate brittle IPC wiring. */ +import { readFileSync, writeFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CollectedBundle } from '../observability/bundle' +import type * as NodeFs from 'node:fs' + +const handlers = new Map unknown>() + +const { + handleMock, + mkdirSyncMock, + readFileSyncMock, + writeFileSyncMock, + showMessageBoxMock, + openPathMock, + showItemInFolderMock, + collectDiagnosticBundleMock, + deleteDiagnosticBundleMock, + getDiagnosticsStatusMock, + getTraceFilePathMock, + uploadDiagnosticBundleMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + mkdirSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), + writeFileSyncMock: vi.fn(), + showMessageBoxMock: vi.fn(), + openPathMock: vi.fn(), + showItemInFolderMock: vi.fn(), + collectDiagnosticBundleMock: vi.fn(), + deleteDiagnosticBundleMock: vi.fn(), + getDiagnosticsStatusMock: vi.fn(), + getTraceFilePathMock: vi.fn(), + uploadDiagnosticBundleMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + mkdirSync: mkdirSyncMock, + readFileSync: readFileSyncMock, + writeFileSync: writeFileSyncMock + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp', getVersion: () => '1.2.3-test' }, + dialog: { showMessageBox: showMessageBoxMock }, + ipcMain: { handle: handleMock }, + shell: { openPath: openPathMock, showItemInFolder: showItemInFolderMock } +})) + +vi.mock('../observability', () => ({ + clearLocalTraces: vi.fn(), + collectDiagnosticBundle: collectDiagnosticBundleMock, + deleteDiagnosticBundle: deleteDiagnosticBundleMock, + getDiagnosticsStatus: getDiagnosticsStatusMock, + getTraceFilePath: getTraceFilePathMock, + uploadDiagnosticBundle: uploadDiagnosticBundleMock +})) + +import { registerDiagnosticsHandlers } from './diagnostics' + +function captureHandlers(): void { + handlers.clear() + for (const call of handleMock.mock.calls) { + const [channel, handler] = call as [ + string, + typeof handlers extends Map ? V : never + ] + handlers.set(channel, handler) + } +} + +function makeBundle(overrides: Partial = {}): CollectedBundle { + return { + bundleSubmissionId: 'abcdefghijklmnopqrstuv', + payload: '{"type":"bundle-header"}\n', + bytes: 25, + spanCount: 0, + ...overrides + } +} + +describe('diagnostics IPC handlers', () => { + beforeEach(() => { + handleMock.mockReset() + mkdirSyncMock.mockReset() + readFileSyncMock.mockReset() + writeFileSyncMock.mockReset() + showMessageBoxMock.mockReset() + showMessageBoxMock.mockResolvedValue({ response: 0 }) + openPathMock.mockReset() + openPathMock.mockResolvedValue('') + showItemInFolderMock.mockReset() + collectDiagnosticBundleMock.mockReset() + deleteDiagnosticBundleMock.mockReset() + getDiagnosticsStatusMock.mockReset() + getTraceFilePathMock.mockReset() + uploadDiagnosticBundleMock.mockReset() + delete (globalThis as { ORCA_BUILD_IDENTITY?: unknown }).ORCA_BUILD_IDENTITY + delete (globalThis as { ORCA_DIAGNOSTICS_TOKEN_URL?: unknown }).ORCA_DIAGNOSTICS_TOKEN_URL + process.env.ORCA_DIAGNOSTICS_TOKEN_URL = 'https://diagnostics.example.com/diagnostics/token' + getDiagnosticsStatusMock.mockReturnValue({ + localFileEnabled: true, + otlpEnabled: false, + bundleEnabled: true, + otlpStatus: 'Disabled', + traceFilePath: '/tmp/main.trace.ndjson', + traceFamilySize: 0 + }) + collectDiagnosticBundleMock.mockReturnValue(makeBundle()) + readFileSyncMock.mockReturnValue(makeBundle().payload) + uploadDiagnosticBundleMock.mockResolvedValue({ ticketId: 'ticketabcdefghijklmnop' }) + deleteDiagnosticBundleMock.mockResolvedValue(undefined) + registerDiagnosticsHandlers() + captureHandlers() + }) + + it('rejects upload without a main-collected bundle preview', async () => { + const upload = handlers.get('diagnostics:uploadBundle')! + await expect(upload({}, 'rendererMintedBundleId')).rejects.toThrow(/expired/) + expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled() + }) + + it('uploads only the payload retained by main after collection', async () => { + const bundle = makeBundle({ + bundleSubmissionId: 'bundleabcdefghijklmnop', + payload: '{"type":"bundle-header"}\n{"safe":true}\n' + }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + readFileSyncMock.mockReturnValue(bundle.payload) + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + await upload({}, bundle.bundleSubmissionId) + + expect(uploadDiagnosticBundleMock).toHaveBeenCalledWith({ + tokenEndpoint: 'https://diagnostics.example.com/diagnostics/token', + payload: bundle.payload, + bundleSubmissionId: bundle.bundleSubmissionId + }) + }) + + it('pins official builds to the compile-time diagnostics endpoint', async () => { + const bundle = makeBundle({ + bundleSubmissionId: 'bundleabcdefghijklmnop', + payload: '{"type":"bundle-header"}\n{"safe":true}\n' + }) + const globalOverrides = globalThis as { + ORCA_BUILD_IDENTITY?: 'stable' + ORCA_DIAGNOSTICS_TOKEN_URL?: string + } + globalOverrides.ORCA_BUILD_IDENTITY = 'stable' + globalOverrides.ORCA_DIAGNOSTICS_TOKEN_URL = 'https://official.example.com/diagnostics/token' + process.env.ORCA_DIAGNOSTICS_TOKEN_URL = 'https://attacker.example.com/diagnostics/token' + collectDiagnosticBundleMock.mockReturnValue(bundle) + readFileSyncMock.mockReturnValue(bundle.payload) + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + await upload({}, bundle.bundleSubmissionId) + + expect(uploadDiagnosticBundleMock).toHaveBeenCalledWith({ + tokenEndpoint: 'https://official.example.com/diagnostics/token', + payload: bundle.payload, + bundleSubmissionId: bundle.bundleSubmissionId + }) + }) + + it('requires main-owned user confirmation before upload', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + showMessageBoxMock.mockResolvedValue({ response: 1 }) + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/cancelled/) + expect(showMessageBoxMock).toHaveBeenCalledTimes(1) + expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled() + }) + + it('rechecks the retained preview after upload confirmation', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + const discard = handlers.get('diagnostics:discardBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + showMessageBoxMock.mockImplementation(async () => { + await discard({}, bundle.bundleSubmissionId) + return { response: 0 } + }) + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/expired/) + + expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled() + }) + + it('ignores edited preview file contents and uploads the retained original payload', async () => { + const bundle = makeBundle({ + bundleSubmissionId: 'bundleabcdefghijklmnop', + payload: '{"original":true}\n' + }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + readFileSyncMock.mockReturnValue('{"edited":true}\n') + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + await upload({}, bundle.bundleSubmissionId) + + expect(readFileSync).not.toHaveBeenCalled() + expect(uploadDiagnosticBundleMock).toHaveBeenCalledWith( + expect.objectContaining({ payload: '{"original":true}\n' }) + ) + }) + + it('opens the retained bundle preview file through main', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const openPreview = handlers.get('diagnostics:openBundlePreview')! + + await collect({}, 30) + await openPreview({}, bundle.bundleSubmissionId) + + expect(openPathMock).toHaveBeenCalledWith( + expect.stringContaining(`${bundle.bundleSubmissionId}.ndjson`) + ) + }) + + it('requires opening the retained preview before upload', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/open.*preview/) + expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled() + }) + + it('discards retained bundle previews on request', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const discard = handlers.get('diagnostics:discardBundlePreview')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await discard({}, bundle.bundleSubmissionId) + + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/expired/) + }) + + it('clears retained previews when local traces are cleared', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + const clear = handlers.get('diagnostics:clearTraces')! + const upload = handlers.get('diagnostics:uploadBundle')! + + await collect({}, 30) + await clear({}) + + await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/expired/) + }) + + it('writes retained preview files with private permissions', async () => { + const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + + await collect({}, 30) + + expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), { + mode: 0o700, + recursive: true + }) + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining(`${bundle.bundleSubmissionId}.ndjson`), + bundle.payload, + { encoding: 'utf8', mode: 0o600 } + ) + }) + + it('returns only bundle metadata from collection', async () => { + const bundle = makeBundle({ + bundleSubmissionId: 'bundleabcdefghijklmnop', + payload: '{"type":"bundle-header"}\n{"safe":true}\n', + bytes: 37, + spanCount: 1 + }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + + expect(collect({}, 30)).toEqual({ + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + }) + }) + + it('does not expose retained bundle payloads through collection IPC', async () => { + const bundle = makeBundle({ + bundleSubmissionId: 'bundleabcdefghijklmnop', + payload: '{"secret":"retained in main"}\n' + }) + collectDiagnosticBundleMock.mockReturnValue(bundle) + const collect = handlers.get('diagnostics:collectBundle')! + + const preview = await collect({}, 30) + expect(JSON.stringify(preview)).not.toContain('retained in main') + }) + + it('allows crash-report lookbacks beyond 24 hours while bounding abuse', async () => { + const collect = handlers.get('diagnostics:collectBundle')! + await collect({}, 3 * 24 * 60) + expect(collectDiagnosticBundleMock).toHaveBeenCalledWith( + expect.objectContaining({ lookbackMinutes: 3 * 24 * 60 }) + ) + }) + + it('registers and handles bundle deletion by ticket ID', async () => { + const deleteBundle = handlers.get('diagnostics:deleteBundle')! + await deleteBundle({}, 'ticketabcdefghijklmnop') + expect(deleteDiagnosticBundleMock).toHaveBeenCalledWith({ + tokenEndpoint: 'https://diagnostics.example.com/diagnostics/token', + ticketId: 'ticketabcdefghijklmnop' + }) + }) +}) diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts new file mode 100644 index 000000000..2b8c6de82 --- /dev/null +++ b/src/main/ipc/diagnostics.ts @@ -0,0 +1,367 @@ +// IPC surface for the error-tracking lane (telemetry-error-tracking.md +// §User controls). Seven renderer-facing channels: +// +// diagnostics:getStatus — read-only snapshot for the Privacy pane. +// diagnostics:openTraceFolder — Reveal in Finder / Explorer. +// diagnostics:clearTraces — delete the rotated NDJSON family. +// diagnostics:collectBundle — assemble and retain a redacted payload. +// diagnostics:openBundlePreview — open the retained payload in the OS. +// diagnostics:discardBundlePreview — delete a retained, unuploaded payload. +// diagnostics:uploadBundle — POST the main-retained payload. +// diagnostics:deleteBundle — delete an uploaded bundle by ticket ID. +// +// Same threat model as the product-telemetry IPC (`ipc/telemetry.ts`): +// renderer can pass anything over the wire, type-narrow here. Everything +// that touches the network or filesystem stays in main — the renderer +// only sees the resulting status / preview / ticket-id. +// +// Hardening item §Endpoint contract #10 ("No renderer access to any of +// these endpoints"): the upload endpoint URL never crosses IPC. The +// renderer triggers the flow; main reads the URL from a build-time +// constant or env var and does the POST itself. + +import { app, dialog, ipcMain, shell } from 'electron' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + clearLocalTraces, + collectDiagnosticBundle, + deleteDiagnosticBundle, + getDiagnosticsStatus, + getTraceFilePath, + uploadDiagnosticBundle, + type DiagnosticsStatus +} from '../observability' +import type { CollectedBundle } from '../observability/bundle' +import type { UploadBundleResult } from '../observability/diagnostic-bundle-upload' + +export type DiagnosticsBundlePreview = Omit + +const PENDING_BUNDLE_TTL_MS = 15 * 60 * 1000 +const MAX_PENDING_BUNDLES = 8 + +type PendingBundle = { + bundle: CollectedBundle + readonly createdAtMs: number + readonly previewFilePath: string + previewOpened: boolean +} + +const pendingBundles = new Map() + +// Build-time constant for the diagnostic-token endpoint. Substituted by +// electron-vite at compile time. Local / contributor builds get `null`, +// at which point the upload path returns a clear "endpoint not configured" +// error rather than POSTing to a placeholder. +// +// The dev escape hatch is `ORCA_DIAGNOSTICS_TOKEN_URL` — set this env var +// to point at a local server during development. Mirrors the +// `ORCA_OTLP_TRACES_URL` env-var pattern for OTLP. +function resolveBuildTokenEndpoint(): string | null { + const endpoint = + typeof ORCA_DIAGNOSTICS_TOKEN_URL !== 'undefined' + ? ORCA_DIAGNOSTICS_TOKEN_URL + : ((globalThis as { ORCA_DIAGNOSTICS_TOKEN_URL?: string | null }) + .ORCA_DIAGNOSTICS_TOKEN_URL ?? null) + return typeof endpoint === 'string' && endpoint.length > 0 ? endpoint : null +} + +function resolveBuildIdentity(): 'stable' | 'rc' | null { + const ident = + typeof ORCA_BUILD_IDENTITY !== 'undefined' + ? ORCA_BUILD_IDENTITY + : ((globalThis as { ORCA_BUILD_IDENTITY?: 'stable' | 'rc' | null }).ORCA_BUILD_IDENTITY ?? + null) + return ident === 'stable' || ident === 'rc' ? ident : null +} + +function resolveTokenEndpoint(): string | null { + const buildEndpoint = resolveBuildTokenEndpoint() + // Official builds must stay pinned to the CI-substituted endpoint; the + // upload confirmation says "Orca support", so env cannot redirect it. + if (resolveBuildIdentity()) { + return buildEndpoint + } + // Env wins only for dev / unofficial builds so contributors can point a + // local packaged app at staging without re-running a release pipeline. + const fromEnv = process.env.ORCA_DIAGNOSTICS_TOKEN_URL + if (fromEnv && fromEnv.length > 0) { + return fromEnv + } + return buildEndpoint +} + +function resolveOrcaChannel(): 'stable' | 'rc' | 'dev' { + const ident = resolveBuildIdentity() + if (ident === 'stable' || ident === 'rc') { + return ident + } + return 'dev' +} + +function prunePendingBundles(now = Date.now()): void { + for (const [id, pending] of pendingBundles) { + if (now - pending.createdAtMs > PENDING_BUNDLE_TTL_MS) { + deletePreviewFile(pending.previewFilePath) + pendingBundles.delete(id) + } + } + while (pendingBundles.size > MAX_PENDING_BUNDLES) { + const oldest = pendingBundles.keys().next().value as string | undefined + if (!oldest) { + break + } + const pending = pendingBundles.get(oldest) + if (pending) { + deletePreviewFile(pending.previewFilePath) + pendingBundles.delete(oldest) + } + } +} + +function rememberBundle(bundle: CollectedBundle): void { + const previewFilePath = writeBundlePreviewFile(bundle) + pendingBundles.set(bundle.bundleSubmissionId, { + bundle, + createdAtMs: Date.now(), + previewFilePath, + previewOpened: false + }) + prunePendingBundles() +} + +function toBundlePreview(bundle: CollectedBundle): DiagnosticsBundlePreview { + return { + bundleSubmissionId: bundle.bundleSubmissionId, + bytes: bundle.bytes, + spanCount: bundle.spanCount + } +} + +function getPendingBundleForUpload(bundleSubmissionId: unknown): { + readonly bundle: CollectedBundle + readonly payload: string +} { + if ( + typeof bundleSubmissionId !== 'string' || + !/^[A-Za-z0-9_-]{16,64}$/.test(bundleSubmissionId) + ) { + throw new Error('bundleSubmissionId has invalid format') + } + prunePendingBundles() + const pending = pendingBundles.get(bundleSubmissionId) + if (!pending) { + throw new Error('diagnostic bundle has expired; collect a new preview before uploading') + } + if (!pending.previewOpened) { + throw new Error('open the diagnostic bundle preview before uploading') + } + // Why: the preview file is user-editable once opened in the OS. Upload only + // the redacted bytes main collected and retained before preview. + return { bundle: pending.bundle, payload: pending.bundle.payload } +} + +function getPendingPreviewFilePath(bundleSubmissionId: unknown): string { + if ( + typeof bundleSubmissionId !== 'string' || + !/^[A-Za-z0-9_-]{16,64}$/.test(bundleSubmissionId) + ) { + throw new Error('bundleSubmissionId has invalid format') + } + prunePendingBundles() + const pending = pendingBundles.get(bundleSubmissionId) + if (!pending) { + throw new Error('diagnostic bundle has expired; collect a new preview before opening') + } + return pending.previewFilePath +} + +function discardPendingBundle(bundleSubmissionId: unknown): void { + if ( + typeof bundleSubmissionId !== 'string' || + !/^[A-Za-z0-9_-]{16,64}$/.test(bundleSubmissionId) + ) { + throw new Error('bundleSubmissionId has invalid format') + } + const pending = pendingBundles.get(bundleSubmissionId) + if (pending) { + deletePreviewFile(pending.previewFilePath) + pendingBundles.delete(bundleSubmissionId) + } +} + +function getPreviewDirectory(): string { + let base: string + try { + base = app.getPath('temp') + } catch { + base = tmpdir() + } + return join(base, 'orca-diagnostic-bundle-previews') +} + +function writeBundlePreviewFile(bundle: CollectedBundle): string { + const previewDirectory = getPreviewDirectory() + mkdirSync(previewDirectory, { mode: 0o700, recursive: true }) + const previewFilePath = join(previewDirectory, `${bundle.bundleSubmissionId}.ndjson`) + writeFileSync(previewFilePath, bundle.payload, { encoding: 'utf8', mode: 0o600 }) + return previewFilePath +} + +function deletePreviewFile(filePath: string): void { + try { + if (existsSync(filePath)) { + unlinkSync(filePath) + } + } catch { + /* best effort */ + } +} + +function discardAllPendingBundles(): void { + for (const pending of pendingBundles.values()) { + deletePreviewFile(pending.previewFilePath) + } + pendingBundles.clear() +} + +function isTicketId(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(value) +} + +async function confirmBundleUpload(bundle: CollectedBundle): Promise { + const result = await dialog.showMessageBox({ + type: 'question', + buttons: ['Upload', 'Cancel'], + defaultId: 1, + cancelId: 1, + title: 'Upload diagnostic bundle?', + message: 'Upload diagnostic bundle to Orca support?', + detail: `Bundle ${bundle.bundleSubmissionId}\n${bundle.spanCount} span(s), ${Math.round( + bundle.bytes / 1024 + )} KB\n\nThe exact redacted NDJSON preview was opened before this upload confirmation.` + }) + if (result.response !== 0) { + throw new Error('diagnostic bundle upload cancelled') + } +} + +export function registerDiagnosticsHandlers(): void { + ipcMain.handle('diagnostics:getStatus', (): DiagnosticsStatus => { + return getDiagnosticsStatus() + }) + + ipcMain.handle('diagnostics:openTraceFolder', async (): Promise => { + // Show the trace file's parent in the OS file manager. Using + // `showItemInFolder` rather than `openPath(folder)` so the file itself + // is highlighted — the user is much more likely to want to inspect + // `main.trace.ndjson` than to browse the `logs/` directory. + try { + shell.showItemInFolder(getTraceFilePath()) + } catch { + /* swallow — best effort; the user can navigate manually */ + } + }) + + ipcMain.handle('diagnostics:clearTraces', (): void => { + discardAllPendingBundles() + clearLocalTraces() + }) + + ipcMain.handle( + 'diagnostics:collectBundle', + (_event, lookbackMinutesIn: unknown): DiagnosticsBundlePreview => { + // Consent gate: main is the consent enforcement boundary; the + // renderer-side button-hide is UX, not security. A compromised or + // malicious renderer must not be able to assemble a bundle when the + // user has disabled diagnostic-bundle collection in Settings → Privacy. + const status = getDiagnosticsStatus() + if (!status.bundleEnabled) { + throw new Error('diagnostic bundle collection is disabled') + } + // Renderer-controlled input → narrow at the boundary. The default + // (DEFAULT_LOOKBACK_MINUTES in bundle.ts) is fine for the common + // "last 30 minutes" case the Privacy pane button triggers. + const lookbackMinutes = + typeof lookbackMinutesIn === 'number' && Number.isFinite(lookbackMinutesIn) + ? Math.max(1, Math.min(30 * 24 * 60, Math.floor(lookbackMinutesIn))) + : undefined + const bundle = collectDiagnosticBundle({ + appVersion: app.getVersion(), + platform: osPlatform(), + arch: osArch(), + osRelease: osRelease(), + orcaChannel: resolveOrcaChannel(), + ...(lookbackMinutes !== undefined ? { lookbackMinutes } : {}) + }) + rememberBundle(bundle) + return toBundlePreview(bundle) + } + ) + + ipcMain.handle( + 'diagnostics:uploadBundle', + async (_event, bundleSubmissionId: unknown): Promise => { + // Why: the renderer is in the threat model. Upload only a payload main + // collected and retained for preview, never renderer-supplied bytes. + const pendingForConfirmation = getPendingBundleForUpload(bundleSubmissionId) + // Consent gate: main is the consent enforcement boundary; the + // renderer-side button-hide is UX, not security. Re-check here in case + // the user toggled the setting off between collect and upload. + if (!getDiagnosticsStatus().bundleEnabled) { + throw new Error('diagnostic bundle collection is disabled') + } + await confirmBundleUpload(pendingForConfirmation.bundle) + // Why: the preview can be discarded or diagnostics can be disabled + // while the native confirmation dialog is open. + const { bundle, payload } = getPendingBundleForUpload(bundleSubmissionId) + if (!getDiagnosticsStatus().bundleEnabled) { + throw new Error('diagnostic bundle collection is disabled') + } + const tokenEndpoint = resolveTokenEndpoint() + if (!tokenEndpoint) { + throw new Error('diagnostic upload endpoint is not configured for this build') + } + const result = await uploadDiagnosticBundle({ + tokenEndpoint, + payload, + bundleSubmissionId: bundle.bundleSubmissionId + }) + const uploadedPending = pendingBundles.get(bundle.bundleSubmissionId) + if (uploadedPending) { + deletePreviewFile(uploadedPending.previewFilePath) + } + pendingBundles.delete(bundle.bundleSubmissionId) + return result + } + ) + + ipcMain.handle('diagnostics:openBundlePreview', async (_event, bundleSubmissionId: unknown) => { + const previewFilePath = getPendingPreviewFilePath(bundleSubmissionId) + const errorMessage = await shell.openPath(previewFilePath) + if (errorMessage) { + throw new Error('could not open diagnostic bundle preview') + } + const pending = pendingBundles.get(bundleSubmissionId as string) + if (pending) { + pending.previewOpened = true + } + }) + + ipcMain.handle('diagnostics:discardBundlePreview', (_event, bundleSubmissionId: unknown) => { + discardPendingBundle(bundleSubmissionId) + }) + + ipcMain.handle('diagnostics:deleteBundle', async (_event, ticketId: unknown): Promise => { + if (!isTicketId(ticketId)) { + throw new Error('ticketId has invalid format') + } + const tokenEndpoint = resolveTokenEndpoint() + if (!tokenEndpoint) { + throw new Error('diagnostic upload endpoint is not configured for this build') + } + await deleteDiagnosticBundle({ tokenEndpoint, ticketId }) + }) +} diff --git a/src/main/ipc/feedback.test.ts b/src/main/ipc/feedback.test.ts index 6f7d8818d..ebc86399f 100644 --- a/src/main/ipc/feedback.test.ts +++ b/src/main/ipc/feedback.test.ts @@ -1,14 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const fetchMock = vi.fn() +const { fetchMock, handlers } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + handlers: new Map unknown>() +})) vi.mock('electron', () => ({ app: { getVersion: () => '1.2.3-test' }, - ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + removeHandler: vi.fn((channel: string) => handlers.delete(channel)) + }, net: { fetch: (...args: unknown[]) => fetchMock(...args) } })) -import { submitFeedback } from './feedback' +import { registerFeedbackHandlers, submitFeedback } from './feedback' function okResponse(): Response { return { ok: true, status: 200 } as unknown as Response @@ -21,6 +29,7 @@ function postedBody(): Record { describe('submitFeedback', () => { beforeEach(() => { + handlers.clear() fetchMock.mockReset() fetchMock.mockResolvedValue(okResponse()) }) @@ -68,14 +77,14 @@ describe('submitFeedback', () => { }) }) - it('marks crash submissions so the backend can route them separately', async () => { + it('preserves crash submissions for the crash report lane', async () => { await submitFeedback({ feedback: '[Crash Report]', submissionType: 'crash', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: null - }) + } as Parameters[0]) expect(postedBody()).toMatchObject({ feedback: '[Crash Report]', @@ -84,4 +93,22 @@ describe('submitFeedback', () => { githubEmail: null }) }) + + it('forces renderer IPC submissions onto the feedback lane', async () => { + registerFeedbackHandlers() + await handlers.get('feedback:submit')?.(null, { + feedback: 'not a crash report', + submissionType: 'crash', + submitAnonymously: false, + githubLogin: 'trusted-user', + githubEmail: null + }) + + expect(postedBody()).toMatchObject({ + feedback: 'not a crash report', + submissionType: 'feedback', + githubLogin: 'trusted-user', + githubEmail: null + }) + }) }) diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index c889ff0de..f062b5b65 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -16,7 +16,6 @@ export type FeedbackSubmitArgs = { submitAnonymously?: boolean githubLogin: string | null githubEmail: string | null - submissionType?: FeedbackSubmissionType } type FeedbackSubmitBody = { @@ -34,12 +33,16 @@ export type FeedbackSubmitResult = | { ok: true } | { ok: false; status: number | null; error: string } +type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & { + submissionType?: FeedbackSubmissionType +} + // Why: the Slack notification and any follow-up investigation need to know // which Orca build and which OS the feedback came from. The main process is // the only place with trusted access to these values (app.getVersion and the // node os module), so we enrich the payload here rather than trusting the // renderer. -function buildSubmitBody(args: FeedbackSubmitArgs): FeedbackSubmitBody { +function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { const identity = args.submitAnonymously ? { githubLogin: null, githubEmail: null } : { githubLogin: args.githubLogin, githubEmail: args.githubEmail } @@ -65,7 +68,9 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise { +export async function submitFeedback( + args: InternalFeedbackSubmitArgs +): Promise { const body = buildSubmitBody(args) try { const res = await postFeedback(FEEDBACK_API_URL, body) @@ -103,5 +108,9 @@ export async function submitFeedback(args: FeedbackSubmitArgs): Promise submitFeedback(args)) + ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => + // Why: crash submissions are main-only. A compromised renderer can invoke + // this channel directly, so force the public feedback lane at the boundary. + submitFeedback({ ...args, submissionType: 'feedback' }) + ) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 518f770e0..3dff929ce 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -18,6 +18,7 @@ const { registerComputerUsePermissionHandlersMock, registerSettingsHandlersMock, registerTelemetryHandlersMock, + registerDiagnosticsHandlersMock, registerShellHandlersMock, registerPetHandlersMock, registerSessionHandlersMock, @@ -62,6 +63,7 @@ const { registerComputerUsePermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), registerTelemetryHandlersMock: vi.fn(), + registerDiagnosticsHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), registerPetHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), @@ -176,6 +178,10 @@ vi.mock('./telemetry', () => ({ registerTelemetryHandlers: registerTelemetryHandlersMock })) +vi.mock('./diagnostics', () => ({ + registerDiagnosticsHandlers: registerDiagnosticsHandlersMock +})) + vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) @@ -277,6 +283,7 @@ describe('registerCoreHandlers', () => { registerComputerUsePermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() registerTelemetryHandlersMock.mockReset() + registerDiagnosticsHandlersMock.mockReset() registerShellHandlersMock.mockReset() registerPetHandlersMock.mockReset() registerSessionHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 47218794a..d11e8faac 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -30,6 +30,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerDiagnosticsHandlers } from './diagnostics' import { registerSkillsHandlers } from './skills' import { registerWorkspaceSpaceHandlers } from './workspace-space' import { registerWorkspacePortHandlers } from './workspace-ports' @@ -113,6 +114,11 @@ export function registerCoreHandlers( registerNotebookHandlers(store) registerOnboardingHandlers(store) registerDeveloperPermissionHandlers() + // Why: diagnostics handlers are wired alongside telemetry but the two + // lanes never share a code path — `ipc/diagnostics.ts` imports only from + // `src/main/observability/`, never from `src/main/telemetry/`. Order is + // not load-bearing; both register independent ipcMain channels. + registerDiagnosticsHandlers() registerComputerUsePermissionHandlers() registerSettingsHandlers(store, agentAwakeService) registerSkillsHandlers(store) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 93801400e..ee09b5dfb 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -21,6 +21,7 @@ import { removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' +import { withWorktreeSpan } from '../observability/instrumentation' import { getDefaultRemote } from '../git/repo' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils' @@ -375,51 +376,61 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:create', async (_event, args: CreateWorktreeArgs): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support creating worktrees.') - } + // Why span here: worktree creation chains a clone-or-checkout, an + // install hook, and several git invocations. Wrapping the IPC entry + // gives every child git span a parent to attach to, so a failure in + // step 3 of 5 still shows up in the trace tree alongside steps 1–2. + // The branch name and remote URL are intentionally not added as + // attributes — branch names can carry user-content (e.g. an issue + // title) and the redactor would have to learn yet another rule; + // the repo ID is the safer correlator for the bundle. + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + if (isFolderRepo(repo)) { + throw new Error('Folder mode does not support creating worktrees.') + } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - let result: CreateWorktreeResult - try { - // Why: only wrap the helpers themselves. The pre-validation throws - // above (`Repo not found`, `Folder mode does not support creating - // worktrees`) signal IPC-shape bugs, not the user-visible - // git/filesystem failures the funnel cares about — bucketing them - // into `unknown` would pollute the failure taxonomy. - result = repo.connectionId - ? await createRemoteWorktree(args, repo, store, mainWindow) - : await createLocalWorktree(args, repo, store, mainWindow, runtime) - } catch (error) { - track('workspace_create_failed', { + let result: CreateWorktreeResult + try { + // Why: only wrap the helpers themselves. The pre-validation throws + // above (`Repo not found`, `Folder mode does not support creating + // worktrees`) signal IPC-shape bugs, not the user-visible + // git/filesystem failures the funnel cares about — bucketing them + // into `unknown` would pollute the failure taxonomy. + result = repo.connectionId + ? await createRemoteWorktree(args, repo, store, mainWindow) + : await createLocalWorktree(args, repo, store, mainWindow, runtime) + } catch (error) { + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + + // Why: emit `workspace_created` only after the underlying create has + // resolved (the helpers throw on failure, so reaching this line means + // git-add succeeded — we deliberately do not also emit a separate + // `workspace_initialized`, see telemetry-plan.md§Deferred events). + // `from_existing_branch` is true iff the caller specified a non-empty + // baseBranch; an unspecified baseBranch means "branch from default + // HEAD", which is the not-from-existing-branch case. We never send + // the branch name itself. + track('workspace_created', { source, - error_class: classifyWorkspaceCreateError(error), + from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, ...getCohortAtEmit() }) - throw error - } - // Why: emit `workspace_created` only after the underlying create has - // resolved (the helpers throw on failure, so reaching this line means - // git-add succeeded — we deliberately do not also emit a separate - // `workspace_initialized`, see telemetry-plan.md§Deferred events). - // `from_existing_branch` is true iff the caller specified a non-empty - // baseBranch; an unspecified baseBranch means "branch from default - // HEAD", which is the not-from-existing-branch case. We never send - // the branch name itself. - track('workspace_created', { - source, - from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, - ...getCohortAtEmit() + return result }) - - return result } ) diff --git a/src/main/observability/architecture.test.ts b/src/main/observability/architecture.test.ts new file mode 100644 index 000000000..4e94b7d3f --- /dev/null +++ b/src/main/observability/architecture.test.ts @@ -0,0 +1,96 @@ +// Architectural-invariant test (telemetry-error-tracking.md §Architecture): +// +// "Nothing in `src/main/telemetry/` imports from `src/main/observability/` +// or vice versa. The two lanes never share a code path." +// +// Cross-contamination is the failure mode the entire two-lane split is +// counter-designed against. oxlint's plugin set does not include +// `import-x`, and adding eslint just for this rule is a heavier lift than +// the rule warrants. A vitest test that grep-scans the two directories is +// adequate, runs in <50 ms, and fails CI loudly if a future PR adds the +// wrong import. +// +// What it catches: +// - any `from '../observability'` / `from '../observability/...'` inside +// `src/main/telemetry/` +// - any `from '../telemetry'` / `from '../telemetry/...'` inside +// `src/main/observability/` +// - the same with deeper relative paths (`../../observability/...`) +// - the same with absolute-from-src forms if anyone introduces them +// +// What it does NOT catch: +// - dynamic `await import()` / `require()` — neither lane uses these, +// and adding a regex catch would create false positives. If a future +// change introduces dynamic loading, extend the regex set. +// - re-exports through a third module. The simplest workaround +// (re-export `observability` from a "neutral" module) is the same +// anti-pattern this test is preventing; reviewers should reject it. +// +// Both directions of the rule are checked symmetrically — the asymmetric +// alternative (only one-way) leaves the door open to a `setOptIn` → +// `bundle` callback chain that smuggles consent state across the boundary. + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = join(__dirname, '..', '..', '..') +const TELEMETRY_DIR = join(REPO_ROOT, 'src', 'main', 'telemetry') +const OBSERVABILITY_DIR = join(REPO_ROOT, 'src', 'main', 'observability') + +function listTsFiles(dir: string): string[] { + const out: string[] = [] + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + out.push(...listTsFiles(full)) + } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) { + out.push(full) + } + } + return out +} + +function findOffendingImports(file: string, forbiddenSegment: string): string[] { + const text = readFileSync(file, 'utf8') + // Match `from ''`, `from ""`, dynamic `import('')` and + // `require('')`. The `` capture is what we inspect. + const importRe = /(?:from\s+|import\(|require\()\s*(['"])([^'"]+)\1/g + const offenders: string[] = [] + let m: RegExpExecArray | null + while ((m = importRe.exec(text)) !== null) { + const spec = m[2] + if (spec.includes(forbiddenSegment)) { + offenders.push(spec) + } + } + return offenders +} + +describe('architectural invariant — telemetry / observability lane isolation', () => { + it('no file in src/main/telemetry/ imports from observability', () => { + const files = listTsFiles(TELEMETRY_DIR) + expect(files.length).toBeGreaterThan(0) // sanity: directory exists + const violations = files.flatMap((f) => { + const bad = findOffendingImports(f, 'observability') + return bad.map((spec) => `${relative(REPO_ROOT, f)}: imports '${spec}'`) + }) + expect(violations, violations.join('\n')).toEqual([]) + }) + + it('no file in src/main/observability/ imports from telemetry', () => { + const files = listTsFiles(OBSERVABILITY_DIR) + expect(files.length).toBeGreaterThan(0) + const violations = files.flatMap((f) => { + // Allow this very file — the test references the path string for its + // own message, and the whitelist is one specific filename rather than + // a directory exemption. + if (f.endsWith('architecture.test.ts')) { + return [] + } + const bad = findOffendingImports(f, 'telemetry') + return bad.map((spec) => `${relative(REPO_ROOT, f)}: imports '${spec}'`) + }) + expect(violations, violations.join('\n')).toEqual([]) + }) +}) diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts new file mode 100644 index 000000000..fda71e5c0 --- /dev/null +++ b/src/main/observability/bundle.test.ts @@ -0,0 +1,499 @@ +/* oxlint-disable max-lines -- Why: diagnostics bundle fixtures cover collection, preview deletion, upload URL hardening, and byte caps as one contract surface. Splitting would duplicate the temp-file/server harness and make edge-case coverage harder to audit. */ +// Bundle collection + upload tests. Upload helpers live outside bundle.ts, but +// this suite keeps the diagnostic bundle contract in one place. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type RequestListener, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { _internalsForTests, collectBundle, generateBundleSubmissionId } from './bundle' +import { deleteBundle, uploadBundle, validateUploadUrl } from './diagnostic-bundle-upload' +import { MAX_RESPONSE_BYTES } from './diagnostic-upload-http' + +let dir: string +let traceFile: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-bundle-')) + traceFile = join(dir, 'main.trace.ndjson') +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function makeNDJSON(records: unknown[]): string { + return `${records.map((r) => JSON.stringify(r)).join('\n')}\n` +} + +function makeSpan(overrides: Record = {}): Record { + const now = BigInt(Date.now()) * 1_000_000n + return { + type: 'effect-span', + name: 'test', + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + kind: 'internal', + startTimeUnixNano: String(now - 1_000_000_000n), + endTimeUnixNano: String(now), + durationMs: 1.0, + attributes: {}, + events: [], + exit: { _tag: 'Success' }, + ...overrides + } +} + +describe('bundle — submission ID', () => { + it('is base64url, 22 chars (128 bits)', () => { + const id = generateBundleSubmissionId() + expect(id).toMatch(/^[A-Za-z0-9_-]{22}$/) + }) + it('is unique across many calls', () => { + const ids = new Set() + for (let i = 0; i < 100; i++) { + ids.add(generateBundleSubmissionId()) + } + expect(ids.size).toBe(100) + }) +}) + +describe('bundle — collection', () => { + it('emits a header line with bundle_submission_id, app_version, platform', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan()])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1.2.3', + platform: 'darwin', + arch: 'arm64', + osRelease: '24.0.0', + orcaChannel: 'dev' + }) + const lines = bundle.payload.split('\n').filter(Boolean) + const header = JSON.parse(lines[0]) + expect(header.type).toBe('bundle-header') + expect(header.bundle_submission_id).toBe(bundle.bundleSubmissionId) + expect(header.app_version).toBe('1.2.3') + expect(header.platform).toBe('darwin') + expect(header.arch).toBe('arm64') + expect(header.orca_channel).toBe('dev') + expect(header.schema_version).toBe(1) + }) + + it('NEVER carries install_id in the header (Issue 8)', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan()])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1.0', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + const header = JSON.parse(bundle.payload.split('\n')[0]) + expect(header).not.toHaveProperty('install_id') + expect(header).not.toHaveProperty('installId') + expect(header).not.toHaveProperty('distinct_id') + }) + + it('reads spans from the rotated family', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'a' })])) + writeFileSync(`${traceFile}.1`, makeNDJSON([makeSpan({ name: 'b' })])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.spanCount).toBe(2) + }) + + it('drops spans older than the lookback window', () => { + const oldNanos = BigInt(Date.now() - 60 * 60 * 1000) * 1_000_000n // 1h ago + writeFileSync( + traceFile, + makeNDJSON([ + makeSpan({ name: 'recent' }), + makeSpan({ + name: 'old', + startTimeUnixNano: String(oldNanos - 1n), + endTimeUnixNano: String(oldNanos) + }) + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + lookbackMinutes: 30, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + // Header + recent only. + expect(bundle.spanCount).toBe(1) + expect(bundle.payload).toContain('"name":"recent"') + expect(bundle.payload).not.toContain('"name":"old"') + }) + + it('runs the redactor on the merged payload (belt-and-suspenders)', () => { + // Simulate a sink-write bug that leaked a secret through. The bundle + // pass should still strip it. + const span = makeSpan({ + attributes: { + // raw secret embedded in serialized form — bypass the API surface. + leaked: `sk-ant-api03-${'a'.repeat(50)}` + } + }) + writeFileSync(traceFile, makeNDJSON([span])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).not.toContain('sk-ant-api03-aaaaa') + expect(bundle.payload).toContain('[redacted:anthropic-key]') + }) + + it('uses server-mode structured redaction for nested auth and identity keys', () => { + writeFileSync( + traceFile, + makeNDJSON([ + makeSpan({ + attributes: { + install_id: 'posthog-install-id', + request: { + headers: { + authorization: 'Bearer plain-secret', + cookie: 'sid=plain-secret', + keep: 'ok' + } + } + } + }) + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).not.toContain('posthog-install-id') + expect(bundle.payload).not.toContain('plain-secret') + expect(bundle.payload).not.toContain('authorization') + expect(bundle.payload).not.toContain('cookie') + expect(bundle.payload).toContain('"keep":"ok"') + }) + + it('does not append a span that would push the payload over the upload cap', () => { + const giantSpan = makeSpan({ + attributes: { + message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) + } + }) + writeFileSync(traceFile, makeNDJSON([giantSpan])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.bytes).toBeLessThanOrEqual(_internalsForTests.MAX_BUNDLE_BYTES) + expect(bundle.spanCount).toBe(0) + }) + + it('keeps the newest spans when the bundle size cap truncates a file', () => { + const oldSpan = makeSpan({ + name: 'oldest', + attributes: { message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) } + }) + const newSpan = makeSpan({ name: 'newest', attributes: { message: 'recent crash' } }) + writeFileSync(traceFile, makeNDJSON([oldSpan, newSpan])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"name":"newest"') + expect(bundle.payload).not.toContain('"name":"oldest"') + }) + + it('skips individually oversized recent spans and keeps smaller recent context', () => { + const tooLargeSpan = makeSpan({ + name: 'oversized', + attributes: { message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) } + }) + const usefulSpan = makeSpan({ + name: 'useful', + attributes: { message: 'still useful' } + }) + writeFileSync(traceFile, makeNDJSON([usefulSpan, tooLargeSpan])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"name":"useful"') + expect(bundle.payload).not.toContain('"name":"oversized"') + }) + + it('skips oversized middle spans after accepting newer context', () => { + const olderUseful = makeSpan({ name: 'older-useful', attributes: { message: 'older context' } }) + const oversizedMiddle = makeSpan({ + name: 'oversized-middle', + attributes: { message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) } + }) + const newestUseful = makeSpan({ name: 'newest-useful', attributes: { message: 'new context' } }) + writeFileSync(traceFile, makeNDJSON([olderUseful, oversizedMiddle, newestUseful])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"name":"newest-useful"') + expect(bundle.payload).toContain('"name":"older-useful"') + expect(bundle.payload).not.toContain('"name":"oversized-middle"') + }) + + it('skips malformed (non-JSON) lines without throwing', () => { + writeFileSync(traceFile, [JSON.stringify(makeSpan()), 'not json', ''].join('\n')) + expect(() => + collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + ).not.toThrow() + }) + + it('skips valid JSON lines that are not span objects without throwing', () => { + writeFileSync( + traceFile, + [JSON.stringify(makeSpan({ name: 'valid' })), 'null', '"string"', '42', '[1]', ''].join('\n') + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + + expect(bundle.spanCount).toBe(1) + expect(bundle.payload).toContain('"name":"valid"') + }) +}) + +describe('validateUploadUrl', () => { + it('allows https upload_url when tokenEndpoint is https', () => { + expect(() => + validateUploadUrl('https://api.example.com/upload', 'https://api.example.com/token') + ).not.toThrow() + }) + + it('rejects http upload_url when tokenEndpoint is https (mixed scheme)', () => { + expect(() => + validateUploadUrl('http://api.example.com/upload', 'https://api.example.com/token') + ).toThrow(/must use https/) + }) + + it('allows http upload_url when tokenEndpoint is http (localhost dev)', () => { + expect(() => + validateUploadUrl('http://localhost:8080/upload', 'http://localhost:8080/token') + ).not.toThrow() + }) + + it('rejects an unparseable upload_url', () => { + expect(() => validateUploadUrl('not a url', 'https://api.example.com/token')).toThrow( + /invalid upload_url/ + ) + }) + + it('rejects a mismatched host even when both are https (same-origin pin)', () => { + expect(() => + validateUploadUrl('https://attacker.example.com/upload', 'https://api.example.com/token') + ).toThrow(/must match tokenEndpoint host/) + }) + + it('rejects a non-http(s) scheme like file://', () => { + expect(() => validateUploadUrl('file:///tmp/upload', 'https://api.example.com/token')).toThrow( + /must use https/ + ) + }) +}) + +describe('uploadBundle and deleteBundle', () => { + let server: Server | null = null + + afterEach( + () => + new Promise((resolve) => { + if (!server) { + resolve() + return + } + server.close(() => { + server = null + resolve() + }) + }) + ) + + function listen(handler: RequestListener): Promise { + server = createServer(handler) + return new Promise((resolve) => { + server?.listen(0, '127.0.0.1', () => { + const address = server?.address() + if (address && typeof address === 'object') { + resolve(`http://127.0.0.1:${address.port}`) + } + }) + }) + } + + it('does not include token endpoint response bodies in thrown errors', async () => { + const secretBody = 'internal token service detail: sk-ant-api03-secret' + const baseUrl = await listen((_req, res) => { + res.statusCode = 500 + res.end(secretBody) + }) + await expect( + uploadBundle({ + tokenEndpoint: `${baseUrl}/token`, + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^HTTP 500$/) + }) + + it('does not include upload endpoint response bodies in thrown errors', async () => { + const secretBody = 'internal upload detail: ghp_secret' + const baseUrl = await listen((req, res) => { + if (req.url === '/token') { + res.setHeader('content-type', 'application/json') + res.end( + JSON.stringify({ + token: 'test-token', + expires_at: new Date(Date.now() + 60_000).toISOString(), + upload_url: `${baseUrl}/upload`, + max_bytes: _internalsForTests.MAX_BUNDLE_BYTES + }) + ) + return + } + res.statusCode = 500 + res.end(secretBody) + }) + await expect( + uploadBundle({ + tokenEndpoint: `${baseUrl}/token`, + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^HTTP 500$/) + }) + + it('does not include malformed upload_url values in thrown errors', async () => { + const secretUrl = 'not a url with sk-ant-api03-secret' + const baseUrl = await listen((_req, res) => { + res.setHeader('content-type', 'application/json') + res.end( + JSON.stringify({ + token: 'test-token', + expires_at: new Date(Date.now() + 60_000).toISOString(), + upload_url: secretUrl, + max_bytes: _internalsForTests.MAX_BUNDLE_BYTES + }) + ) + }) + await expect( + uploadBundle({ + tokenEndpoint: `${baseUrl}/token`, + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^invalid upload_url from token endpoint$/) + }) + + it('does not include transport error details in thrown errors', async () => { + await expect( + uploadBundle({ + tokenEndpoint: 'http://diagnostics-secret.example.invalid/diagnostics/token', + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^diagnostic network request failed$/) + }) + + it('does not include invalid tokenEndpoint values in thrown errors', async () => { + await expect( + uploadBundle({ + tokenEndpoint: 'not a url with sk-ant-api03-secret', + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^diagnostic endpoint configuration is invalid$/) + }) + + it('caps diagnostic endpoint response bodies', async () => { + const baseUrl = await listen((_req, res) => { + res.setHeader('content-type', 'application/json') + res.end('x'.repeat(MAX_RESPONSE_BYTES + 1)) + }) + + await expect( + uploadBundle({ + tokenEndpoint: `${baseUrl}/token`, + payload: '{}\n', + bundleSubmissionId: generateBundleSubmissionId() + }) + ).rejects.toThrow(/^diagnostic response exceeded size limit$/) + }) + + it('posts deletion requests to the diagnostics delete endpoint for a ticket', async () => { + const ticketId = generateBundleSubmissionId() + const seen: string[] = [] + const baseUrl = await listen((req, res) => { + seen.push(req.url ?? '') + res.setHeader('content-type', 'application/json') + res.end('{}') + }) + await deleteBundle({ tokenEndpoint: `${baseUrl}/diagnostics/token`, ticketId }) + expect(seen).toEqual([`/diagnostics/delete/${ticketId}`]) + }) +}) diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts new file mode 100644 index 000000000..77a1c9c15 --- /dev/null +++ b/src/main/observability/bundle.ts @@ -0,0 +1,223 @@ +// Diagnostic bundle collection + upload (Mode 3 from +// telemetry-error-tracking.md). The single user-initiated network path from +// the error-tracking lane to Orca infrastructure. Every step here implements +// a hardening requirement from §Endpoint contract — the comments name the +// requirement number when they apply. +// +// Lifecycle: +// 1. `collectBundle()` — read the last N minutes of NDJSON across the +// rotated family, run the redactor a second time over the merged +// payload (belt-and-suspenders), embed the per-bundle +// `bundle_submission_id`. NEVER carries `install_id` (Issue 8 in the +// security review). +// 2. (renderer) — preview the bundle as plain text. User can copy or cancel. +// Main retains the uploadable payload so renderer cannot substitute +// arbitrary bytes after preview. +// 3. `uploadBundle()` — two-step: +// a) POST `/diagnostics/token` → token + upload_url +// b) POST `` with `Authorization: Bearer ` and the +// collected NDJSON payload. Returns ticket ID. +// 4. (renderer) — surface the ticket ID; offer "Copy ticket" and +// "Delete this bundle" controls. Delete posts only the ticket ID. +// +// Server-side endpoint contract is fully specified in +// telemetry-error-tracking.md §Endpoint contract. Implementation of those +// endpoints (token issuance, rate limit, storage, server-side redaction, +// retention, deletion) is operational TBD — flagged as an open question to +// the human dispatching this task. We ship the *client* of that contract +// with all hardening invariants the client controls (content-type pinning, +// body-size cap on upload, token-handling discipline). + +import { randomBytes } from 'node:crypto' +import { readFileSync, statSync } from 'node:fs' +import { MAX_BUNDLE_BYTES } from './diagnostic-bundle-limits' +import { listRotatedFiles } from './local-file-sink' +import { redactValue } from './redactor' + +const DEFAULT_LOOKBACK_MINUTES = 30 + +export type CollectBundleOptions = { + readonly traceFilePath: string + readonly maxFiles: number + readonly lookbackMinutes?: number + readonly appVersion: string + readonly platform: string + readonly arch: string + readonly osRelease: string + readonly orcaChannel: 'stable' | 'rc' | 'dev' +} + +export type CollectedBundle = { + /** 128-bit unguessable random ID, base64url. NOT the install_id — + * bundles are deliberately join-incompatible with the PostHog lane. */ + readonly bundleSubmissionId: string + /** UTF-8 NDJSON payload — header line + N redacted span lines. */ + readonly payload: string + /** Byte length of `payload`. Pre-checked against the 10 MB upload cap. */ + readonly bytes: number + /** Span-line count, for the preview window's "N spans" label. */ + readonly spanCount: number +} + +type BundleHeader = { + readonly bundle_submission_id: string + readonly app_version: string + readonly platform: string + readonly arch: string + readonly os_release: string + readonly orca_channel: 'stable' | 'rc' | 'dev' + readonly collected_at: string + readonly schema_version: 1 +} + +function* readLinesNewestFirst(text: string): Iterable { + let end = text.length + while (end > 0) { + const start = text.lastIndexOf('\n', end - 1) + const rawLine = text.slice(start + 1, end) + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (line.length > 0) { + yield line + } + if (start === -1) { + break + } + end = start + } +} + +/** + * Read the last N minutes of NDJSON across the rotated family and produce + * a redacted bundle payload. Caller renders this as preview text; main keeps + * the uploadable payload and `uploadBundle()` ships only those collected + * bytes. This keeps compromised renderer code from substituting arbitrary + * upload content after preview. + */ +export function collectBundle(opts: CollectBundleOptions): CollectedBundle { + const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 + const cutoffNanos = BigInt(Date.now() - lookbackMs) * 1_000_000n + const bundleSubmissionId = generateBundleSubmissionId() + const header: BundleHeader = { + bundle_submission_id: bundleSubmissionId, + app_version: opts.appVersion, + platform: opts.platform, + arch: opts.arch, + os_release: opts.osRelease, + orca_channel: opts.orcaChannel, + collected_at: new Date().toISOString(), + schema_version: 1 + } + + const headerLine = JSON.stringify({ type: 'bundle-header', ...header }) + const lines: string[] = [headerLine] + let spanCount = 0 + // Running byte counter for the eventual payload. Starts with the header + // plus its final newline; each pushed span adds its line plus newline. + // Avoids re-running `lines.join('\n').length` every iteration — that's + // O(N²) in span count and dominates collection time for large backlogs. + let currentBytes = Buffer.byteLength(`${headerLine}\n`) + const maxRecordBytes = MAX_BUNDLE_BYTES - currentBytes + + // Files from listRotatedFiles are newest → oldest. Reading newest first + // means the cutoff filter naturally bounds our work — once we hit a span + // older than the cutoff in an older file we can stop entirely. We don't + // optimize that yet; the worst case (10 × 10 MB = 100 MB scan) takes + // <1 s on a modern SSD and bundles are user-initiated, not hot-path. + const files = listRotatedFiles(opts.traceFilePath, opts.maxFiles) + outer: for (const file of files) { + let text: string + try { + // statSync first to skip absurdly-large files defensively. The sink + // caps at 10 MB per file; a tampered file could theoretically be + // bigger, in which case we want to abort the bundle rather than + // panic-allocate. + const size = statSync(file).size + if (size > 50 * 1024 * 1024) { + continue + } + text = readFileSync(file, 'utf8') + } catch { + continue + } + + // NDJSON parsing — one record per line. Process each file newest-first + // so the size cap preserves the spans closest to the support action. + // Skip malformed lines silently; a crash can leave a half-line. + for (const raw of readLinesNewestFirst(text)) { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + continue + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + const record = parsed as { startTimeUnixNano?: string; endTimeUnixNano?: string } + // Filter by end-time, not start-time. A long-lived span started 35 + // minutes ago but ending inside the lookback is exactly what we want + // in the bundle for diagnosing "session crashed at minute 32." + if (typeof record.endTimeUnixNano === 'string') { + try { + if (BigInt(record.endTimeUnixNano) < cutoffNanos) { + continue + } + } catch { + // Non-numeric end-time — keep it; better to over-include than to + // drop a record we couldn't classify. + } + } + + // Run the redactor a SECOND TIME over the parsed shape, in server mode. + // This catches nested auth-bearing fields and strips product-telemetry + // identity keys before the user's eyes hit the preview window. + const redacted = JSON.stringify(redactValue(parsed, 'server')) + const redactedBytes = Buffer.byteLength(redacted) + 1 + if (redactedBytes > maxRecordBytes) { + // One pathological record should not suppress every smaller recent + // span behind it. Skip records that cannot fit in an empty payload. + continue + } + if (currentBytes + redactedBytes > MAX_BUNDLE_BYTES) { + // Hard ceiling at the same 10 MB the upload endpoint enforces (F4). + // Check before appending so the preview can be uploaded as-is. + break outer + } + lines.push(redacted) + spanCount += 1 + currentBytes += redactedBytes + } + } + + const payload = `${lines.join('\n')}\n` + return { + bundleSubmissionId, + payload, + bytes: Buffer.byteLength(payload), + spanCount + } +} + +// ── Bundle submission ID ───────────────────────────────────────────────── + +/** + * 128-bit cryptographic random, URL-safe base64. Generated per bundle — + * NOT persisted. A user submitting two bundles produces two unrelated IDs. + * This is the primary structural mitigation for Issue 8 (bundle ↔ + * install_id correlation). + */ +export function generateBundleSubmissionId(): string { + // 16 bytes = 128 bits → base64url is 22 chars (no padding). Matches the + // §Endpoint contract requirement that ticket IDs be unguessable and + // non-enumerable; we use the same shape for the submission ID. + return randomBytes(16) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +// Test-only export. +export const _internalsForTests = { + MAX_BUNDLE_BYTES +} diff --git a/src/main/observability/diagnostic-bundle-limits.ts b/src/main/observability/diagnostic-bundle-limits.ts new file mode 100644 index 000000000..13f47f609 --- /dev/null +++ b/src/main/observability/diagnostic-bundle-limits.ts @@ -0,0 +1 @@ +export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 // 10 MB cap enforced before upload diff --git a/src/main/observability/diagnostic-bundle-upload.ts b/src/main/observability/diagnostic-bundle-upload.ts new file mode 100644 index 000000000..b5c419f1f --- /dev/null +++ b/src/main/observability/diagnostic-bundle-upload.ts @@ -0,0 +1,146 @@ +import { URL } from 'node:url' +import { MAX_BUNDLE_BYTES } from './diagnostic-bundle-limits' +import { postBodyForJson, postJsonForJson } from './diagnostic-upload-http' + +const TOKEN_REQUEST_TIMEOUT_MS = 10_000 +const UPLOAD_TIMEOUT_MS = 30_000 + +export type UploadBundleOptions = { + /** Server endpoint that issues short-lived tokens. From a build-time + * constant or a user-set env var (developer mode). */ + readonly tokenEndpoint: string + /** Already-collected payload bytes retained by main after preview. */ + readonly payload: string + readonly bundleSubmissionId: string +} + +export type UploadBundleResult = { + readonly ticketId: string +} + +export type DeleteBundleOptions = { + readonly tokenEndpoint: string + readonly ticketId: string +} + +type TokenResponse = { + readonly token: string + readonly expires_at: string + readonly upload_url: string + readonly max_bytes: number +} + +type UploadResponse = { + readonly ticket_id: string +} + +/** + * Two-step upload through a short-lived token. Failures throw an Error + * with a human-readable message; the IPC handler in `ipc/diagnostics.ts` + * surfaces them in the renderer toast. + */ +export async function uploadBundle(opts: UploadBundleOptions): Promise { + const bytes = Buffer.byteLength(opts.payload) + if (bytes > MAX_BUNDLE_BYTES) { + throw new Error(`bundle exceeds 10 MB cap (${bytes} bytes)`) + } + + // (1) Request a token. The token endpoint is rate-limited per IP at the + // edge (10/hour). A failure here typically means the user has hit the + // rate limit or the network is offline. + const tokenRes = (await postJsonForJson( + opts.tokenEndpoint, + { + bundle_submission_id: opts.bundleSubmissionId, + bytes + }, + TOKEN_REQUEST_TIMEOUT_MS + )) as TokenResponse + if ( + typeof tokenRes.token !== 'string' || + typeof tokenRes.upload_url !== 'string' || + typeof tokenRes.max_bytes !== 'number' + ) { + throw new Error('malformed token response') + } + if (bytes > tokenRes.max_bytes) { + throw new Error(`bundle exceeds server-issued cap (${bytes} > ${tokenRes.max_bytes})`) + } + + // Validate `upload_url` BEFORE we send the bearer token + user data to it. + // A misconfigured or compromised token endpoint could otherwise redirect + // the upload (with the bearer token and the user's NDJSON payload) to an + // attacker-controlled host. Require https in production and only relax to + // http when the configured tokenEndpoint is itself non-https for local dev. + validateUploadUrl(tokenRes.upload_url, opts.tokenEndpoint) + + // (2) Upload using the bearer token. The server only accepts NDJSON uploads + // for this route and rejects other content-types at the edge. + const uploadRes = (await postBodyForJson({ + url: tokenRes.upload_url, + body: opts.payload, + headers: { + authorization: `Bearer ${tokenRes.token}`, + 'content-type': 'application/x-ndjson', + 'content-length': String(bytes) + }, + timeoutMs: UPLOAD_TIMEOUT_MS + })) as UploadResponse + + if (typeof uploadRes.ticket_id !== 'string' || uploadRes.ticket_id.length === 0) { + throw new Error('malformed upload response: missing ticket_id') + } + return { ticketId: uploadRes.ticket_id } +} + +export async function deleteBundle(opts: DeleteBundleOptions): Promise { + const endpoint = resolveDeleteEndpoint(opts.tokenEndpoint, opts.ticketId) + await postJsonForJson(endpoint, {}, TOKEN_REQUEST_TIMEOUT_MS) +} + +/** + * Reject an `upload_url` returned by the token endpoint that we can't safely + * POST a bearer token + the user's diagnostic payload to. Exists because the + * upload destination is chosen by the server response, not pinned at build + * time — without this gate, a misconfigured or compromised token endpoint + * could exfiltrate bundles to an attacker-controlled host. + */ +export function validateUploadUrl(uploadUrl: string, tokenEndpoint: string): void { + let parsedUpload: URL + try { + parsedUpload = new URL(uploadUrl) + } catch { + throw new Error('invalid upload_url from token endpoint') + } + let parsedToken: URL + try { + parsedToken = new URL(tokenEndpoint) + } catch { + throw new Error('invalid tokenEndpoint configuration') + } + const tokenIsHttps = parsedToken.protocol === 'https:' + if (tokenIsHttps && parsedUpload.protocol !== 'https:') { + throw new Error('upload_url must use https when tokenEndpoint is https') + } + if (parsedUpload.protocol !== 'https:' && parsedUpload.protocol !== 'http:') { + throw new Error('upload_url must use http(s)') + } + // Same-origin host pin. Defends against a compromised token endpoint that + // returns a valid-https upload_url pointing at an attacker-controlled host. + if (parsedUpload.host !== parsedToken.host) { + throw new Error('upload_url host must match tokenEndpoint host') + } +} + +function resolveDeleteEndpoint(tokenEndpoint: string, ticketId: string): string { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(ticketId)) { + throw new Error('ticketId has invalid format') + } + let parsedToken: URL + try { + parsedToken = new URL(tokenEndpoint) + } catch { + throw new Error('invalid tokenEndpoint configuration') + } + return new URL(`/diagnostics/delete/${encodeURIComponent(ticketId)}`, parsedToken).toString() +} diff --git a/src/main/observability/diagnostic-upload-http.ts b/src/main/observability/diagnostic-upload-http.ts new file mode 100644 index 000000000..95528ab7c --- /dev/null +++ b/src/main/observability/diagnostic-upload-http.ts @@ -0,0 +1,127 @@ +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { URL } from 'node:url' + +export const MAX_RESPONSE_BYTES = 1024 * 1024 + +export function postJsonForJson(url: string, body: unknown, timeoutMs: number): Promise { + return postRaw( + url, + JSON.stringify(body), + { + 'content-type': 'application/json', + accept: 'application/json' + }, + timeoutMs + ) +} + +export function postBodyForJson({ + url, + body, + headers, + timeoutMs +}: { + readonly url: string + readonly body: string + readonly headers: Record + readonly timeoutMs: number +}): Promise { + return postRaw(url, body, { ...headers, accept: 'application/json' }, timeoutMs) +} + +function postRaw( + url: string, + body: string, + headers: Record, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + let settled = false + function resolveOnce(value: unknown): void { + if (settled) { + return + } + settled = true + resolve(value) + } + function rejectOnce(error: Error): void { + if (settled) { + return + } + settled = true + reject(error) + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + rejectOnce(new Error('diagnostic endpoint configuration is invalid')) + return + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + rejectOnce(new Error('diagnostic endpoint must use http(s)')) + return + } + const protocol = parsed.protocol === 'https:' ? httpsRequest : httpRequest + const req = protocol( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + timeout: timeoutMs, + headers: { + 'content-length': Buffer.byteLength(body), + ...headers + } + }, + (res) => { + const chunks: Buffer[] = [] + let responseBytes = 0 + res.on('data', (chunk: Buffer) => { + responseBytes += chunk.length + if (responseBytes > MAX_RESPONSE_BYTES) { + // Why: diagnostics endpoints should return tiny JSON envelopes. + // Cap response buffering so a bad endpoint cannot grow main memory. + rejectOnce(new Error('diagnostic response exceeded size limit')) + req.destroy() + res.destroy() + return + } + chunks.push(chunk) + }) + res.on('end', () => { + const status = res.statusCode ?? 0 + const text = Buffer.concat(chunks).toString('utf8') + if (status >= 200 && status < 300) { + try { + resolveOnce(text.length > 0 ? JSON.parse(text) : {}) + } catch { + rejectOnce(new Error(`malformed JSON response (HTTP ${status})`)) + } + } else { + // Why: this error can cross IPC into renderer toasts. Never + // include backend response bodies; they may contain infra detail. + rejectOnce(new Error(`HTTP ${status}`)) + } + }) + res.on('error', () => { + rejectOnce(new Error('diagnostic network request failed')) + }) + } + ) + req.on('error', () => { + // Why: request errors can include endpoint hostnames. The diagnostics + // endpoint contract keeps infrastructure details out of renderer IPC. + rejectOnce(new Error('diagnostic network request failed')) + }) + req.on('timeout', () => { + rejectOnce(new Error('diagnostic network request timed out')) + req.destroy() + }) + req.write(body) + req.end() + }) +} diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts new file mode 100644 index 000000000..d18c5c8c6 --- /dev/null +++ b/src/main/observability/index.ts @@ -0,0 +1,378 @@ +// Composition root for the error-tracking lane (telemetry-error-tracking.md +// §Architecture). Wires the local NDJSON sink + the optional OTLP exporter +// into the active tracer, and exposes a single init/shutdown pair the main +// process calls from `src/main/index.ts`. +// +// Architectural rule (load-bearing): nothing in `src/main/telemetry/` +// imports from this directory and vice versa — the two lanes never share a +// code path. Cross-contamination is the failure mode this entire lane is +// counter-designed against. An import-restricted-paths lint rule will +// enforce this; even before the rule lands, the rule is a code-review +// invariant. +// +// Consent boundaries (telemetry-error-tracking.md §Consent boundaries): +// +// DO_NOT_TRACK=1 → disable OTLP + bundle button. KEEP local file. +// Local file writes never leave the machine, +// so they are not "tracking" in the DNT sense. +// ORCA_TELEMETRY_DISABLED=1 → identical to DO_NOT_TRACK for this lane. +// ORCA_DIAGNOSTICS_DISABLED=1 → ALSO disable local file writes. The escape +// hatch for users on devices where even local +// debug logs are policy-forbidden. +// CI detection → disable everything in this lane. +// +// The CI gate matches the same env-var list the product-telemetry consent +// resolver uses (CI / GITHUB_ACTIONS / GITLAB_CI / CIRCLECI / TRAVIS / +// BUILDKITE / JENKINS_URL / TEAMCITY_VERSION). Duplicating the list — rather +// than importing it from `src/main/telemetry/consent.ts` — preserves the +// import isolation rule above. The cost of one duplicated array vs. +// punching a hole in the architecture is trivially worth it. + +import { app } from 'electron' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' +import { + clearRotatedFamily, + createLocalFileSink, + DEFAULT_MAX_FILES, + getRotatedFamilySize, + type LocalFileSink +} from './local-file-sink' +import { + collectBundle as _collectBundle, + type CollectBundleOptions, + type CollectedBundle +} from './bundle' +import { + deleteBundle as _deleteBundle, + uploadBundle as _uploadBundle, + type DeleteBundleOptions, + type UploadBundleOptions, + type UploadBundleResult +} from './diagnostic-bundle-upload' +import { createOtlpExporterFromEnv, type OtlpExporter } from './otlp-exporter' +import { setActiveSink, type TracerSink } from './tracer' + +const CI_ENV_VARS = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] as const + +export type ObservabilityConsent = { + /** Whether the local NDJSON sink is active. */ + readonly localFileEnabled: boolean + /** Whether an OTLP exporter was instantiated for this session. */ + readonly otlpEnabled: boolean + /** Whether the diagnostic-bundle button should be available. */ + readonly bundleEnabled: boolean + /** Display string shown in the Privacy pane's OTLP status row. */ + readonly otlpStatus: string + /** Reason any of the lanes are disabled, for debug surfaces. */ + readonly disabledReason?: + | 'do_not_track' + | 'orca_telemetry_disabled' + | 'orca_diagnostics_disabled' + | 'ci' +} + +function envOn(name: string): boolean { + const v = process.env[name] + if (!v) { + return false + } + const norm = v.trim().toLowerCase() + return norm === '1' || norm === 'true' +} + +function inCI(): boolean { + return CI_ENV_VARS.some((v) => process.env[v] !== undefined && process.env[v] !== '') +} + +/** Resolve the per-launch consent state for this lane. Pure — reads only + * process.env, so callers can re-evaluate any time without holding state. */ +export function resolveObservabilityConsent(): ObservabilityConsent { + // CI and DNT/disabled have different effects on which sub-lanes are gated. + // Keep the ordering aligned with §Consent boundaries above. + const dnt = envOn('DO_NOT_TRACK') + const orcaDisabled = envOn('ORCA_TELEMETRY_DISABLED') + const diagnosticsDisabled = envOn('ORCA_DIAGNOSTICS_DISABLED') + const ci = inCI() + + if (ci) { + return { + localFileEnabled: false, + otlpEnabled: false, + bundleEnabled: false, + otlpStatus: 'Disabled in CI', + disabledReason: 'ci' + } + } + if (diagnosticsDisabled) { + return { + localFileEnabled: false, + otlpEnabled: false, + bundleEnabled: false, + otlpStatus: 'Disabled by ORCA_DIAGNOSTICS_DISABLED', + disabledReason: 'orca_diagnostics_disabled' + } + } + if (dnt || orcaDisabled) { + // Local file remains active — DNT is a *network* signal, and the local + // file never leaves the machine. + return { + localFileEnabled: true, + otlpEnabled: false, + bundleEnabled: false, + otlpStatus: dnt ? 'Disabled by DO_NOT_TRACK' : 'Disabled by ORCA_TELEMETRY_DISABLED', + disabledReason: dnt ? 'do_not_track' : 'orca_telemetry_disabled' + } + } + + // Normal path: everything is on, but the OTLP exporter only initializes + // if the user has set ORCA_OTLP_TRACES_URL. + const tracesUrl = process.env.ORCA_OTLP_TRACES_URL + return { + localFileEnabled: true, + otlpEnabled: tracesUrl !== undefined && tracesUrl.length > 0, + bundleEnabled: true, + otlpStatus: + tracesUrl !== undefined && tracesUrl.length > 0 + ? `Enabled — exporting to ${tracesUrl}` + : 'Disabled (set ORCA_OTLP_TRACES_URL to enable)' + } +} + +/** Path for the trace NDJSON file. macOS conventional location is + * `~/Library/Application Support/Orca/logs/main.trace.ndjson`; we resolve + * the same intent on Windows / Linux via Electron's `userData` dir. The + * function falls back to homedir when Electron is not available (tests). + */ +export function getTraceFilePath(): string { + let userData: string + try { + userData = app.getPath('userData') + } catch { + // Tests — Electron's `app` may not be initialized. Use a sensible + // OS-conventional fallback so unit tests can construct the path + // without spinning up the full Electron runtime. + const home = homedir() + if (platform() === 'darwin') { + userData = join(home, 'Library', 'Application Support', 'Orca') + } else if (platform() === 'win32') { + userData = join(process.env.APPDATA ?? home, 'Orca') + } else { + userData = join(home, '.config', 'Orca') + } + } + return join(userData, 'logs', 'main.trace.ndjson') +} + +// ── Module-level state ─────────────────────────────────────────────────── + +let sink: LocalFileSink | null = null +let otlp: OtlpExporter | null = null +let consent: ObservabilityConsent | null = null + +/** Composite tracer sink that fans out to local file and (optionally) OTLP. + * The two are independent — an OTLP failure does not affect the local file + * and vice versa. */ +function makeCompositeSink(localSink: LocalFileSink, exporter: OtlpExporter | null): TracerSink { + return { + push(record: unknown): void { + // The tracer pushes already-redacted span records here. Both + // destinations are best-effort; either failing must not propagate. + try { + localSink.push(record) + } catch { + /* swallow — error-tracking lane must never crash main */ + } + if (exporter) { + try { + // Records emitted by `tracer.ts` carry `type: 'effect-span'` plus + // the RedactableSpan fields. The OTLP exporter expects the + // RedactableSpan shape; strip the envelope before forwarding. + const r = record as { type?: string } & Record + if (r.type === 'effect-span') { + const { type: _t, ...spanFields } = r + void _t + exporter.exportSpan(spanFields as Parameters[0]) + } + } catch { + /* swallow */ + } + } + }, + flush(): void { + try { + localSink.flush() + } catch { + /* */ + } + if (exporter) { + // Async flush — fire-and-forget on this synchronous path. The + // shutdown path awaits the OTLP flush separately. + void exporter.flush() + } + }, + close(): void { + try { + localSink.close() + } catch { + /* */ + } + if (exporter) { + // Fire-and-forget flush before close — prevents queued-span loss when + // callers invoke close() without separately awaiting flush(). Same + // fire-and-forget pattern documented above in flush(). + void exporter.flush() + exporter.close() + } + } + } +} + +/** Create the local file sink, install the composite (local + optional OTLP) + * as the active tracer sink, and update module-level `sink`. The OTLP + * exporter is reused from the current module-level `otlp` reference — only + * the local sink is recreated. Used by both `initObservability` (where + * `otlp` is freshly created) and `clearLocalTraces` (where `otlp` is + * already running and must be preserved across the sink swap). */ +function installLocalSink(): void { + const localSink = createLocalFileSink({ filePath: getTraceFilePath() }) + sink = localSink + setActiveSink(makeCompositeSink(localSink, otlp)) +} + +export function initObservability(): ObservabilityConsent { + const c = resolveObservabilityConsent() + consent = c + if (!c.localFileEnabled) { + // Disabled at the CI / ORCA_DIAGNOSTICS_DISABLED level — leave the + // tracer's active sink unset, so all spans are no-ops. + return c + } + otlp = c.otlpEnabled ? createOtlpExporterFromEnv() : null + installLocalSink() + return c +} + +export async function shutdownObservability(): Promise { + // Order matters: tracer first (so no new pushes after this point), then + // bounded OTLP flush, then the local sink close (synchronous fsync). + setActiveSink(null) + if (otlp) { + try { + await otlp.flush() + } catch { + /* swallow */ + } + otlp.close() + otlp = null + } + if (sink) { + sink.close() + sink = null + } + consent = null +} + +export function getObservabilityConsent(): ObservabilityConsent | null { + return consent +} + +// ── Bundle / trace-folder operations exposed to IPC ───────────────────── + +export type DiagnosticsStatus = { + readonly localFileEnabled: boolean + readonly otlpEnabled: boolean + readonly bundleEnabled: boolean + readonly otlpStatus: string + readonly traceFilePath: string + readonly traceFamilySize: number + readonly disabledReason?: ObservabilityConsent['disabledReason'] +} + +export function getDiagnosticsStatus(): DiagnosticsStatus { + const c = consent ?? resolveObservabilityConsent() + const traceFilePath = getTraceFilePath() + const traceFamilySize = c.localFileEnabled ? getRotatedFamilySize(traceFilePath) : 0 + return { + localFileEnabled: c.localFileEnabled, + otlpEnabled: c.otlpEnabled, + bundleEnabled: c.bundleEnabled, + otlpStatus: c.otlpStatus, + traceFilePath, + traceFamilySize, + ...(c.disabledReason ? { disabledReason: c.disabledReason } : {}) + } +} + +/** Wrapper around `local-file-sink.clearRotatedFamily` that fully tears down + * and rebuilds the active sink around the unlink. + * + * Why the close-then-unlink-then-recreate dance: + * The local file sink holds an open fd from `openSync(filePath, 'a')`. If we + * unlink while that fd is still open, two bad things happen: + * - POSIX: the kernel keeps the inode alive as long as the fd is open, so + * subsequent `writeSync` calls land in an orphaned inode invisible to + * the user but still consuming disk until the process exits. + * - Windows: `unlinkSync` on the active file fails with EBUSY (silently + * swallowed inside `clearRotatedFamily`), so the active file is NOT + * cleared — the user clicks "Clear" and nothing happens. + * Both failures are silent. The fix is to fully close the sink (which + * flushes and releases the fd) before unlinking, then recreate the sink so + * a fresh fd points at a brand-new empty file. The OTLP exporter is left + * running across the swap. */ +export function clearLocalTraces(): void { + if (sink) { + sink.close() + sink = null + } + clearRotatedFamily(getTraceFilePath()) + if (consent?.localFileEnabled) { + installLocalSink() + } +} + +/** Collect a bundle from the live trace folder. The `appVersion` / + * `platform` / `arch` / `osRelease` / `orcaChannel` inputs come from main + * and are baked into the bundle header. NEVER pass `install_id` here — + * the bundle's identity is the per-bundle submission ID, not the + * PostHog-lane install_id (Issue 8 in the security review). */ +export function collectDiagnosticBundle( + meta: Pick< + CollectBundleOptions, + 'appVersion' | 'platform' | 'arch' | 'osRelease' | 'orcaChannel' | 'lookbackMinutes' + > +): CollectedBundle { + // Flush the active sink first so the very latest spans are present in the + // file when we read it back. Without this, the user's most-recent action + // before clicking Share might miss the bundle by a few hundred ms — which + // is exactly the case "the thing I just did" they want diagnosed. + if (sink) { + sink.flush() + } + return _collectBundle({ + traceFilePath: getTraceFilePath(), + maxFiles: DEFAULT_MAX_FILES, + ...meta + }) +} + +/** Upload a collected bundle payload. Returns the ticket ID on success; + * throws on any of the failure modes documented in `bundle.ts`. */ +export async function uploadDiagnosticBundle( + opts: UploadBundleOptions +): Promise { + return _uploadBundle(opts) +} + +export async function deleteDiagnosticBundle(opts: DeleteBundleOptions): Promise { + return _deleteBundle(opts) +} diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts new file mode 100644 index 000000000..3651a355e --- /dev/null +++ b/src/main/observability/instrumentation.ts @@ -0,0 +1,177 @@ +// Convenience wrappers around the tracer for the span boundaries listed in +// telemetry-error-tracking.md §"Span boundaries worth capturing": +// +// - IPC boundaries (renderer → main preload calls) +// - Agent session lifecycle (start, turn, stop, recover) +// - Git command execution +// - Worktree setup (clone / checkout / install) +// - PTY session lifecycle +// - External editor launches +// - Updater operations +// +// Each helper wraps `withSpan` from `tracer.ts` with a sensible default +// span name and a small attribute pack. Call sites that already produce +// detailed Result objects (git runner returning stdout/stderr; PTY layer +// reporting exit codes) thread that detail in via `attributes` so the +// span attribute pack stays cohesive without each call site re-inventing +// keys. +// +// All helpers are no-ops when the tracer's active sink is unset (the +// observability lane was disabled at startup by env var or CI). The span +// itself becomes a `noopSpan` that swallows all calls — call sites do not +// need to branch on whether tracing is on. + +import { withSpan, type ActiveSpan } from './tracer' + +export type GitSpanArgs = { + readonly args: readonly string[] + readonly cwd?: string +} + +/** Wrap a git execution in a `git.exec` span. The first argument typically + * is the subcommand (`status`, `clone`, `pull`); promoting it to its own + * attribute makes it grep-friendly without pulling the full args array + * into a single comma-joined string in dashboards. */ +export async function withGitSpan(meta: GitSpanArgs, fn: () => Promise): Promise { + return withSpan( + 'git.exec', + async (span) => { + span.setAttribute('git.subcommand', meta.args[0] ?? '') + // Why: git args can contain commit messages, branch names, remotes, or + // paths. Keep cardinality without copying user-authored content. + span.setAttribute('git.arg_count', meta.args.length) + if (meta.cwd) { + span.setAttribute('cwd', meta.cwd) + } + return await fn() + }, + { attributes: { kind: 'git' } } + ) +} + +export type IpcSpanArgs = { + readonly channel: string +} + +/** Wrap an ipcMain handler invocation in an `ipc.handle` span. Used by + * the highest-traffic handlers — `git`, `runtime`, `pty`, `worktree`, + * `agent` — not every handler. Tracing every IPC call would explode the + * trace tree and obscure the spans that matter. */ +export async function withIpcSpan(meta: IpcSpanArgs, fn: () => Promise | T): Promise { + return withSpan( + 'ipc.handle', + async (span) => { + span.setAttribute('ipc.channel', meta.channel) + return await fn() + }, + { attributes: { kind: 'ipc' } } + ) +} + +export type WorktreeSpanArgs = { + readonly stage: 'clone' | 'checkout' | 'install' | 'create' | 'remove' + readonly path?: string +} + +/** Wrap a worktree-setup phase in a `worktree.` span. */ +export async function withWorktreeSpan( + meta: WorktreeSpanArgs, + fn: () => Promise +): Promise { + return withSpan( + `worktree.${meta.stage}`, + async (span) => { + span.setAttribute('worktree.stage', meta.stage) + if (meta.path) { + span.setAttribute('worktree.path', meta.path) + } + return await fn() + }, + { attributes: { kind: 'worktree' } } + ) +} + +export type PtySpanArgs = { + readonly stage: 'spawn' | 'exit' | 'recover' + readonly shell?: string + readonly cwd?: string +} + +/** Wrap a PTY-lifecycle event in a `pty.` span. The lifecycle is + * long-lived; callers typically use `startSpan` directly for the live + * session and call `withPtySpan` only for the spawn/exit moments. */ +export async function withPtySpan(meta: PtySpanArgs, fn: () => Promise | T): Promise { + return withSpan( + `pty.${meta.stage}`, + async (span) => { + span.setAttribute('pty.stage', meta.stage) + if (meta.shell) { + span.setAttribute('pty.shell', meta.shell) + } + if (meta.cwd) { + span.setAttribute('cwd', meta.cwd) + } + return await fn() + }, + { attributes: { kind: 'pty' } } + ) +} + +export type AgentSpanArgs = { + readonly stage: 'start' | 'turn' | 'stop' | 'recover' + readonly agentKind?: string +} + +export async function withAgentSpan(meta: AgentSpanArgs, fn: () => Promise | T): Promise { + return withSpan( + `agent.${meta.stage}`, + async (span) => { + span.setAttribute('agent.stage', meta.stage) + if (meta.agentKind) { + span.setAttribute('agent.kind', meta.agentKind) + } + return await fn() + }, + { attributes: { kind: 'agent' } } + ) +} + +export type ExternalEditorSpanArgs = { + readonly editor: string + readonly path?: string +} + +export async function withExternalEditorSpan( + meta: ExternalEditorSpanArgs, + fn: () => Promise | T +): Promise { + return withSpan( + 'external_editor.launch', + async (span) => { + span.setAttribute('editor', meta.editor) + if (meta.path) { + span.setAttribute('path', meta.path) + } + return await fn() + }, + { attributes: { kind: 'external_editor' } } + ) +} + +export type UpdaterSpanArgs = { + readonly stage: 'check' | 'download' | 'install' +} + +export async function withUpdaterSpan( + meta: UpdaterSpanArgs, + fn: (span: ActiveSpan) => Promise | T +): Promise { + return withSpan( + `updater.${meta.stage}`, + async (span) => { + span.setAttribute('updater.stage', meta.stage) + return await fn(span) + }, + { attributes: { kind: 'updater' } } + ) +} diff --git a/src/main/observability/local-file-sink.test.ts b/src/main/observability/local-file-sink.test.ts new file mode 100644 index 000000000..442b70637 --- /dev/null +++ b/src/main/observability/local-file-sink.test.ts @@ -0,0 +1,297 @@ +// Sink tests: rotation behavior under size pressure, listing, clearing. + +import { + chmodSync, + mkdtempSync, + rmSync, + readFileSync, + existsSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + clearRotatedFamily, + createLocalFileSink, + getRotatedFamilySize, + listRotatedFiles +} from './local-file-sink' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-sink-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function makeRecord(i: number): { i: number; payload: string } { + // ~120 bytes per line so we can hit a small cap with a known number of + // pushes. Padding character chosen to avoid colliding with redactor rules + // in case the test span ever flows through the redactor. + return { i, payload: 'x'.repeat(100) } +} + +describe('local-file-sink — basic write', () => { + it('writes one NDJSON line per push', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + batchWindowMs: 100, + flushBufferThreshold: 1 + }) + sink.push({ a: 1 }) + sink.push({ b: 2 }) + sink.flush() + sink.close() + + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) + expect(lines).toHaveLength(2) + expect(JSON.parse(lines[0])).toEqual({ a: 1 }) + expect(JSON.parse(lines[1])).toEqual({ b: 2 }) + }) + + it('coalesces writes up to the buffer threshold', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + batchWindowMs: 100_000, + flushBufferThreshold: 5 + }) + for (let i = 0; i < 4; i++) { + sink.push({ i }) + } + // Below threshold: nothing on disk yet (the periodic timer is far away). + expect(statSync(file).size).toBe(0) + sink.push({ i: 4 }) + // 5th push hits the threshold, flushes synchronously. + expect(statSync(file).size).toBeGreaterThan(0) + sink.close() + }) + + it('creates trace directories and files with private POSIX permissions', () => { + if (process.platform === 'win32') { + return + } + const file = join(dir, 'logs', 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + sink.push({ ok: true }) + sink.close() + + expect(statSync(dirname(file)).mode & 0o777).toBe(0o700) + expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + it('tightens permissions on an existing rotated trace family', () => { + if (process.platform === 'win32') { + return + } + const file = join(dir, 'test.ndjson') + const rotated = `${file}.1` + writeFileSync(file, '{}\n') + writeFileSync(rotated, '{}\n') + chmodSync(file, 0o644) + chmodSync(rotated, 0o644) + + const sink = createLocalFileSink({ + filePath: file, + maxFiles: 3, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + sink.close() + + expect(statSync(file).mode & 0o777).toBe(0o600) + expect(statSync(rotated).mode & 0o777).toBe(0o600) + }) +}) + +describe('local-file-sink — rotation', () => { + it('rotates when the byte cap is exceeded', () => { + const file = join(dir, 'test.ndjson') + // ~120 bytes per record × 5 records = ~600 bytes; cap at 500 forces + // rotation before all records land in the same file. + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 500, + maxFiles: 3, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + for (let i = 0; i < 8; i++) { + sink.push(makeRecord(i)) + } + sink.flush() + sink.close() + + const files = listRotatedFiles(file, 3) + expect(files.length).toBeGreaterThan(1) + // The base file always exists after rotation (the post-cascade fresh + // open). + expect(existsSync(file)).toBe(true) + }) + + it('uses UTF-8 byte length for rotation accounting', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 90, + maxFiles: 3, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + sink.push({ payload: '😀'.repeat(15) }) + sink.push({ payload: '😀'.repeat(15) }) + sink.flush() + sink.close() + + expect(listRotatedFiles(file, 3).length).toBeGreaterThan(1) + }) + + it('drops an individual record that exceeds the file byte cap', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 100, + maxFiles: 3, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + sink.push({ payload: 'x'.repeat(1_000) }) + sink.push({ ok: true }) + sink.flush() + sink.close() + + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) + expect(lines.map((line) => JSON.parse(line))).toEqual([{ ok: true }]) + expect(getRotatedFamilySize(file, 3)).toBeLessThanOrEqual(100) + }) + + it('splits an oversized buffered batch instead of dropping valid records', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 170, + maxFiles: 5, + batchWindowMs: 100_000, + flushBufferThreshold: 3 + }) + sink.push({ i: 1, payload: 'x'.repeat(60) }) + sink.push({ i: 2, payload: 'x'.repeat(60) }) + sink.push({ i: 3, payload: 'x'.repeat(60) }) + sink.flush() + sink.close() + + const allRecords = listRotatedFiles(file, 5) + .flatMap((path) => readFileSync(path, 'utf8').split('\n').filter(Boolean)) + .map((line) => JSON.parse(line) as { i: number }) + .map((record) => record.i) + .sort((a, b) => a - b) + expect(allRecords).toEqual([1, 2, 3]) + }) + + it('caps total disk usage at maxFiles × maxBytes (worst case)', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 500, + maxFiles: 3, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + // Far more than 3 × 500 bytes — exercises the FIFO drop path. + for (let i = 0; i < 50; i++) { + sink.push(makeRecord(i)) + } + sink.flush() + sink.close() + + const total = getRotatedFamilySize(file, 3) + // Worst case: 3 files × ~500 bytes each + the in-progress base. Allow + // 1.5× headroom so the test isn't flaky on rotation timing — what + // matters is that we're not unbounded. + expect(total).toBeLessThan(3 * 500 * 2) + }) +}) + +describe('local-file-sink — listing + clearing', () => { + it('lists newest → oldest', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 200, + maxFiles: 5, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + for (let i = 0; i < 20; i++) { + sink.push(makeRecord(i)) + } + sink.flush() + sink.close() + + const files = listRotatedFiles(file, 5) + // First entry is always the base (newest). + expect(files[0]).toBe(file) + // Subsequent entries are the rotated suffixes in ascending order. + for (let i = 1; i < files.length; i++) { + expect(files[i]).toBe(`${file}.${i}`) + } + }) + + it('clears every rotated file', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + maxBytes: 200, + maxFiles: 5, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + for (let i = 0; i < 20; i++) { + sink.push(makeRecord(i)) + } + sink.flush() + sink.close() + + expect(listRotatedFiles(file, 5).length).toBeGreaterThan(0) + clearRotatedFamily(file, 5) + expect(listRotatedFiles(file, 5)).toEqual([]) + }) +}) + +describe('local-file-sink — robustness', () => { + it('does not throw on circular records (drops the line)', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ + filePath: file, + batchWindowMs: 100_000, + flushBufferThreshold: 1 + }) + const a: Record = { ok: true } + a.self = a + expect(() => { + sink.push(a) + sink.push({ ok: 2 }) + }).not.toThrow() + sink.flush() + sink.close() + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) + // Only the non-circular line lands; circular is silently dropped. + expect(lines.map((l) => JSON.parse(l))).toEqual([{ ok: 2 }]) + }) + + it('survives close-after-close', () => { + const file = join(dir, 'test.ndjson') + const sink = createLocalFileSink({ filePath: file }) + sink.close() + expect(() => sink.close()).not.toThrow() + }) +}) diff --git a/src/main/observability/local-file-sink.ts b/src/main/observability/local-file-sink.ts new file mode 100644 index 000000000..07e581942 --- /dev/null +++ b/src/main/observability/local-file-sink.ts @@ -0,0 +1,336 @@ +// NDJSON sink with size-based file rotation. Spans are serialized one per +// line to a primary file, and when that file's byte budget is exceeded the +// sink rolls it forward (`main.trace.ndjson` → `main.trace.ndjson.1` → +// `main.trace.ndjson.2` → … → `main.trace.ndjson.N`, oldest deleted). +// +// Defaults match the local-first trace sink design: 10 MB × 10 files. 100 MB +// is the worst-case footprint on a user's disk, keeping the sizing bounded +// without adding a network dependency. +// +// Two design constraints worth calling out: +// +// 1. Synchronous writes by default. The error-tracking lane has to be +// durable on crash — if the renderer or main process is about to die, +// a buffered async flush is exactly what we don't want. We use the +// `appendFileSync` path (cheap on modern fs at this volume) and +// explicitly do a final `flush()` on shutdown. +// +// 2. Buffered batches with a flush threshold. Batches of up to +// `FLUSH_BUFFER_THRESHOLD` lines are coalesced into one syscall to +// keep the per-span cost low; a periodic interval flushes the partial +// batch every `batchWindowMs` so a sparse-trace session still ends up +// on disk. Both knobs are configurable for tests. + +import { + chmodSync, + closeSync, + existsSync, + fchmodSync, + fstatSync, + mkdirSync, + openSync, + renameSync, + statSync, + unlinkSync, + writeSync +} from 'node:fs' +import { dirname } from 'node:path' + +const DEFAULT_FLUSH_BUFFER_THRESHOLD = 32 +export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 // 10 MB +export const DEFAULT_MAX_FILES = 10 +export const DEFAULT_BATCH_WINDOW_MS = 200 +const PRIVATE_DIRECTORY_MODE = 0o700 +const PRIVATE_FILE_MODE = 0o600 + +export type LocalFileSinkOptions = { + readonly filePath: string + readonly maxBytes?: number + readonly maxFiles?: number + readonly batchWindowMs?: number + readonly flushBufferThreshold?: number +} + +export type LocalFileSink = { + readonly filePath: string + /** Serialize and enqueue one JSON-shaped record. */ + push(record: unknown): void + /** Force any buffered lines to disk synchronously. Called from shutdown. */ + flush(): void + /** Stop the periodic timer + flush + close the underlying fd. */ + close(): void +} + +function chmodPathIfPresent(path: string, mode: number): void { + try { + if (existsSync(path)) { + chmodSync(path, mode) + } + } catch { + /* best effort — permissions hardening must not break trace writes */ + } +} + +function tightenTraceFamilyPermissions(filePath: string, maxFiles: number): void { + for (let i = 0; i < maxFiles; i++) { + chmodPathIfPresent(i === 0 ? filePath : `${filePath}.${i}`, PRIVATE_FILE_MODE) + } +} + +export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink { + const filePath = opts.filePath + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES + const batchWindowMs = opts.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS + const flushThreshold = opts.flushBufferThreshold ?? DEFAULT_FLUSH_BUFFER_THRESHOLD + + // Local traces can contain paths and crash context; keep them readable only + // by the current user even on systems with permissive default umasks. + const traceDirectory = dirname(filePath) + mkdirSync(traceDirectory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }) + chmodPathIfPresent(traceDirectory, PRIVATE_DIRECTORY_MODE) + tightenTraceFamilyPermissions(filePath, maxFiles) + + // The sink owns one open fd. The fd is recreated on rotation; the rotation + // routine closes the old fd, renames the file, and opens a fresh one. We + // use the fd directly (rather than `appendFileSync(filePath, ...)`) so + // rotation is a clean swap and so we can rely on `fstatSync` for the + // current-file size — `statSync(filePath)` would race against another + // process truncating the file under us. + let fd: number = openAppend(filePath) + let currentBytes: number = safeFstatSize(fd) + + let buffer: string[] = [] + let timer: NodeJS.Timeout | null = null + let closed = false + + function openAppend(path: string): number { + const handle = openSync(path, 'a', PRIVATE_FILE_MODE) + try { + fchmodSync(handle, PRIVATE_FILE_MODE) + } catch { + /* best effort — Windows can reject POSIX-style chmod on some volumes */ + } + return handle + } + + function safeFstatSize(handle: number): number { + try { + return fstatSync(handle).size + } catch { + // Fresh-open or fd-out-of-band — start from zero. The next write will + // size correctly via `currentBytes += chunk.length`. + return 0 + } + } + + function rotate(): void { + // Close the active fd before renaming. Some filesystems (notably CIFS) + // refuse to rename an open file; we close, rename, then reopen. + try { + closeSync(fd) + } catch { + /* swallow — best-effort */ + } + // Cascade rename: `.N-1` → `.N`, `.N-2` → `.N-1`, …, base → `.1`. + // Walking from highest index down ensures we never overwrite a file we + // are about to rotate. + for (let i = maxFiles - 1; i >= 1; i--) { + const src = i === 1 ? filePath : `${filePath}.${i - 1}` + const dst = `${filePath}.${i}` + if (!existsSync(src)) { + continue + } + try { + if (existsSync(dst)) { + // The destination shouldn't exist after a clean rotation, but if + // we're recovering from a crashed prior session, drop stale + // intermediate files rather than failing the rename. + unlinkSync(dst) + } + renameSync(src, dst) + } catch { + /* keep going — partial rotation is preferable to crash */ + } + } + // The post-cascade slot is empty; reopen the base file fresh. + fd = openAppend(filePath) + currentBytes = 0 + } + + function flushBuffer(): void { + if (buffer.length === 0 || closed) { + return + } + const lines = buffer + buffer = [] + let pendingChunk: string[] = [] + let pendingChunkBytes = 0 + + function writeChunk(chunkLines: string[], chunkBytes: number): void { + if (chunkLines.length === 0) { + return + } + const chunk = chunkLines.join('') + try { + writeSync(fd, chunk) + currentBytes += chunkBytes + } catch { + // Reopen and retry once. If the second write also fails, drop this + // chunk — the error-tracking lane must never crash main. + try { + // Best-effort close of the prior fd to prevent fd-leak on transient errors. + try { + closeSync(fd) + } catch { + /* swallow — best effort */ + } + fd = openAppend(filePath) + writeSync(fd, chunk) + currentBytes = safeFstatSize(fd) + } catch { + /* swallow — telemetry must never crash main */ + } + } + } + + function flushPendingChunk(): void { + writeChunk(pendingChunk, pendingChunkBytes) + pendingChunk = [] + pendingChunkBytes = 0 + } + + for (const line of lines) { + const lineBytes = Buffer.byteLength(line, 'utf8') + if (lineBytes > maxBytes) { + // A single pathological span should not violate the documented + // maxFiles × maxBytes disk envelope. Drop only that record, not the + // rest of the buffered batch. + continue + } + if (pendingChunkBytes > 0 && currentBytes + pendingChunkBytes + lineBytes > maxBytes) { + flushPendingChunk() + } + // Rotation point: if writing this line would exceed the cap and we + // already have something in the file, rotate first. Empty-file rotations + // are skipped (would just produce zero-byte `.N` files on a new install). + if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) { + rotate() + } + pendingChunk.push(line) + pendingChunkBytes += lineBytes + } + flushPendingChunk() + } + + function ensureTimer(): void { + if (timer || closed) { + return + } + timer = setTimeout(() => { + timer = null + flushBuffer() + }, batchWindowMs) + // Don't keep the event loop alive purely for the flush timer — quitting + // is the path that already triggers a final synchronous flush via + // `close()`, and the periodic flush is a "while running" optimization. + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + return { + filePath, + push(record: unknown): void { + if (closed) { + return + } + let line: string + try { + line = `${JSON.stringify(record)}\n` + } catch { + // Circular reference / non-serializable. The redactor handles cycles + // for us; a stray here means the caller pushed something pre-redact. + // Drop rather than crash — the local file is best-effort. + return + } + buffer.push(line) + if (buffer.length >= flushThreshold) { + flushBuffer() + } else { + ensureTimer() + } + }, + flush(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + flushBuffer() + }, + close(): void { + if (closed) { + return + } + if (timer) { + clearTimeout(timer) + timer = null + } + flushBuffer() + try { + closeSync(fd) + } catch { + /* swallow */ + } + closed = true + } + } +} + +/** Total byte usage across the rotated file family. Used by `bundle.ts` to + * size the read buffer and by the Privacy pane to display a footprint hint. */ +export function getRotatedFamilySize( + filePath: string, + maxFiles: number = DEFAULT_MAX_FILES +): number { + let total = 0 + for (let i = 0; i < maxFiles; i++) { + const path = i === 0 ? filePath : `${filePath}.${i}` + if (existsSync(path)) { + try { + total += statSync(path).size + } catch { + /* ignore — file disappeared between exists and stat */ + } + } + } + return total +} + +/** List rotated files in age order (newest → oldest) for `bundle.ts` to + * iterate when collecting the last N minutes of traces. */ +export function listRotatedFiles(filePath: string, maxFiles: number = DEFAULT_MAX_FILES): string[] { + const out: string[] = [] + for (let i = 0; i < maxFiles; i++) { + const path = i === 0 ? filePath : `${filePath}.${i}` + if (existsSync(path)) { + out.push(path) + } + } + return out +} + +/** Delete every file in the rotated family. Wired up to the "Clear local + * traces" button in Settings → Privacy → Diagnostics. */ +export function clearRotatedFamily(filePath: string, maxFiles: number = DEFAULT_MAX_FILES): void { + for (let i = 0; i < maxFiles; i++) { + const path = i === 0 ? filePath : `${filePath}.${i}` + if (existsSync(path)) { + try { + unlinkSync(path) + } catch { + /* swallow — best-effort delete; user can always reveal-in-finder */ + } + } + } +} diff --git a/src/main/observability/otlp-exporter.test.ts b/src/main/observability/otlp-exporter.test.ts new file mode 100644 index 000000000..f1658de2b --- /dev/null +++ b/src/main/observability/otlp-exporter.test.ts @@ -0,0 +1,226 @@ +// OTLP exporter tests. Most cases lock the wire encoding; the flush suite uses +// a local HTTP server to verify batching without requiring an LGTM container. + +import { createServer, type RequestListener, type Server } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { _internalsForTests, createOtlpExporter, createOtlpExporterFromEnv } from './otlp-exporter' +import type { RedactableSpan } from './redactor' + +const { encodeOtlpPayload, toOtlpAttributes, spanKindToOtlp } = _internalsForTests + +let server: Server | null = null + +afterEach( + () => + new Promise((resolve) => { + if (!server) { + resolve() + return + } + server.close(() => { + server = null + resolve() + }) + }) +) + +function listen(handler: RequestListener): Promise { + server = createServer(handler) + return new Promise((resolve) => { + server?.listen(0, '127.0.0.1', () => { + const address = server?.address() + if (address && typeof address === 'object') { + resolve(`http://127.0.0.1:${address.port}`) + } + }) + }) +} + +function span(overrides: Partial = {}): RedactableSpan { + return { + name: 'unit', + traceId: 'a'.repeat(32), + spanId: 'b'.repeat(16), + kind: 'internal', + startTimeUnixNano: '1000', + endTimeUnixNano: '2000', + durationMs: 1.0, + attributes: {}, + events: [], + exit: { _tag: 'Success' }, + ...overrides + } +} + +describe('otlp-exporter — env gating', () => { + it('returns null when ORCA_OTLP_TRACES_URL is unset', () => { + const before = process.env.ORCA_OTLP_TRACES_URL + delete process.env.ORCA_OTLP_TRACES_URL + expect(createOtlpExporterFromEnv()).toBeNull() + if (before !== undefined) { + process.env.ORCA_OTLP_TRACES_URL = before + } + }) +}) + +describe('otlp-exporter — flushing', () => { + it('serializes batch POSTs and awaits in-flight flushes', async () => { + let activeRequests = 0 + let maxConcurrentRequests = 0 + let requestCount = 0 + const baseUrl = await listen((req, res) => { + req.resume() + requestCount += 1 + activeRequests += 1 + maxConcurrentRequests = Math.max(maxConcurrentRequests, activeRequests) + setTimeout(() => { + activeRequests -= 1 + res.setHeader('content-type', 'application/json') + res.end('{}') + }, 20) + }) + const exporter = createOtlpExporter({ + tracesUrl: `${baseUrl}/v1/traces`, + serviceName: 'orca-test', + timeoutMs: 1_000 + }) + + for (let i = 0; i < 128; i++) { + exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') })) + } + await exporter.flush() + exporter.close() + + expect(requestCount).toBe(2) + expect(maxConcurrentRequests).toBe(1) + }) + + it('caps queued spans and keeps the newest records', async () => { + const receivedSpanIds: string[] = [] + const baseUrl = await listen((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const payload = JSON.parse(body) as ReturnType + receivedSpanIds.push( + ...payload.resourceSpans.flatMap((resourceSpan) => + resourceSpan.scopeSpans.flatMap((scopeSpan) => + scopeSpan.spans.map((exportedSpan) => exportedSpan.spanId) + ) + ) + ) + res.setHeader('content-type', 'application/json') + res.end('{}') + }) + }) + const exporter = createOtlpExporter({ + tracesUrl: `${baseUrl}/v1/traces`, + serviceName: 'orca-test', + timeoutMs: 1_000, + maxQueueSpans: 4 + }) + + for (let i = 0; i < 6; i++) { + exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') })) + } + await exporter.flush() + exporter.close() + + expect(receivedSpanIds).toEqual([ + '0000000000000002', + '0000000000000003', + '0000000000000004', + '0000000000000005' + ]) + }) +}) + +describe('otlp-exporter — attribute encoding', () => { + it('encodes strings, ints, floats, bools', () => { + const out = toOtlpAttributes({ s: 'x', i: 5, f: 1.5, b: true }) + expect(out).toEqual([ + { key: 's', value: { stringValue: 'x' } }, + { key: 'i', value: { intValue: '5' } }, + { key: 'f', value: { doubleValue: 1.5 } }, + { key: 'b', value: { boolValue: true } } + ]) + }) + it('JSON-encodes objects and arrays', () => { + const out = toOtlpAttributes({ list: [1, 2], obj: { a: 1 } }) + expect(out).toContainEqual({ key: 'list', value: { stringValue: '[1,2]' } }) + expect(out).toContainEqual({ key: 'obj', value: { stringValue: '{"a":1}' } }) + }) + it('drops null/undefined', () => { + const out = toOtlpAttributes({ keep: 'x', drop: null, alsodrop: undefined }) + expect(out.find((kv) => kv.key === 'drop')).toBeUndefined() + expect(out.find((kv) => kv.key === 'alsodrop')).toBeUndefined() + expect(out.find((kv) => kv.key === 'keep')).toBeDefined() + }) +}) + +describe('otlp-exporter — span kind mapping', () => { + it('maps OTel SpanKind names to numeric codes', () => { + expect(spanKindToOtlp('internal')).toBe(1) + expect(spanKindToOtlp('server')).toBe(2) + expect(spanKindToOtlp('client')).toBe(3) + expect(spanKindToOtlp('producer')).toBe(4) + expect(spanKindToOtlp('consumer')).toBe(5) + expect(spanKindToOtlp('unknown')).toBe(1) + }) +}) + +describe('otlp-exporter — payload encoding', () => { + it('builds a valid OTLP payload skeleton with service.name', () => { + const out = encodeOtlpPayload('orca-test', [span()]) + expect(out.resourceSpans).toHaveLength(1) + expect(out.resourceSpans[0].resource.attributes).toContainEqual({ + key: 'service.name', + value: { stringValue: 'orca-test' } + }) + expect(out.resourceSpans[0].scopeSpans[0].spans).toHaveLength(1) + }) + + it('includes parentSpanId when present', () => { + const out = encodeOtlpPayload('s', [span({ parentSpanId: 'p'.repeat(16) })]) + const s = out.resourceSpans[0].scopeSpans[0].spans[0] + expect(s.parentSpanId).toBe('p'.repeat(16)) + }) + + it('omits parentSpanId for root spans', () => { + const out = encodeOtlpPayload('s', [span()]) + const s = out.resourceSpans[0].scopeSpans[0].spans[0] + expect(s.parentSpanId).toBeUndefined() + }) + + it('sets ERROR status on Failure exits', () => { + const out = encodeOtlpPayload('s', [span({ exit: { _tag: 'Failure', cause: 'boom' } })]) + const s = out.resourceSpans[0].scopeSpans[0].spans[0] + expect(s.status?.code).toBe(2) + expect(s.status?.message).toBe('boom') + }) + + it('omits status for Success exits', () => { + const out = encodeOtlpPayload('s', [span()]) + const s = out.resourceSpans[0].scopeSpans[0].spans[0] + expect(s.status).toBeUndefined() + }) + + it('encodes events with their attributes', () => { + const out = encodeOtlpPayload('s', [ + span({ + events: [{ name: 'log', timeUnixNano: '1500', attributes: { msg: 'hi' } }] + }) + ]) + const s = out.resourceSpans[0].scopeSpans[0].spans[0] + expect(s.events).toHaveLength(1) + expect(s.events[0].name).toBe('log') + expect(s.events[0].timeUnixNano).toBe('1500') + expect(s.events[0].attributes).toContainEqual({ + key: 'msg', + value: { stringValue: 'hi' } + }) + }) +}) diff --git a/src/main/observability/otlp-exporter.ts b/src/main/observability/otlp-exporter.ts new file mode 100644 index 000000000..2f00ff80c --- /dev/null +++ b/src/main/observability/otlp-exporter.ts @@ -0,0 +1,350 @@ +// Optional OTLP/HTTP traces exporter, gated on `ORCA_OTLP_TRACES_URL`. +// +// Mode 2 in telemetry-error-tracking.md — the user (or an Orca dogfooder) +// stands up a local Grafana LGTM stack with `docker run grafana/otel-lgtm` +// and points the app at it via env vars: +// +// ORCA_OTLP_TRACES_URL=http://localhost:4318/v1/traces +// ORCA_OTLP_METRICS_URL=http://localhost:4318/v1/metrics (reserved for v2) +// ORCA_OTLP_SERVICE_NAME=orca-desktop-myname +// +// Important per the spec: "no Orca-operated OTLP endpoint." This exporter +// is only ever pointed at a user-controlled URL — the README's privacy +// section can truthfully say we do not run an OTLP ingest. +// +// Spec calls for "Effect's first-party OtlpTracer.make"; Orca does not have +// Effect in the dependency tree, so we ship a minimal OTLP/HTTP-JSON +// implementation here. The wire format is the OTLP/HTTP JSON encoding of +// the OpenTelemetry trace ProtoBuf — well-documented, accepted by Grafana +// LGTM, Tempo, Jaeger's OTLP receiver, and any compliant collector. We +// deliberately do not pull in `@opentelemetry/exporter-trace-otlp-http` +// (~80 KB of transitive deps) for a feature gated entirely on an env var +// the typical user will never set. + +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { URL } from 'node:url' +import { redactSpan, type RedactableSpan, type SpanEvent } from './redactor' + +export type OtlpExporterOptions = { + readonly tracesUrl: string + readonly serviceName: string + /** Override for the default 5-second timeout on each POST. */ + readonly timeoutMs?: number + /** Test/diagnostic override for the in-memory span queue cap. */ + readonly maxQueueSpans?: number +} + +export type OtlpExporter = { + /** Enqueue a span for export. Best-effort; failures log a one-time warn. */ + exportSpan(span: RedactableSpan): void + /** Force-flush any in-flight queue. Called from shutdown. */ + flush(): Promise + /** Stop the periodic timer. Called from shutdown. */ + close(): void +} + +const FLUSH_INTERVAL_MS = 1_000 +const MAX_BATCH = 64 +const DEFAULT_MAX_QUEUE_SPANS = 1_024 + +type InternalSpan = { + span: RedactableSpan +} + +/** + * Build an exporter from env vars. Returns `null` if the relevant env vars + * are not set — callers can compose this with the consent gate by simply + * not invoking it when consent disallows network paths. + */ +export function createOtlpExporterFromEnv(): OtlpExporter | null { + const tracesUrl = process.env.ORCA_OTLP_TRACES_URL + if (!tracesUrl || tracesUrl.length === 0) { + return null + } + const serviceName = process.env.ORCA_OTLP_SERVICE_NAME ?? 'orca-desktop' + return createOtlpExporter({ tracesUrl, serviceName }) +} + +export function createOtlpExporter(opts: OtlpExporterOptions): OtlpExporter { + let queue: InternalSpan[] = [] + let timer: NodeJS.Timeout | null = null + let warned = false + let closed = false + let flushPromise: Promise | null = null + const maxQueueSpans = Math.max(1, Math.floor(opts.maxQueueSpans ?? DEFAULT_MAX_QUEUE_SPANS)) + + function ensureTimer(): void { + if (timer || closed) { + return + } + timer = setTimeout(() => { + timer = null + void runFlushLoop() + }, FLUSH_INTERVAL_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + async function flushBatch(): Promise { + if (queue.length === 0) { + return + } + const batch = queue.splice(0, MAX_BATCH) + const payload = encodeOtlpPayload( + opts.serviceName, + batch.map((b) => b.span) + ) + try { + await postJson(opts.tracesUrl, payload, opts.timeoutMs ?? 5_000) + } catch (err) { + if (!warned) { + warned = true + console.warn('[observability:otlp] export failed; further failures will be silent:', err) + } + // Drop the batch on the floor; this is a best-effort path. Re-queueing + // forever would risk unbounded memory growth on a misconfigured URL. + } + } + + function runFlushLoop(): Promise { + if (flushPromise) { + return flushPromise + } + // Why: MAX_BATCH flushes can be triggered by both the timer and exportSpan. + // Serialize them so shutdown can await the currently-posting batch and a + // slow collector cannot create overlapping POST bursts. + flushPromise = (async () => { + while (queue.length > 0) { + await flushBatch() + } + })().finally(() => { + flushPromise = null + }) + return flushPromise + } + + return { + exportSpan(span: RedactableSpan): void { + if (closed) { + return + } + // Apply the redactor regardless of whether the caller already did — + // idempotence makes this safe and the OTLP destination is one of the + // three locations the spec calls for redactor application. + const redacted = redactSpan(span, 'client') + queue.push({ span: redacted }) + if (queue.length > maxQueueSpans) { + // Why: a slow or misconfigured collector must not turn optional OTLP + // export into unbounded memory growth. Keep the newest spans because + // they are closest to the user action being diagnosed. + queue.splice(0, queue.length - maxQueueSpans) + } + if (queue.length >= MAX_BATCH) { + void runFlushLoop() + } else { + ensureTimer() + } + }, + async flush(): Promise { + if (timer) { + clearTimeout(timer) + timer = null + } + await runFlushLoop() + }, + close(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + closed = true + } + } +} + +// ── OTLP/HTTP JSON encoding ────────────────────────────────────────────── +// +// Minimal subset of the OTLP trace ProtoBuf JSON encoding — the parts an +// LGTM / Tempo / Jaeger receiver uses. Full schema: opentelemetry-proto's +// `trace/v1/trace.proto`. Anything we don't emit (status code, scope, links) +// is optional in the spec and defaults sensibly receiver-side. + +type OtlpKeyValue = { + key: string + value: + | { stringValue: string } + | { intValue: string } + | { boolValue: boolean } + | { doubleValue: number } +} + +function toOtlpAttributes(input: Record): OtlpKeyValue[] { + const out: OtlpKeyValue[] = [] + for (const [k, v] of Object.entries(input)) { + if (v === null || v === undefined) { + continue + } + if (typeof v === 'string') { + out.push({ key: k, value: { stringValue: v } }) + } else if (typeof v === 'boolean') { + out.push({ key: k, value: { boolValue: v } }) + } else if (typeof v === 'number') { + // Integers fit in intValue (OTLP requires string-encoded int); floats go + // to doubleValue. JS Number distinguishes via `Number.isInteger`. + if (Number.isInteger(v)) { + out.push({ key: k, value: { intValue: String(v) } }) + } else { + out.push({ key: k, value: { doubleValue: v } }) + } + } else { + // Objects / arrays — flatten to a JSON string. OTLP supports an + // array/kvlist value but the marginal cost of a structured encoder is + // not worth it for a v1 minimal exporter. The redactor has already + // run, so the JSON is safe to ship. + out.push({ key: k, value: { stringValue: JSON.stringify(v) } }) + } + } + return out +} + +function eventToOtlp(ev: SpanEvent): { + timeUnixNano: string + name: string + attributes: OtlpKeyValue[] +} { + return { + timeUnixNano: ev.timeUnixNano, + name: ev.name, + attributes: toOtlpAttributes(ev.attributes as Record) + } +} + +type OtlpPayload = { + resourceSpans: { + resource: { attributes: OtlpKeyValue[] } + scopeSpans: { + scope: { name: string } + spans: { + traceId: string + spanId: string + parentSpanId?: string + name: string + kind: number + startTimeUnixNano: string + endTimeUnixNano: string + attributes: OtlpKeyValue[] + events: ReturnType[] + status?: { code: number; message?: string } + }[] + }[] + }[] +} + +function spanKindToOtlp(kind: string): number { + // SPAN_KIND_INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5. + switch (kind) { + case 'server': + return 2 + case 'client': + return 3 + case 'producer': + return 4 + case 'consumer': + return 5 + default: + return 1 + } +} + +function encodeOtlpPayload(serviceName: string, spans: RedactableSpan[]): OtlpPayload { + return { + resourceSpans: [ + { + resource: { + attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] + }, + scopeSpans: [ + { + scope: { name: 'orca-observability' }, + spans: spans.map((s) => { + // STATUS_CODE: UNSET=0, OK=1, ERROR=2 — Failure → ERROR, the + // others map to UNSET so receivers default-render as "no + // status" rather than synthesizing OK. + const status = + s.exit._tag === 'Failure' + ? { code: 2, ...(s.exit.cause ? { message: s.exit.cause } : {}) } + : undefined + return { + traceId: s.traceId, + spanId: s.spanId, + ...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}), + name: s.name, + kind: spanKindToOtlp(s.kind), + startTimeUnixNano: s.startTimeUnixNano, + endTimeUnixNano: s.endTimeUnixNano, + attributes: toOtlpAttributes(s.attributes as Record), + events: s.events.map(eventToOtlp), + ...(status ? { status } : {}) + } + }) + } + ] + } + ] + } +} + +function postJson(url: string, body: unknown, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let parsed: URL + try { + parsed = new URL(url) + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))) + return + } + const data = JSON.stringify(body) + const protocol = parsed.protocol === 'https:' ? httpsRequest : httpRequest + const req = protocol( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + timeout: timeoutMs, + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(data) + } + }, + (res) => { + // Drain so the connection can be reused / freed; the response body + // is uninteresting for exports. + res.resume() + const status = res.statusCode ?? 0 + if (status >= 200 && status < 300) { + resolve() + } else { + reject(new Error(`OTLP HTTP ${status}`)) + } + } + ) + req.on('error', reject) + req.on('timeout', () => { + req.destroy(new Error('OTLP timeout')) + }) + req.write(data) + req.end() + }) +} + +// Test-only export so the encoder can be verified without a network round- +// trip. Not part of the runtime API. +export const _internalsForTests = { + encodeOtlpPayload, + toOtlpAttributes, + spanKindToOtlp +} diff --git a/src/main/observability/redactor.test.ts b/src/main/observability/redactor.test.ts new file mode 100644 index 000000000..4ad470bc7 --- /dev/null +++ b/src/main/observability/redactor.test.ts @@ -0,0 +1,343 @@ +/* oxlint-disable max-lines -- Why: the redactor has intentionally broad fixture coverage across every secret location and key-shape rule; keeping it together makes gaps visible. */ +// Fixture-based test suite for the redactor. Each provider-key shape is +// exercised in three locations (attribute value, span event message, +// exit-status `cause`) — that's the contract telemetry-error-tracking.md +// §The redactor "Test strategy" calls for. We also cover the four other +// rule families (labeled kv, URL userinfo, .env-line, attribute blocklist) +// plus the server-side mode that drops install_id. + +import { describe, it, expect } from 'vitest' +import { + redactString, + redactAttributes, + redactValue, + redactSpan, + type RedactableSpan +} from './redactor' + +const SECRETS = { + anthropic: 'sk-ant-api03-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789ABCDEFGHIJKLMNOP', + openai: 'sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCDEFGH', + github: 'ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB', + awsAccessKey: 'AKIAIOSFODNN7EXAMPLE', + awsSecret: 'aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY1', + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + slack: 'xoxb-1234567890-abcdefghij', + pem: '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ\n-----END PRIVATE KEY-----' +} + +const SHAPES: { label: string; raw: string; tag: string }[] = [ + { label: 'anthropic', raw: SECRETS.anthropic, tag: 'anthropic-key' }, + { label: 'openai', raw: SECRETS.openai, tag: 'openai-key' }, + { label: 'github', raw: SECRETS.github, tag: 'github-token' }, + { label: 'aws-access-key', raw: SECRETS.awsAccessKey, tag: 'aws-access-key-id' }, + { label: 'aws-secret', raw: SECRETS.awsSecret, tag: 'aws-secret-access-key' }, + { label: 'jwt', raw: SECRETS.jwt, tag: 'jwt' }, + { label: 'slack', raw: SECRETS.slack, tag: 'slack-token' }, + { label: 'pem', raw: SECRETS.pem, tag: 'pem' } +] + +describe('redactor — provider-key fingerprints', () => { + for (const { label, raw, tag } of SHAPES) { + describe(`${label}`, () => { + it('redacts when the secret appears as an attribute value', () => { + // Bare "" without a labeled-kv keyword nearby — exercises the + // provider-shape pass directly. (The labeled-kv pass is verified by + // its own test below; here we want to confirm provider-shape wins + // when there's no label to swallow it.) + const out = redactAttributes({ msg: `failure ${raw}` }) + const serialized = JSON.stringify(out) + expect(serialized).not.toContain(raw) + expect(serialized).toContain(`[redacted:${tag}]`) + }) + + it('redacts when the secret appears in a span event attribute', () => { + const span = makeSpan({ + events: [ + { + name: 'log', + timeUnixNano: '0', + attributes: { 'log.message': `oops ${raw}` } + } + ] + }) + const out = JSON.stringify(redactSpan(span)) + expect(out).not.toContain(raw) + expect(out).toContain(`[redacted:${tag}]`) + }) + + it('redacts when the secret appears in the exit cause', () => { + const span = makeSpan({ + exit: { + _tag: 'Failure', + // Phrased without a labeled-kv keyword (no "token:", "key:") so + // the assertion can pin the provider-specific tag — the labeled- + // kv rule is checked separately. What matters most is that the + // raw secret is absent; the tag is for triage convenience. + cause: `Error: provider rejected request ${raw}\n at handler (file.ts:1:1)` + } + }) + const out = JSON.stringify(redactSpan(span)) + expect(out).not.toContain(raw) + expect(out).toContain(`[redacted:${tag}]`) + }) + + it('redacts when nested under a labeled-kv keyword (defense in depth)', () => { + // This case mimics the real-world failure mode: the provider SDK + // echoes the key back as `Invalid token: sk-ant-…`. Either the + // labeled-kv or provider-shape rule is allowed to win; the contract + // is "the secret is gone." + const span = makeSpan({ + exit: { + _tag: 'Failure', + cause: `Invalid token: ${raw}` + } + }) + const out = JSON.stringify(redactSpan(span)) + expect(out).not.toContain(raw) + }) + }) + } +}) + +describe('redactor — labeled key-value', () => { + it('redacts api_key:', () => { + expect(redactString('api_key: hunter2')).toBe('[redacted:labeled-kv]') + }) + it('redacts Authorization=Bearer …', () => { + expect(redactString('Authorization=Bearer abcdef')).toBe('[redacted:labeled-kv]') + }) + it('redacts password=…', () => { + expect(redactString("password='hunter2'")).toContain('[redacted:labeled-kv]') + }) + it('redacts secret=…', () => { + expect(redactString('secret=topsekret')).toBe('[redacted:labeled-kv]') + }) + it('preserves surrounding context outside the matched segment', () => { + const out = redactString('before api_key=xyz after') + expect(out).toContain('before') + expect(out).toContain('after') + expect(out).not.toContain('xyz') + }) +}) + +describe('redactor — URL userinfo strip', () => { + it('strips user:pass@ from https URLs', () => { + const out = redactString('clone failed: https://ghp_xxxxxxxxxx@github.com/foo/bar') + expect(out).not.toContain('ghp_xxxxxxxxxx@') + expect(out).toContain('github.com/foo/bar') + expect(out).toContain('[redacted]@') + }) + it('preserves bare URLs without userinfo', () => { + expect(redactString('https://github.com/foo')).toBe('https://github.com/foo') + }) +}) + +describe('redactor — .env-shape line', () => { + it('redacts the value but keeps the key', () => { + const out = redactString('FOO_SECRET=topsekret') + expect(out).toContain('FOO_SECRET=') + expect(out).toContain('[redacted:env-value]') + expect(out).not.toContain('topsekret') + }) + it('handles multi-line .env dumps', () => { + const out = redactString(['DB_HOST=db.local', 'DB_PASSWORD=hunter2', '# comment'].join('\n')) + expect(out).toContain('DB_HOST=[redacted:env-value]') + expect(out).toContain('DB_PASSWORD=[redacted:env-value]') + expect(out).toContain('# comment') + expect(out).not.toContain('hunter2') + expect(out).not.toContain('db.local') + }) +}) + +describe('redactor — attribute-key blocklist', () => { + it('drops env attribute', () => { + const out = redactAttributes({ env: { ANTHROPIC_API_KEY: SECRETS.anthropic } }) + expect(out).not.toHaveProperty('env') + }) + it('drops authorization (case-insensitive)', () => { + const out = redactAttributes({ Authorization: 'Bearer x', cookie: 'a=b' }) + expect(out).not.toHaveProperty('Authorization') + expect(out).not.toHaveProperty('cookie') + }) + it('drops headers.authorization', () => { + const out = redactAttributes({ 'headers.authorization': 'Bearer x' }) + expect(out).not.toHaveProperty('headers.authorization') + }) + it('drops structured secret-bearing keys with plain values', () => { + const out = redactAttributes({ + token: 'plain-token', + api_key: 'plain-api-key', + password: 'plain-password', + secret: 'plain-secret', + keep: 'ok' + }) + expect(out).not.toHaveProperty('token') + expect(out).not.toHaveProperty('api_key') + expect(out).not.toHaveProperty('password') + expect(out).not.toHaveProperty('secret') + expect(out.keep).toBe('ok') + }) + it('drops compound structured secret-bearing keys with plain values', () => { + const out = redactValue({ + ANTHROPIC_API_KEY: 'plain-anthropic', + client_secret: 'plain-client', + accessToken: 'plain-access', + refreshToken: 'plain-refresh', + 'x-api-key': 'plain-x-api', + private_key: 'plain-private', + auth_token: 'plain-auth', + AUTH_TOKEN: 'plain-auth-upper', + sessionToken: 'plain-session', + githubToken: 'plain-github', + DB_PASSWORD: 'plain-db', + serverSecretKey: 'plain-server', + keep: 'ok' + }) as Record + expect(out).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(out).not.toHaveProperty('client_secret') + expect(out).not.toHaveProperty('accessToken') + expect(out).not.toHaveProperty('refreshToken') + expect(out).not.toHaveProperty('x-api-key') + expect(out).not.toHaveProperty('private_key') + expect(out).not.toHaveProperty('auth_token') + expect(out).not.toHaveProperty('AUTH_TOKEN') + expect(out).not.toHaveProperty('sessionToken') + expect(out).not.toHaveProperty('githubToken') + expect(out).not.toHaveProperty('DB_PASSWORD') + expect(out).not.toHaveProperty('serverSecretKey') + expect(out.keep).toBe('ok') + }) + it('keeps non-blocklisted keys', () => { + const out = redactAttributes({ path: '/Users/x/repo', method: 'GET' }) + expect(out).toHaveProperty('path') + expect(out).toHaveProperty('method') + }) + it('preserves filesystem paths verbatim — they are diagnostic data', () => { + const out = redactAttributes({ cwd: '/Users/brennanb/projects/orca' }) + expect(out.cwd).toBe('/Users/brennanb/projects/orca') + }) + it('drops nested blocked keys while recursively redacting values', () => { + const out = redactValue({ + request: { + headers: { + authorization: 'Bearer plain-secret', + cookie: 'sid=plain-secret', + keep: 'ok' + } + } + }) as { request: { headers: Record } } + expect(out.request.headers).not.toHaveProperty('authorization') + expect(out.request.headers).not.toHaveProperty('cookie') + expect(out.request.headers.keep).toBe('ok') + }) +}) + +describe('redactor — server mode adds install_id keys', () => { + it('drops install_id, installId, distinct_id', () => { + const before = { + install_id: 'abc', + installId: 'def', + distinct_id: 'ghi', + keep: 'me' + } + const client = redactAttributes(before, 'client') + const server = redactAttributes(before, 'server') + + // Client mode keeps these (they are valid in product telemetry). + expect(client).toHaveProperty('install_id') + expect(client).toHaveProperty('installId') + expect(client).toHaveProperty('distinct_id') + + // Server mode strips them. + expect(server).not.toHaveProperty('install_id') + expect(server).not.toHaveProperty('installId') + expect(server).not.toHaveProperty('distinct_id') + expect(server).toHaveProperty('keep') + }) + it('drops identity keys nested inside server-mode values', () => { + const out = redactValue({ context: { install_id: 'abc', keep: 'me' } }, 'server') as { + context: Record + } + expect(out.context).not.toHaveProperty('install_id') + expect(out.context.keep).toBe('me') + }) +}) + +describe('redactor — recursive value redaction', () => { + it('recurses into nested objects', () => { + const out = redactValue({ outer: { inner: `key: ${SECRETS.anthropic}` } }) as Record< + string, + Record + > + expect(out.outer.inner).not.toContain(SECRETS.anthropic) + expect(out.outer.inner).toContain('[redacted:anthropic-key]') + }) + it('recurses into arrays', () => { + const out = redactValue([SECRETS.github, 'plain']) as string[] + expect(out[0]).not.toContain(SECRETS.github) + expect(out[1]).toBe('plain') + }) + it('handles circular references without crashing', () => { + const a: Record = {} + a.self = a + expect(() => redactValue(a)).not.toThrow() + }) +}) + +describe('redactor — idempotence', () => { + it('running twice equals running once', () => { + const cases = [ + `api_key: ${SECRETS.anthropic}`, + `https://${SECRETS.github}@github.com/foo`, + 'FOO=bar', + 'plain text' + ] + for (const c of cases) { + const once = redactString(c) + const twice = redactString(once) + expect(twice).toBe(once) + } + }) +}) + +describe('redactor — span shape', () => { + it('preserves traceId/spanId/name/duration', () => { + const span = makeSpan({}) + const out = redactSpan(span) + expect(out.traceId).toBe(span.traceId) + expect(out.spanId).toBe(span.spanId) + expect(out.name).toBe(span.name) + expect(out.durationMs).toBe(span.durationMs) + }) + it('preserves parentSpanId when present', () => { + const span = makeSpan({ parentSpanId: 'parent123' }) + expect(redactSpan(span).parentSpanId).toBe('parent123') + }) + it('does not mutate input span', () => { + const before = makeSpan({ + attributes: { token: SECRETS.anthropic } + }) + const beforeStr = JSON.stringify(before) + redactSpan(before) + expect(JSON.stringify(before)).toBe(beforeStr) + }) +}) + +// ── helpers ────────────────────────────────────────────────────────────── + +function makeSpan(overrides: Partial): RedactableSpan { + return { + name: 'test.span', + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + kind: 'internal', + startTimeUnixNano: '1000', + endTimeUnixNano: '2000', + durationMs: 1.0, + attributes: {}, + events: [], + exit: { _tag: 'Success' }, + ...overrides + } +} diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts new file mode 100644 index 000000000..9db5349db --- /dev/null +++ b/src/main/observability/redactor.ts @@ -0,0 +1,353 @@ +// Secrets scrubber for the error-tracking lane. Runs synchronously at three +// well-defined locations (see telemetry-error-tracking.md §The redactor): +// +// 1. Sink-write time — every span is redacted before NDJSON serialization +// and before any optional OTLP export. +// 2. Bundle-collection time — a second pass before the user-preview window +// renders. Belt-and-suspenders against a sink-write bug. +// 3. Server-side ingest — a third pass. The client-side redactor runs on +// an attacker-controllable binary; server-side redaction is the +// defense-in-depth guarantee on the one path where bundle bytes reach +// Orca infrastructure. We expose `serverSideRedact()` separately so +// the server can additionally drop `install_id`/`installId`/ +// `distinct_id` keys (which are valid in product telemetry but must not +// ride along on a bundle — see "Why bundles do not carry install_id"). +// +// Five rule families, applied in this order: +// 1. labeled key-value (`api_key:`, `Authorization=Bearer …`) +// 2. provider-key fingerprints (8 shapes) +// 3. URL userinfo strip (`https://user:pass@host` → `https://[redacted]@host`) +// 4. .env-shape line redaction (`FOO_SECRET=…`) +// 5. attribute-key block-list (drop key entirely) +// +// Rules 1–4 operate on string values; rule 5 drops attribute *keys* before +// the values are even examined. The string passes are idempotent — running +// the redactor twice in a row produces the same output as running it once, +// which is what makes the three-location placement safe. +// +// Per-attribute length capping is deliberately NOT applied here. The spec +// argues against it (see §The redactor "No per-attribute length cap"): +// envelope-level bounds (10 MB × 10 file rotation; 10 MB Mode-3 upload cap) +// already cover the worst case, and a per-attribute truncation would eat the +// tail of long stack chains, which is the most diagnostic part. Spans that +// dump a multi-MB blob into one attribute are a call-site bug to fix at the +// call site, not at the sink. + +// Word boundaries (`\b`) on the keyword alternation prevent the rule from +// firing inside compound identifiers — e.g. `FOO_SECRET=…` (an .env-shape +// line redacted by Rule 4) and `DB_PASSWORD=…` should NOT match the +// `secret`/`password` keyword here, otherwise this rule would steal the +// match from rule 4 and produce `FOO_[redacted:labeled-kv]` rather than +// preserving the key name. +// +// The value alternation `(?:Bearer\s+\S+|Token\s+\S+|\S+)` lets the rule +// consume the *whole* secret-bearing segment for the common +// `Authorization=Bearer ` / `Authorization: Token ` shapes — a +// plain `\S+` would only eat `Bearer` and leave the JWT exposed. +const LABELED_KV = + /\b(?:api[-_]?key|token|secret|password|bearer|authorization)\b\s*[:=]\s*(?:Bearer\s+\S+|Token\s+\S+|\S+)/gi + +// Each provider shape replaced with a tagged token so triage can see WHAT +// was redacted (e.g. `[redacted:anthropic-key]` is a strong hint that the +// failing call was a Claude auth error) without exposing the key itself. +// +// Order: longest / most-specific patterns first. `sk-ant-…` must be tried +// before the bare `sk-…` OpenAI shape, otherwise the Anthropic key would be +// partially matched by the OpenAI rule and the `[redacted:anthropic-key]` +// triage signal would be lost. +const PROVIDER_PATTERNS: { tag: string; re: RegExp }[] = [ + { tag: 'anthropic-key', re: /sk-ant-[a-zA-Z0-9_-]{40,}/g }, + { tag: 'openai-key', re: /sk-(?:proj-)?[a-zA-Z0-9_-]{32,}/g }, + { tag: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,}/g }, + { tag: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/g }, + { + tag: 'aws-secret-access-key', + re: /aws_secret_access_key\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi + }, + { + tag: 'jwt', + re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g + }, + { tag: 'slack-token', re: /xox[baprsoe]-[A-Za-z0-9-]{10,}/g }, + { + tag: 'pem', + // Greedy intentionally bounded by the matching END marker; PEM blocks are + // multi-line. The `[\s\S]+?` keeps it minimal so a buffer with two PEM + // blocks back-to-back redacts each one independently rather than gobbling + // text between them. + re: /-----BEGIN [A-Z ]+-----[\s\S]+?-----END [A-Z ]+-----/g + } +] + +// Userinfo strip — preserves host + path so the debug context (`failed to +// fetch from github.com/foo/bar`) is intact while removing the credential. +// Two shapes are valid in practice and both leak credentials: +// - `https://user:pass@host/...` (classic basic-auth URL) +// - `https://@github.com/...` (GitHub PAT-in-URL — exactly what +// `git clone` emits when push/pull fails. No colon, just a token before +// the `@`.) +// The pattern matches either: any non-empty `[^/@\s]+@` after the scheme is +// userinfo and gets stripped. Spec mentions only the colon-bearing form, +// but the bare-token form is the one we actually see in failing git stderr. +const URL_USERINFO = /(https?:\/\/)([^/@\s]+)@/g + +// Per-line .env shape. The `m` flag is required so `^` anchors at line +// starts inside multi-line strings (a stack frame, a captured stderr +// dump, etc.). The pattern intentionally requires the equals sign on the +// same line — `FOO=\n bar` is a different pattern (continuation) and not +// commonly how secrets show up. +// +// The value pattern (`\S.*`) consumes to end of line so multi-token values +// like `FOO_TOKEN=Bearer ` are redacted whole rather than leaking the +// trailing token. The leading `\S` requires the value to start with a +// non-whitespace char so a bare `FOO=` followed by nothing on the same +// line doesn't get an empty redact-token. +const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*\S.*/gm + +// Attribute keys that must never carry through, regardless of value. Match +// is case-insensitive — HTTP headers vary in case and we want all forms. +// `Object.hasOwn` semantics: presence in this set drops the attribute +// entirely, the value is never examined. +const CLIENT_ATTR_BLOCKLIST = new Set([ + 'env', + 'environment', + 'env_vars', + 'api_key', + 'api-key', + 'apikey', + 'authorization', + 'bearer', + 'cookie', + 'password', + 'set-cookie', + 'secret', + 'token', + 'access_token', + 'refresh_token', + 'proxy-authorization', + 'headers.authorization' +]) + +// Mode-3 server-side pass adds the PostHog-lane identity keys. These are +// valid in product telemetry but must not ride along inside a bundle — +// otherwise an Orca staff member opening the bundle could re-identify all +// PostHog history for that user (see telemetry-error-tracking.md §"Why +// bundles do not carry install_id"). +const SERVER_ATTR_BLOCKLIST_EXTRA = new Set([ + 'install_id', + 'installid', + 'distinct_id', + 'distinctid' +]) + +export type RedactorMode = 'client' | 'server' + +function shouldDropAttributeKey(key: string, mode: RedactorMode): boolean { + const k = key.toLowerCase() + const normalized = k.replace(/[^a-z0-9]+/g, '') + if (CLIENT_ATTR_BLOCKLIST.has(k)) { + return true + } + // Structured span attributes often carry secret labels in the key itself + // (`ANTHROPIC_API_KEY`, `clientSecret`, `x-api-key`) with plain values that + // string redaction cannot classify. Drop by key family before value redaction. + if ( + /\b(api[-_]?key|token|secret|password|bearer|authorization|private[-_]?key)\b/i.test(key) || + /(apikey|token|secret|password|authorization|bearer|privkey|privatekey)/.test(normalized) + ) { + return true + } + if (mode === 'server' && SERVER_ATTR_BLOCKLIST_EXTRA.has(k)) { + return true + } + return false +} + +/** + * Apply rules 1–4 to a string. Idempotent — running this twice yields the + * same output as once, which is what makes triple-application safe. + */ +export function redactString(input: string): string { + if (typeof input !== 'string' || input.length === 0) { + return input + } + let out = input + + // Rule 1 — labeled key-value. Replace the entire `key: value` segment with + // a tagged token. We deliberately blow away the labeled-key alongside the + // value because the label name itself ("api_key", "Authorization") leaks + // no useful debug context once the value is gone. + out = out.replace(LABELED_KV, '[redacted:labeled-kv]') + + // Rule 2 — provider-key fingerprints. Each shape is tried independently, + // so a string carrying multiple keys gets all of them redacted. The tag + // names (e.g. `anthropic-key`) are stable wire identifiers — third-party + // tools that read our NDJSON can grep for them. + for (const { tag, re } of PROVIDER_PATTERNS) { + out = out.replace(re, `[redacted:${tag}]`) + } + + // Rule 3 — URL userinfo. Preserves scheme + host + path; replaces only the + // `user:pass@` segment with `[redacted]@`. Done after rule 2 so a userinfo + // value that happens to look like a provider key gets the more specific + // redaction first. + out = out.replace(URL_USERINFO, '$1[redacted]@') + + // Rule 4 — .env-shape line redaction. Keep the key name (`FOO_SECRET=`), + // replace only the value with `[redacted:env-value]`. Done last among the + // string passes so a labeled-kv match (rule 1) wins over a coincidentally + // .env-shaped substring inside a longer line. + out = out.replace(ENV_LINE, (_match, key) => `${String(key)}=[redacted:env-value]`) + + return out +} + +/** + * Recursively redact a value of unknown shape — strings get rules 1–4; + * objects/arrays/maps recurse; primitives pass through. Designed for the + * span-attribute and span-event use cases where attribute *values* can be + * any JSON-shaped thing. + * + * Loop guard: we track visited references in a `WeakSet` so a self-referential + * cycle does not stack-overflow. Cycles are unusual in span attributes but + * span-event payloads occasionally get serialized error objects with cycles. + */ +export function redactValue( + value: unknown, + mode: RedactorMode = 'client', + seen: WeakSet = new WeakSet() +): unknown { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string') { + return redactString(value) + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return value + } + if (Array.isArray(value)) { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + return value.map((entry) => redactValue(entry, mode, seen)) + } + if (value instanceof Date) { + return value + } + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + // Why: bundle collection re-redacts parsed NDJSON, where secrets can + // appear below attributes as nested HTTP headers or identity payloads. + if (shouldDropAttributeKey(k, mode)) { + continue + } + out[k] = redactValue(v, mode, seen) + } + return out + } + // Functions / symbols — coerce to a string label rather than carrying the + // value through. These do not show up in legitimate spans. + return `[unsupported:${typeof value}]` +} + +/** + * Redact an attributes record: drop blocked keys, recursively redact values + * of remaining keys. + */ +export function redactAttributes( + attrs: Readonly>, + mode: RedactorMode = 'client' +): Record { + const out: Record = {} + for (const [k, v] of Object.entries(attrs)) { + if (shouldDropAttributeKey(k, mode)) { + continue + } + out[k] = redactValue(v, mode) + } + return out +} + +// ── Span-record redaction (the public entry point used by the sink) ────── + +export type SpanEvent = { + readonly name: string + readonly timeUnixNano: string + readonly attributes: Readonly> +} + +export type SpanExit = { + readonly _tag: 'Success' | 'Failure' | 'Interrupted' + readonly cause?: string +} + +export type RedactableSpan = { + readonly name: string + readonly traceId: string + readonly spanId: string + readonly parentSpanId?: string + readonly kind: string + readonly startTimeUnixNano: string + readonly endTimeUnixNano: string + readonly durationMs: number + readonly attributes: Readonly> + readonly events: readonly SpanEvent[] + readonly exit: SpanExit +} + +/** + * Redact a complete span record. Returns a new record — the input is not + * mutated, which keeps the redactor safe to run mid-pipeline (e.g. the sink + * holds a reference to the live span until end()) and idempotent. + * + * The exit `cause` string carries the formatted stack trace and is one of + * the most-likely places for a leaked secret (provider SDKs routinely echo + * the auth token back in the error message). Apply rules 1–4 there. + * + * Span event attribute keys are redacted with the same blocklist as span + * attributes; an `authorization` event-attribute is just as leaky as an + * `authorization` span-attribute. + */ +export function redactSpan(span: RedactableSpan, mode: RedactorMode = 'client'): RedactableSpan { + const redactedAttrs = redactAttributes(span.attributes, mode) + const redactedEvents: SpanEvent[] = span.events.map((ev) => ({ + name: ev.name, + timeUnixNano: ev.timeUnixNano, + attributes: redactAttributes(ev.attributes, mode) + })) + const exit: SpanExit = span.exit.cause + ? { _tag: span.exit._tag, cause: redactString(span.exit.cause) } + : { _tag: span.exit._tag } + return { + name: span.name, + traceId: span.traceId, + spanId: span.spanId, + ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}), + kind: span.kind, + startTimeUnixNano: span.startTimeUnixNano, + endTimeUnixNano: span.endTimeUnixNano, + durationMs: span.durationMs, + attributes: redactedAttrs, + events: redactedEvents, + exit + } +} + +// ── Test-only introspection (kept here so tests can verify the rule set +// without re-deriving it from external assertions). ───────────────────────── + +export const _internalsForTests = { + PROVIDER_PATTERNS, + CLIENT_ATTR_BLOCKLIST, + SERVER_ATTR_BLOCKLIST_EXTRA, + LABELED_KV, + URL_USERINFO, + ENV_LINE +} diff --git a/src/main/observability/tracer.test.ts b/src/main/observability/tracer.test.ts new file mode 100644 index 000000000..d1bec74ab --- /dev/null +++ b/src/main/observability/tracer.test.ts @@ -0,0 +1,185 @@ +// Tracer tests: context propagation, exit-status recording, redaction +// integration. The sink is mocked so we can inspect the records that would +// have been pushed. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + _resetTracerForTests, + getActiveSpanContext, + setActiveSink, + startSpan, + withSpan, + type TracerSink +} from './tracer' + +type CapturedSink = TracerSink & { + records: unknown[] +} + +function makeCapturingSink(): CapturedSink { + const records: unknown[] = [] + return { + records, + push(r) { + records.push(r) + }, + flush() { + /* no-op */ + }, + close() { + /* no-op */ + } + } +} + +let sink: CapturedSink + +beforeEach(() => { + sink = makeCapturingSink() + setActiveSink(sink) +}) +afterEach(() => { + _resetTracerForTests() +}) + +describe('tracer — basic span lifecycle', () => { + it('records a span on end()', () => { + const span = startSpan('test') + span.end() + expect(sink.records).toHaveLength(1) + const r = sink.records[0] as { type: string; name: string; exit: { _tag: string } } + expect(r.type).toBe('effect-span') + expect(r.name).toBe('test') + expect(r.exit._tag).toBe('Success') + }) + + it('records Failure when fail() is called', () => { + const span = startSpan('test') + span.fail(new Error('boom')) + const r = sink.records[0] as { exit: { _tag: string; cause: string } } + expect(r.exit._tag).toBe('Failure') + expect(r.exit.cause).toContain('boom') + }) + + it('records Interrupted on interrupt()', () => { + const span = startSpan('test') + span.interrupt('user-cancelled') + const r = sink.records[0] as { exit: { _tag: string; cause: string } } + expect(r.exit._tag).toBe('Interrupted') + expect(r.exit.cause).toBe('user-cancelled') + }) + + it('end() is idempotent — second call is a no-op', () => { + const span = startSpan('test') + span.end() + span.end() + expect(sink.records).toHaveLength(1) + }) + + it('fail() after end() is a no-op (first end wins)', () => { + const span = startSpan('test') + span.end() + span.fail('late') + expect(sink.records).toHaveLength(1) + const r = sink.records[0] as { exit: { _tag: string } } + expect(r.exit._tag).toBe('Success') + }) +}) + +describe('tracer — attributes and events', () => { + it('captures attributes set before end()', () => { + const span = startSpan('test', { attributes: { initial: true } }) + span.setAttribute('mid', 42) + span.end() + const r = sink.records[0] as { attributes: Record } + expect(r.attributes.initial).toBe(true) + expect(r.attributes.mid).toBe(42) + }) + + it('captures events with redacted attribute values', () => { + const span = startSpan('test') + span.addEvent('log', { 'log.message': `sk-ant-${'a'.repeat(50)}` }) + span.end() + const r = sink.records[0] as { + events: { name: string; attributes: Record }[] + } + expect(r.events).toHaveLength(1) + expect(r.events[0].name).toBe('log') + expect(r.events[0].attributes['log.message']).toContain('[redacted:anthropic-key]') + }) + + it('drops blocklisted attribute keys', () => { + const span = startSpan('test', { attributes: { authorization: 'Bearer x', cwd: '/repo' } }) + span.end() + const r = sink.records[0] as { attributes: Record } + expect(r.attributes).not.toHaveProperty('authorization') + expect(r.attributes.cwd).toBe('/repo') + }) +}) + +describe('tracer — context propagation via AsyncLocalStorage', () => { + it('child span inherits parent traceId', async () => { + await withSpan('outer', async () => { + const outerCtx = getActiveSpanContext() + expect(outerCtx).toBeDefined() + await withSpan('inner', async () => { + const innerCtx = getActiveSpanContext() + expect(innerCtx?.traceId).toBe(outerCtx?.traceId) + // spanId should be different — inner has its own. + expect(innerCtx?.spanId).not.toBe(outerCtx?.spanId) + }) + }) + + // Two records: inner first (it ended first), then outer. + expect(sink.records).toHaveLength(2) + const inner = sink.records[0] as { + name: string + traceId: string + parentSpanId?: string + } + const outer = sink.records[1] as { name: string; traceId: string; spanId: string } + expect(inner.name).toBe('inner') + expect(outer.name).toBe('outer') + expect(inner.traceId).toBe(outer.traceId) + expect(inner.parentSpanId).toBe(outer.spanId) + }) + + it('a top-level span has no parentSpanId', async () => { + await withSpan('top', async () => { + /* */ + }) + const r = sink.records[0] as { parentSpanId?: string } + expect(r.parentSpanId).toBeUndefined() + }) +}) + +describe('tracer — withSpan auto-fail on throw', () => { + it('records Failure when fn throws and re-throws', async () => { + await expect( + withSpan('throws', async () => { + throw new Error('kaboom') + }) + ).rejects.toThrow('kaboom') + const r = sink.records[0] as { exit: { _tag: string; cause: string } } + expect(r.exit._tag).toBe('Failure') + expect(r.exit.cause).toContain('kaboom') + }) + + it('records Success when fn returns', async () => { + const result = await withSpan('ok', async () => 42) + expect(result).toBe(42) + const r = sink.records[0] as { exit: { _tag: string } } + expect(r.exit._tag).toBe('Success') + }) +}) + +describe('tracer — no-op when sink is unset', () => { + it('returns a no-op span and writes nothing', () => { + setActiveSink(null) + const span = startSpan('detached') + span.setAttribute('x', 1) + span.fail('whatever') + // Nothing observable happens. + expect(sink.records).toHaveLength(0) + }) +}) diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts new file mode 100644 index 000000000..f11b0fbbf --- /dev/null +++ b/src/main/observability/tracer.ts @@ -0,0 +1,261 @@ +// A small, plain-TS span recorder modeled on a local-first +// `LocalFileTracer` / `TraceSink` pair. Orca does not use Effect, so we +// port the *behavior* — span lifecycle, attribute capture, exit-status +// recording — rather than the Effect Tracer.Tracer interface, and emit the +// same NDJSON record shape our local sink expects (`type: 'effect-span'`, +// `traceId`, `spanId`, `parentSpanId?`, `attributes`, `events`, `exit`). +// Wire-compatibility is what lets us pipe traces into the same Grafana LGTM +// dashboards used by local OpenTelemetry collectors. +// +// Concurrency model: in-process span tree maintained via Node's +// `AsyncLocalStorage`, so a child span created inside an `await` chain +// inherits its caller's parent without explicit threading. The tree itself +// is single-threaded — Electron's main process is one v8 isolate, no +// worker_threads in this layer — so plain in-memory state is enough. +// +// All spans hand off through `redactSpan()` before serialization. The +// redactor is run at sink-write time, again at bundle-collection time, and +// a third time on the server (see redactor.ts) — three locations of one +// idempotent function. The runtime cost is dominated by `redactString` on +// the exit cause string and is negligible at the span volume we expect. + +import { AsyncLocalStorage } from 'node:async_hooks' +import { randomBytes } from 'node:crypto' +import { redactSpan, type RedactableSpan, type SpanEvent, type SpanExit } from './redactor' + +export type TracerSink = { + push(record: unknown): void + flush(): void + close(): void +} + +export type SpanContext = { + readonly traceId: string + readonly spanId: string +} + +export type ActiveSpan = SpanContext & { + /** Set or overwrite an attribute on this span. Free-form values are fine + * — they pass through `redactValue` at serialization time. */ + setAttribute(key: string, value: unknown): void + /** Record a span event (an embedded log message). `attributes` are + * redacted with the same blocklist as span attributes. */ + addEvent(name: string, attributes?: Record): void + /** Mark the span complete with a Failure exit. `cause` typically holds + * the formatted stack chain. */ + fail(cause: string | Error): void + /** Mark the span complete with an Interrupted exit (user cancellation, + * process abort). */ + interrupt(cause?: string): void + /** Mark the span complete with a Success exit. Idempotent — calling end() + * twice is a no-op so wrappers can call it from a finally block without + * worrying about a prior fail()/interrupt() race. */ + end(): void +} + +type PendingSpan = { + readonly name: string + readonly traceId: string + readonly spanId: string + readonly parentSpanId: string | undefined + readonly kind: string + readonly startTimeUnixNano: bigint + readonly attributes: Map + readonly events: SpanEvent[] + exit: SpanExit | null + ended: boolean +} + +const noopSpan: ActiveSpan = { + traceId: '', + spanId: '', + setAttribute() { + /* no-op */ + }, + addEvent() { + /* no-op */ + }, + fail() { + /* no-op */ + }, + interrupt() { + /* no-op */ + }, + end() { + /* no-op */ + } +} + +let activeSink: TracerSink | null = null +const contextStorage = new AsyncLocalStorage() + +// 16-byte traceId / 8-byte spanId — OpenTelemetry hex shapes. Using +// `randomBytes(8)` over `randomUUID()` because the OTLP exporter expects the +// shorter spanId without dashes; standardizing on the OTLP shape avoids a +// per-exporter conversion later. +function genTraceId(): string { + return randomBytes(16).toString('hex') +} +function genSpanId(): string { + return randomBytes(8).toString('hex') +} + +function nowUnixNano(): bigint { + // Date.now() is millisecond-precision; Effect's NativeSpan uses + // process.hrtime.bigint() but the boot-time offset is annoying to align. + // Millisecond × 1e6 is fine for the scope of "did span A start before + // span B" comparisons within one process. + return BigInt(Date.now()) * 1_000_000n +} + +/** Install the active sink. Called by `index.ts` from the composition root. + * Multiple installs are not supported — `setActiveSink(null)` clears. */ +export function setActiveSink(sink: TracerSink | null): void { + activeSink = sink +} + +/** Read the currently-active sink. Used by the bundle path to flush before + * collecting traces, and by tests for assertion. */ +export function getActiveSink(): TracerSink | null { + return activeSink +} + +/** Get the current parent context, or `undefined` if we are at the top of + * the trace tree. Renderer-IPC entry points capture this, embed it in + * span-event attributes (so cross-process spans can be visually linked + * later in v2), and start a new trace. */ +export function getActiveSpanContext(): SpanContext | undefined { + return contextStorage.getStore() +} + +/** + * Start a span and run `fn` inside its context. The returned promise + * resolves to `fn`'s return value; `fn`'s thrown errors propagate after + * the span has been recorded as a Failure. + * + * This is the function 90% of call sites should reach for. It mirrors + * OpenTelemetry's `tracer.startActiveSpan` shape so engineers who have used + * OTel anywhere recognize the call site immediately. + */ +export async function withSpan( + name: string, + fn: (span: ActiveSpan) => Promise | T, + options?: { kind?: string; attributes?: Record } +): Promise { + const span = startSpan(name, options) + try { + const result = await contextStorage.run({ traceId: span.traceId, spanId: span.spanId }, () => + fn(span) + ) + span.end() + return result + } catch (err) { + span.fail(err as Error) + throw err + } +} + +/** + * Start a span without binding it to a context. Use when the lifecycle is + * not naturally scoped to a function — long-running PTY sessions, agent + * lifecycles. The caller is responsible for calling `end()` / `fail()`; + * forgetting to end leaks memory in the pending map until the next + * `clearPending()`. + * + * Hot path is `withSpan`; this is the escape hatch. + */ +export function startSpan( + name: string, + options?: { kind?: string; attributes?: Record } +): ActiveSpan { + if (!activeSink) { + return noopSpan + } + const parent = contextStorage.getStore() + const traceId = parent?.traceId ?? genTraceId() + const spanId = genSpanId() + const startTimeUnixNano = nowUnixNano() + + const pending: PendingSpan = { + name, + traceId, + spanId, + parentSpanId: parent?.spanId, + kind: options?.kind ?? 'internal', + startTimeUnixNano, + attributes: new Map(Object.entries(options?.attributes ?? {})), + events: [], + exit: null, + ended: false + } + + const finalize = (exit: SpanExit): void => { + if (pending.ended) { + return + } + pending.ended = true + pending.exit = exit + const endTimeUnixNano = nowUnixNano() + const durationMs = Number(endTimeUnixNano - pending.startTimeUnixNano) / 1_000_000 + + const record: RedactableSpan = { + name: pending.name, + traceId: pending.traceId, + spanId: pending.spanId, + ...(pending.parentSpanId ? { parentSpanId: pending.parentSpanId } : {}), + kind: pending.kind, + startTimeUnixNano: String(pending.startTimeUnixNano), + endTimeUnixNano: String(endTimeUnixNano), + durationMs, + attributes: Object.fromEntries(pending.attributes), + events: pending.events, + exit + } + + const redacted = redactSpan(record, 'client') + // Wrap in a `type: 'effect-span'` envelope so the NDJSON file is + // compatible with Effect-style span output. Effect-oriented consumers + // (the LGTM stack, jq cookbooks) can read the file unchanged. + activeSink?.push({ type: 'effect-span', ...redacted }) + } + + return { + traceId, + spanId, + setAttribute(key: string, value: unknown) { + pending.attributes.set(key, value) + }, + addEvent(eventName: string, attributes?: Record) { + pending.events.push({ + name: eventName, + timeUnixNano: String(nowUnixNano()), + attributes: attributes ?? {} + }) + }, + fail(cause: string | Error) { + const causeStr = cause instanceof Error ? formatError(cause) : String(cause) + finalize({ _tag: 'Failure', cause: causeStr }) + }, + interrupt(cause?: string) { + finalize({ _tag: 'Interrupted', ...(cause ? { cause } : {}) }) + }, + end() { + finalize({ _tag: 'Success' }) + } + } +} + +/** Pretty-print an Error including stack, for the Failure cause field. The + * redactor handles the in-string secret stripping. */ +function formatError(err: Error): string { + const head = `${err.name}: ${err.message}` + return err.stack ? `${head}\n${err.stack}` : head +} + +// ── Test-only ──────────────────────────────────────────────────────────── + +export function _resetTracerForTests(): void { + activeSink = null + // No way to clear the AsyncLocalStorage without a fresh one; tests that + // assert on context should run inside their own `withSpan` block. +} diff --git a/src/main/updater.ts b/src/main/updater.ts index d7f4f1e38..7b4579fb8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -4,6 +4,7 @@ import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' import type { UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' +import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' import { beginMacUpdateDownload, @@ -556,7 +557,22 @@ function runBackgroundUpdateCheck( } export function checkForUpdates(): void { - runBackgroundUpdateCheck() + // Fire-and-forget the span so the public function signature stays + // synchronous (callers do not await this). The span ALWAYS records + // Success — it captures only the launch of the check, not its outcome. + // The actual check runs through autoUpdater event handlers; failure is + // surfaced via sendCheckFailureStatus on a separate code path. + // Dashboards: do not group on this span's outcome attribute — the + // success rate here reflects launch dispatch, not check success, and + // will read ~100% by construction. Instead, filter on + // `updater.outcome === 'launched'` to count check-launch dispatches; the + // attribute makes the always-success semantics explicit and queryable + // (so a dashboard tile can't accidentally treat this span's success rate + // as the actual update-check success rate). + void withUpdaterSpan({ stage: 'check' }, async (span) => { + span.setAttribute('updater.outcome', 'launched') + runBackgroundUpdateCheck() + }) } function enableIncludePrerelease(): void { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4c90fbbab..ecfeb2e52 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -462,6 +462,33 @@ export type StatsApi = { getSummary: () => Promise } +// Diagnostics — error-tracking-lane payload shapes that cross the IPC +// boundary. Mirror the runtime types in +// `src/main/observability/{index,bundle}.ts`. Kept here, not imported, +// because the preload api-types file is the source of truth for the +// renderer's view of the IPC surface. +export type DiagnosticsStatusPayload = { + readonly localFileEnabled: boolean + readonly otlpEnabled: boolean + readonly bundleEnabled: boolean + readonly otlpStatus: string + readonly traceFilePath: string + readonly traceFamilySize: number + readonly disabledReason?: + | 'do_not_track' + | 'orca_telemetry_disabled' + | 'orca_diagnostics_disabled' + | 'ci' +} +export type DiagnosticsBundlePayload = { + readonly bundleSubmissionId: string + readonly bytes: number + readonly spanCount: number +} +export type DiagnosticsUploadPayload = { + readonly ticketId: string +} + export type MemoryApi = { getSnapshot: () => Promise } @@ -769,12 +796,13 @@ export type PreloadApi = { } crashReports: { getLatestPending: () => Promise + getLatestReport: () => Promise dismiss: (args: { reportId: string }) => Promise + submit: (args: CrashReportSubmitArgs) => Promise copyLatestDiagnostics: (args?: { reportId?: string notes?: string }) => Promise<{ ok: true } | { ok: false; error: string }> - submit: (args: CrashReportSubmitArgs) => Promise } export: ExportApi gh: { @@ -1189,6 +1217,21 @@ export type PreloadApi = { /** Flip the persisted opt-in preference. Subject to a per-session * consent-mutation rate limit on the main side (≤5/session). */ telemetrySetOptIn: (optedIn: boolean) => Promise + /** Diagnostic-bundle / trace-folder controls. Surface for + * telemetry-error-tracking.md §User controls. The renderer triggers + * flows; main does the filesystem / network work and returns + * serializable metadata. Main retains collected upload payloads so the + * renderer can confirm without reading or substituting arbitrary bytes. */ + diagnostics: { + getStatus: () => Promise + openTraceFolder: () => Promise + clearTraces: () => Promise + collectBundle: (lookbackMinutes?: number) => Promise + openBundlePreview: (bundleSubmissionId: string) => Promise + discardBundlePreview: (bundleSubmissionId: string) => Promise + uploadBundle: (bundleSubmissionId: string) => Promise + deleteBundle: (ticketId: string) => Promise + } /** Read-only view of effective consent state, including the reason if * disabled (env var / user opt-out / CI / pending banner). Used by the * Privacy pane to render the correct "blocked by X" helper text — env diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f83dba66..8ccca0c8e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -145,6 +145,7 @@ import { import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' +import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' type NativeDropResolution = | { target: 'editor' } @@ -753,16 +754,12 @@ const api = { crashReports: { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), + getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + submit: (args: CrashReportSubmitArgs): Promise => + ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), - submit: (args: { - reportId?: string - notes?: string - submitAnonymously?: boolean - githubLogin: string | null - githubEmail: string | null - }) => ipcRenderer.invoke('crashReports:submit', args) + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) }, export: { @@ -1212,6 +1209,27 @@ const api = { telemetryGetConsentState: (): Promise => ipcRenderer.invoke('telemetry:getConsentState'), + // Why: diagnostics is the renderer-facing surface for the error-tracking + // lane (telemetry-error-tracking.md §User controls). All five channels + // are gated by main-side handlers that strictly type-narrow their inputs + // (renderer is untrusted by design); the bridges here are deliberately + // loose for the same reason the telemetry bridges are. + diagnostics: { + getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), + openTraceFolder: (): Promise => ipcRenderer.invoke('diagnostics:openTraceFolder'), + clearTraces: (): Promise => ipcRenderer.invoke('diagnostics:clearTraces'), + collectBundle: (lookbackMinutes?: number): Promise => + ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), + openBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), + discardBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), + uploadBundle: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), + deleteBundle: (ticketId: string): Promise => + ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) + }, + settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index ae034292e..c8bad2f97 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, Clipboard, Send } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -27,28 +27,34 @@ export function CrashReportDialog(): React.JSX.Element { const [loading, setLoading] = useState(false) const [submitting, setSubmitting] = useState(false) const [viewer, setViewer] = useState(null) + const deferredNotes = useDeferredValue(notes) const diagnosticText = useMemo( - () => (report ? formatCrashReportText(report, notes) : ''), - [notes, report] + // Why: formatting applies redaction and truncation over the full crash + // payload. Keep that preview update out of the textarea keystroke path. + () => (report ? formatCrashReportText(report, deferredNotes) : ''), + [deferredNotes, report] ) - const loadPendingReport = async (promptIfPresent: boolean): Promise => { + const loadCrashReport = async (promptIfPresent: boolean): Promise => { setLoading(true) try { - const pending = await window.api.crashReports.getLatestPending() - let displayedReport = pending - if (pending && promptIfPresent) { + const nextReport = promptIfPresent + ? await window.api.crashReports.getLatestPending() + : await window.api.crashReports.getLatestReport() + let displayedReport = nextReport + if (nextReport?.status === 'pending' && promptIfPresent) { try { // Why: startup crash prompts are one-shot. The open dialog keeps the - // report data locally if the user chooses to send immediately. - await window.api.crashReports.dismiss({ reportId: pending.id }) - displayedReport = { ...pending, status: 'dismissed' as const } + // report data locally if the user chooses to send immediately, while + // Help > Report Crash can still reopen dismissed unsent reports. + await window.api.crashReports.dismiss({ reportId: nextReport.id }) + displayedReport = { ...nextReport, status: 'dismissed' as const } } catch (error) { console.error('Failed to dismiss crash report after startup prompt:', error) } } setReport(displayedReport) - if (pending && promptIfPresent) { + if (nextReport && promptIfPresent) { setOpen(true) } } catch (error) { @@ -63,12 +69,12 @@ export function CrashReportDialog(): React.JSX.Element { return } promptedThisLaunch.current = true - void loadPendingReport(true) + void loadCrashReport(true) }, []) useEffect(() => { return window.api.ui.onOpenCrashReport(() => { - void loadPendingReport(false).then(() => setOpen(true)) + void loadCrashReport(false).then(() => setOpen(true)) }) }, []) @@ -201,7 +207,7 @@ export function CrashReportDialog(): React.JSX.Element { ) : (
- {loading ? 'Checking for crash reports...' : 'No pending crash report is available.'} + {loading ? 'Checking for crash reports...' : 'No crash report is available.'}
)} diff --git a/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx b/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx new file mode 100644 index 000000000..b2de2a87f --- /dev/null +++ b/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx @@ -0,0 +1,142 @@ +import { Check, Clipboard, Eye, FileText, Loader2, Trash2, UploadCloud, X } from 'lucide-react' +import type { + DiagnosticsBundlePayload, + DiagnosticsStatusPayload +} from '../../../../preload/api-types' +import { Button } from '../ui/button' + +export function PrivacyDiagnosticBundleControls({ + status, + bundle, + previewOpened, + ticketId, + collecting, + openingPreview, + uploading, + discarding, + copyingTicket, + deletingTicket, + onCollect, + onOpenPreview, + onUpload, + onDiscard, + onCopyTicket, + onDeleteUploadedBundle, + onDismissTicket +}: { + readonly status: DiagnosticsStatusPayload | null + readonly bundle: DiagnosticsBundlePayload | null + readonly previewOpened: boolean + readonly ticketId: string | null + readonly collecting: boolean + readonly openingPreview: boolean + readonly uploading: boolean + readonly discarding: boolean + readonly copyingTicket: boolean + readonly deletingTicket: boolean + readonly onCollect: () => Promise + readonly onOpenPreview: () => Promise + readonly onUpload: () => Promise + readonly onDiscard: () => Promise + readonly onCopyTicket: () => Promise + readonly onDeleteUploadedBundle: () => Promise + readonly onDismissTicket: () => void +}): React.JSX.Element { + if (ticketId) { + return ( + <> + + + + + ) + } + + if (bundle) { + return ( + <> + + + + + ) + } + + return ( + + ) +} + +export function getDiagnosticBundleDescription({ + bundle, + previewOpened, + ticketId +}: { + readonly bundle: DiagnosticsBundlePayload | null + readonly previewOpened: boolean + readonly ticketId: string | null +}): string { + if (ticketId) { + return `Uploaded ticket ${ticketId}.` + } + if (bundle) { + const previewState = previewOpened ? 'Ready to upload.' : 'Open the preview before uploading.' + return `${bundle.spanCount} span(s), ${formatBytes(bundle.bytes)}. ${previewState}` + } + return 'Creates a redacted NDJSON preview for support upload.' +} + +function ActionIcon({ busy, icon }: { readonly busy: boolean; readonly icon: React.ReactNode }) { + return busy ? : icon +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B` + } + if (bytes < 1024 * 1024) { + return `${Math.round(bytes / 1024)} KB` + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx new file mode 100644 index 000000000..f2a68921c --- /dev/null +++ b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx @@ -0,0 +1,361 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { FileText, Folder, Globe, Trash2 } from 'lucide-react' +import type { + DiagnosticsBundlePayload, + DiagnosticsStatusPayload +} from '../../../../preload/api-types' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Separator } from '../ui/separator' +import { + getDiagnosticBundleDescription, + PrivacyDiagnosticBundleControls +} from './PrivacyDiagnosticBundleControls' + +export function PrivacyDiagnosticsSection(): React.JSX.Element { + const [status, setStatus] = useState(null) + const [bundle, setBundle] = useState(null) + const [previewOpened, setPreviewOpened] = useState(false) + const [ticketId, setTicketId] = useState(null) + const [collecting, setCollecting] = useState(false) + const [openingPreview, setOpeningPreview] = useState(false) + const [uploading, setUploading] = useState(false) + const [discarding, setDiscarding] = useState(false) + const [copyingTicket, setCopyingTicket] = useState(false) + const [deletingTicket, setDeletingTicket] = useState(false) + const mountedRef = useRef(true) + const activeBundleSubmissionIdRef = useRef(null) + + const refreshStatus = useCallback(async (): Promise => { + try { + const next = await window.api.diagnostics.getStatus() + if (mountedRef.current) { + setStatus(next) + } + } catch { + /* swallow — pane shows N/A while the IPC is unavailable */ + } + }, []) + + useEffect(() => { + void refreshStatus() + }, [refreshStatus]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (activeBundleSubmissionIdRef.current) { + void window.api.diagnostics.discardBundlePreview(activeBundleSubmissionIdRef.current) + } + } + }, []) + + useEffect(() => { + activeBundleSubmissionIdRef.current = bundle?.bundleSubmissionId ?? null + }, [bundle]) + + const handleOpenFolder = useCallback(async (): Promise => { + try { + await window.api.diagnostics.openTraceFolder() + } catch { + toast.error('Could not open trace folder') + } + }, []) + + const handleClear = useCallback(async (): Promise => { + try { + await window.api.diagnostics.clearTraces() + if (!mountedRef.current) { + return + } + activeBundleSubmissionIdRef.current = null + setBundle(null) + setPreviewOpened(false) + setTicketId(null) + await refreshStatus() + toast.success('Local trace files cleared') + } catch { + if (mountedRef.current) { + toast.error('Could not clear trace files') + } + } + }, [refreshStatus]) + + const handleCollectBundle = useCallback(async (): Promise => { + setCollecting(true) + try { + const nextBundle = await window.api.diagnostics.collectBundle() + if (!mountedRef.current) { + await window.api.diagnostics.discardBundlePreview(nextBundle.bundleSubmissionId) + return + } + setBundle(nextBundle) + setPreviewOpened(false) + setTicketId(null) + toast.success('Diagnostic bundle preview created') + } catch (error) { + if (mountedRef.current) { + toast.error(getDiagnosticsErrorMessage(error, 'Could not create diagnostic bundle')) + } + } finally { + if (mountedRef.current) { + setCollecting(false) + } + } + }, []) + + const handleOpenPreview = useCallback(async (): Promise => { + if (!bundle) { + return + } + setOpeningPreview(true) + try { + await window.api.diagnostics.openBundlePreview(bundle.bundleSubmissionId) + if (!mountedRef.current) { + return + } + setPreviewOpened(true) + toast.success('Diagnostic bundle preview opened') + } catch (error) { + if (mountedRef.current) { + toast.error(getDiagnosticsErrorMessage(error, 'Could not open diagnostic bundle preview')) + } + } finally { + if (mountedRef.current) { + setOpeningPreview(false) + } + } + }, [bundle]) + + const handleUploadBundle = useCallback(async (): Promise => { + if (!bundle) { + return + } + setUploading(true) + try { + const upload = await window.api.diagnostics.uploadBundle(bundle.bundleSubmissionId) + if (!mountedRef.current) { + return + } + activeBundleSubmissionIdRef.current = null + setBundle(null) + setPreviewOpened(false) + setTicketId(upload.ticketId) + toast.success('Diagnostic bundle uploaded') + } catch (error) { + if (mountedRef.current) { + toast.error(getDiagnosticsErrorMessage(error, 'Could not upload diagnostic bundle')) + } + } finally { + if (mountedRef.current) { + setUploading(false) + } + } + }, [bundle]) + + const handleDiscardBundle = useCallback(async (): Promise => { + if (!bundle) { + return + } + setDiscarding(true) + try { + await window.api.diagnostics.discardBundlePreview(bundle.bundleSubmissionId) + if (!mountedRef.current) { + return + } + activeBundleSubmissionIdRef.current = null + setBundle(null) + setPreviewOpened(false) + toast.success('Diagnostic bundle preview discarded') + } catch (error) { + if (mountedRef.current) { + toast.error( + getDiagnosticsErrorMessage(error, 'Could not discard diagnostic bundle preview') + ) + } + } finally { + if (mountedRef.current) { + setDiscarding(false) + } + } + }, [bundle]) + + const handleCopyTicket = useCallback(async (): Promise => { + if (!ticketId) { + return + } + setCopyingTicket(true) + try { + await window.api.ui.writeClipboardText(ticketId) + if (!mountedRef.current) { + return + } + toast.success('Diagnostic ticket copied') + } catch { + if (mountedRef.current) { + toast.error('Could not copy diagnostic ticket') + } + } finally { + if (mountedRef.current) { + setCopyingTicket(false) + } + } + }, [ticketId]) + + const handleDeleteUploadedBundle = useCallback(async (): Promise => { + if (!ticketId) { + return + } + setDeletingTicket(true) + try { + await window.api.diagnostics.deleteBundle(ticketId) + if (!mountedRef.current) { + return + } + setTicketId(null) + toast.success('Uploaded diagnostic bundle deleted') + } catch (error) { + if (mountedRef.current) { + toast.error(getDiagnosticsErrorMessage(error, 'Could not delete diagnostic bundle')) + } + } finally { + if (mountedRef.current) { + setDeletingTicket(false) + } + } + }, [ticketId]) + + return ( + <> + {status?.disabledReason ? ( + + ) : null} + +
} + title="Diagnostic bundle" + description={getDiagnosticBundleDescription({ bundle, previewOpened, ticketId })} + > + setTicketId(null)} + /> +
+ +
} + title="Open trace folder" + description={`Reveals ${status?.traceFilePath || 'the trace folder'} in your file manager.`} + > + +
+ +
} + title="Clear local traces" + description="Deletes every rotated trace file on this machine." + > + +
+ +
} + title="OTLP export" + description={ + status?.otlpStatus ?? + 'Set ORCA_OTLP_TRACES_URL to point Orca at your own OpenTelemetry collector.' + } + > + + {status?.otlpEnabled ? 'Enabled' : 'Disabled'} + +
+ + ) +} + +function getDiagnosticsErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function DiagnosticsDisabledStateNote({ + reason +}: { + reason: NonNullable +}): React.JSX.Element { + const message = + reason === 'do_not_track' + ? 'DO_NOT_TRACK=1 is set — network-bound diagnostics are disabled. The local trace file is still active.' + : reason === 'orca_telemetry_disabled' + ? 'ORCA_TELEMETRY_DISABLED=1 is set — network-bound diagnostics are disabled. The local trace file is still active.' + : reason === 'orca_diagnostics_disabled' + ? 'ORCA_DIAGNOSTICS_DISABLED=1 is set — every diagnostics surface is off, including local trace writes.' + : reason === 'ci' + ? 'Running in CI — diagnostics are off.' + : 'Diagnostics are disabled by an environment variable.' + + return ( +
+ {message} +
+ ) +} + +function Section({ + icon, + title, + description, + children +}: { + icon: React.ReactNode + title: string + description: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+
+
{icon}
+
+ +

{description}

+
+
+
+ {children} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/PrivacyPane.tsx b/src/renderer/src/components/settings/PrivacyPane.tsx index 56a126349..4e2711db9 100644 --- a/src/renderer/src/components/settings/PrivacyPane.tsx +++ b/src/renderer/src/components/settings/PrivacyPane.tsx @@ -1,28 +1,3 @@ -// Privacy pane — the permanent surface for the telemetry opt-in toggle. -// Two responsibilities: -// 1. Flip `optedIn` when the user toggles the switch. All renderer- -// initiated opt-in flips route through `window.api.telemetrySetOptIn`; -// main derives the `via` tag and fires `telemetry_opted_in` / -// `telemetry_opted_out`. -// 2. Render the correct "blocked by X" helper text when an environment -// variable (DO_NOT_TRACK, ORCA_TELEMETRY_DISABLED) or CI presence -// disables transmission at runtime. Env vars are main-side process -// state, so the pane reads effective consent via -// `telemetry:getConsentState`. -// -// The toggle is NOT gated while the existing-user notice is pending — the -// whole point of the pane is to let the user flip consent, and disabling -// it would create a chicken-and-egg where the notice pushes the user at -// Settings and the pane points back at the notice. Flipping the toggle -// moves `optedIn` off `null`, which un-mounts `TelemetryFirstLaunchSurface` -// on its own — so "toggle in Settings" IS a way to dismiss the notice. -// -// Structural note: the pane mimics NotificationsPane / DeveloperPermissionsPane -// — a small toggle-heavy surface with inline helper text. A single-row -// "toggle + subtext" layout keeps this file UI-only; `captureException` / -// `$identify` patterns were considered and rejected in the telemetry -// Decision Record and are intentionally not used here. - import { useEffect, useState } from 'react' import { ShieldCheck } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' @@ -30,20 +5,17 @@ import type { TelemetryConsentState } from '../../../../shared/telemetry-consent import { Label } from '../ui/label' import { PRIVACY_URL, getConsentState, setOptIn as telemetrySetOptIn } from '../../lib/telemetry' import { useAppStore } from '../../store' +import { PrivacyDiagnosticsSection } from './PrivacyDiagnosticsSection' -// Pure helpers, exported for direct test coverage. The component's -// useEffect cannot be exercised in the node-env vitest harness (no DOM, -// no act()), so we keep the blocked-state decision and the env-var name -// mapping as plain functions that tests can call without rendering. export type EnvBlockedReason = 'do_not_track' | 'orca_disabled' | 'ci' - -// Reasons the toggle is read-only. Only env/CI overrides block the toggle: -// they persist until the operator unsets the variable and relaunches, so -// the user's flip cannot take effect until then. The existing-user notice -// is NOT a blocked reason — flipping the toggle is one of the valid ways -// to resolve it. export type BlockedReason = { kind: 'env'; reason: EnvBlockedReason } +type PrivacyPaneProps = { + settings: GlobalSettings +} + +const PRIVACY_PANE_BLOCKED_HELPER_ID = 'privacy-pane-blocked-helper' + export function isEnvBlocked(consent: TelemetryConsentState | null): consent is { effective: 'disabled' reason: EnvBlockedReason @@ -66,13 +38,6 @@ export function envVarNameForReason(reason: EnvBlockedReason): string { return 'CI' } -// Compute the reason the toggle should be inert, if any. Only env-var / -// CI overrides block the toggle — they persist until the operator unsets -// the variable and relaunches. The existing-user notice does not gate the -// toggle; flipping it is a valid way to resolve the notice. -// -// Exported for test coverage. The component renders helper text by -// pattern-matching the returned shape. export function computeBlockedReason(consent: TelemetryConsentState | null): BlockedReason | null { if (isEnvBlocked(consent)) { return { kind: 'env', reason: consent.reason } @@ -80,39 +45,11 @@ export function computeBlockedReason(consent: TelemetryConsentState | null): Blo return null } -// Stable id wired from the switch's `aria-describedby` to the blocked-state -// helper text below it. Without this, screen-reader users hear "switch, -// disabled" with no explanation of why the toggle is inert. -const PRIVACY_PANE_BLOCKED_HELPER_ID = 'privacy-pane-blocked-helper' - -type PrivacyPaneProps = { - settings: GlobalSettings -} - export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { const [consent, setConsent] = useState(null) - // Double-click guard. Main's `setOptIn` has no idempotence check; without - // this guard a fast double-click would fire two - // `telemetry_opted_{in,out}` events for one user intent. The handler - // derives `nextOptedIn` from `toggleChecked`, which is computed from - // `settings.telemetry?.optedIn`. Main's `telemetry:setOptIn` handler - // intentionally does NOT broadcast `settings:changed` on telemetry writes - // (telemetry writes stay silent at the settings-event layer so unrelated - // subscribers never re-render on a telemetry flip), so after - // `telemetrySetOptIn` resolves we explicitly call - // `fetchSettings()` to sync the renderer store. This ensures the next - // click sees the updated `toggleChecked` and does not re-fire the same - // opt-{in,out} intent against the user's already-persisted choice; the - // `inFlight` flag guards only the window between the click and that - // refetch completing. const [inFlight, setInFlight] = useState(false) const fetchSettings = useAppStore((s) => s.fetchSettings) - // Pull the effective consent state on mount and again when the user - // interacts with the toggle — env-var status does not change within a - // session, but a toggle flip changes the `user_opt_out` branch so the - // helper text needs to refresh. Polling is not needed; the pane is - // self-contained and the env-var branch is stable for the session. useEffect(() => { let stale = false void getConsentState().then((state) => { @@ -126,44 +63,15 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { }, [settings.telemetry?.optedIn]) const blocked = computeBlockedReason(consent) - - // Display the user's stored preference, not the effective state. An env - // var blocks transmission without overwriting the persisted preference - // (consent.ts:6-8 is explicit about this), so the toggle should still - // reflect "what the user chose" and the helper text explains why it's - // inactive. `optedIn === null` (existing user pre-banner) reads as off - // because no events are transmitting. const toggleChecked = settings.telemetry?.optedIn === true const handleToggle = async (): Promise => { if (blocked || inFlight) { - // Belt-and-suspenders: the button is disabled when env/CI overrides - // block transmission, but the click handler is the single source of - // truth for "did the user actually flip consent?" If a CSS or a11y - // bug ever makes the disabled button clickable, we must not route a - // flip through `telemetrySetOptIn` against an env-blocked state. The - // `inFlight` arm additionally suppresses duplicate sends while a - // previous flip is still round-tripping through IPC. return } setInFlight(true) - const nextOptedIn = !toggleChecked - // Route through `telemetrySetOptIn` (NOT `settings:set` alone). - // `settings:set` persists the flip but skips `telemetry_opted_in/out` - // emission and the PostHog SDK's in-memory optIn / optOut flip. Main's - // `setOptIn` writes the preference, emits the event with - // `via='settings'`, and flips the SDK flag — all in the right order - // (opt-out event BEFORE posthog.optOut). try { - await telemetrySetOptIn(nextOptedIn) - // Why: main's telemetry:setOptIn handler intentionally does NOT - // broadcast `settings:changed` — the invariant is that telemetry - // writes stay silent at the settings-event layer. But the Privacy - // pane derives `toggleChecked` from `settings.telemetry?.optedIn`, - // so without an explicit refresh the toggle would stay stuck on its - // pre-flip value. Refetch here so the next click sees the updated - // state and does not re-fire the same opt-{in,out} event against - // the user's already-persisted choice. + await telemetrySetOptIn(!toggleChecked) await fetchSettings() } finally { setInFlight(false) @@ -171,7 +79,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element { } return ( -
+
@@ -180,8 +88,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {

Help us figure out what to build next. Orca sends anonymous counts of which features you - use and where things break — no file contents, prompts, terminal output, branch names, - or anything that identifies you.{' '} + use and where things break.{' '}

) } -// Per-reason copy for the env/CI blocked states. The pane is accessible -// only once the app boots past CI detection, so `ci` is rare on a -// desktop install — but it's included for symmetry with the resolver. function BlockedHelper({ blocked, id }: { blocked: BlockedReason; id: string }): React.JSX.Element { return (
- + {blocked.reason === 'ci' ? ( +

Telemetry is disabled because a CI environment variable is set. Unset it and restart.

+ ) : ( +

+ Telemetry is disabled by the{' '} + + {envVarNameForReason(blocked.reason)} + {' '} + environment variable. Unset it and restart to re-enable. +

+ )}
) } - -function EnvHelperBody({ reason }: { reason: EnvBlockedReason }): React.JSX.Element { - if (reason === 'ci') { - return ( -

- Telemetry is disabled because a CI environment variable is set. Unset it and restart to - re-enable. -

- ) - } - const varName = envVarNameForReason(reason) - return ( -

- Telemetry is disabled by the{' '} - {varName}{' '} - environment variable. Unset it and restart to re-enable. -

- ) -} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 04f5f97f4..5517f7665 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -109,6 +109,7 @@ type SettingsNavTarget = | 'shortcuts' | 'stats' | 'ssh' + | 'privacy' | 'experimental' | 'agents' | 'orchestration' diff --git a/src/renderer/src/components/settings/privacy-search.ts b/src/renderer/src/components/settings/privacy-search.ts index fa8eb244d..e583f534d 100644 --- a/src/renderer/src/components/settings/privacy-search.ts +++ b/src/renderer/src/components/settings/privacy-search.ts @@ -7,7 +7,7 @@ import type { SettingsSearchEntry } from './settings-search' export const PRIVACY_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Privacy & Telemetry', - description: 'Anonymous product usage data and telemetry controls.', + description: 'Anonymous product usage data, diagnostics, and telemetry controls.', keywords: [ 'privacy', 'telemetry', @@ -25,6 +25,11 @@ export const PRIVACY_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ description: 'Help improve Orca by sending anonymous feature-usage events.', keywords: ['telemetry', 'usage', 'anonymous', 'opt in', 'opt out', 'share'] }, + { + title: 'Diagnostics', + description: 'Trace files and OTLP export controls.', + keywords: ['diagnostics', 'trace', 'logs', 'otlp', 'opentelemetry', 'support'] + }, { title: 'Telemetry environment variables', description: 'Environment variables that disable telemetry transmission.', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 868e09d54..34cc19ce5 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -109,9 +109,34 @@ function createWebPreloadApi(): Partial { ui: createWebUiApi(), crashReports: { getLatestPending: () => Promise.resolve(null), + getLatestReport: () => Promise.resolve(null), dismiss: () => Promise.resolve(null), - copyLatestDiagnostics: () => Promise.resolve({ ok: false, error: 'Unavailable on web.' }), - submit: () => Promise.resolve({ ok: false, status: null, error: 'Unavailable on web.' }) + submit: () => + Promise.resolve({ + ok: false, + status: null, + error: 'Unavailable on web.' + }), + copyLatestDiagnostics: () => Promise.resolve({ ok: false, error: 'Unavailable on web.' }) + }, + diagnostics: { + getStatus: () => + Promise.resolve({ + localFileEnabled: false, + otlpEnabled: false, + bundleEnabled: false, + otlpStatus: 'Unavailable on web', + traceFilePath: '', + traceFamilySize: 0 + }), + openTraceFolder: () => Promise.resolve(), + clearTraces: () => Promise.resolve(), + collectBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')), + openBundlePreview: () => + Promise.reject(new Error('Diagnostic bundles are unavailable on web.')), + discardBundlePreview: () => Promise.resolve(), + uploadBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')), + deleteBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')) }, session: { get: () => Promise.resolve(getStoredWorkspaceSession()), diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 2baadf796..cc497807b 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -256,7 +256,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial [ 'exec', // Why: commit-message generation needs text only, not a persisted agent - // session or workspace writes. Match T3 Code's safe git-text mode. + // session or workspace writes. Match the safe git-text mode used by + // local-first coding agents. '--ephemeral', '--skip-git-repo-check', '-s', diff --git a/src/shared/crash-reporting.test.ts b/src/shared/crash-reporting.test.ts index 821fa14ae..da150bc6a 100644 --- a/src/shared/crash-reporting.test.ts +++ b/src/shared/crash-reporting.test.ts @@ -100,4 +100,31 @@ describe('crash-reporting shared helpers', () => { expect(text).not.toContain('Route:') expect(text).not.toContain('URL:') }) + + it('caps formatted reports to the crash endpoint limit', () => { + const report: CrashReportRecord = { + id: 'crash-oversized', + createdAt: '2026-05-16T01:00:00.000Z', + status: 'pending', + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: 'darwin', + osRelease: '25.0.0', + arch: 'arm64', + electronVersion: '41.0.0', + chromeVersion: '141.0.0', + details: Object.fromEntries( + Array.from({ length: 400 }, (_, index) => [`detail_${index}`, 'x'.repeat(240)]) + ), + breadcrumbs: [] + } + + const text = formatCrashReportText(report) + + expect(text.length).toBeLessThanOrEqual(64_000) + expect(text).toContain('[Crash report truncated to fit feedback endpoint limits.]') + }) }) diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index be4571374..3c5c41238 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -57,6 +57,9 @@ export type CrashReportSubmitResult = const MAX_STRING_DETAIL_LENGTH = 240 const MAX_BREADCRUMB_NAME_LENGTH = 80 const MAX_BREADCRUMBS = 30 +const MAX_FORMATTED_REPORT_LENGTH = 64_000 +const FORMATTED_REPORT_TRUNCATION_SUFFIX = + '\n\n[Crash report truncated to fit feedback endpoint limits.]' const SECRET_PATTERNS = [ /\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g, /\b(sk-[A-Za-z0-9_-]{20,})\b/g, @@ -185,5 +188,15 @@ export function formatCrashReportText(report: CrashReportRecord, notes?: string) lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes)) } - return lines.join('\n') + return truncateFormattedCrashReport(lines.join('\n')) +} + +function truncateFormattedCrashReport(text: string): string { + if (text.length <= MAX_FORMATTED_REPORT_LENGTH) { + return text + } + // Why: the feedback endpoint accepts larger crash bodies and handles + // Slack-specific attachments server-side. Keep local reports below that API cap. + const budget = MAX_FORMATTED_REPORT_LENGTH - FORMATTED_REPORT_TRUNCATION_SUFFIX.length + return `${text.slice(0, Math.max(0, budget)).trimEnd()}${FORMATTED_REPORT_TRUNCATION_SUFFIX}` } diff --git a/src/types/build-constants.d.ts b/src/types/build-constants.d.ts index ddbe3915e..bfc8ac09b 100644 --- a/src/types/build-constants.d.ts +++ b/src/types/build-constants.d.ts @@ -11,3 +11,12 @@ declare const ORCA_BUILD_IDENTITY: 'stable' | 'rc' | null declare const ORCA_POSTHOG_WRITE_KEY: string | null + +// Diagnostic-bundle upload endpoint for Mode 3 (telemetry-error-tracking.md +// §Endpoint contract). Substituted by CI; `null` in contributor builds, at +// which point the upload IPC handler returns "endpoint not configured" +// rather than POSTing to a placeholder. The dev escape hatch is the +// `ORCA_DIAGNOSTICS_TOKEN_URL` env var, which env wins so a developer can +// point a packaged build at a staging server without re-running the +// release pipeline. +declare const ORCA_DIAGNOSTICS_TOKEN_URL: string | null