Add local diagnostics error tracking (#2351)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-21 11:15:01 -07:00 committed by GitHub
parent a7d77aabaa
commit a140313d4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 5721 additions and 337 deletions

View File

@ -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

View File

@ -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 <subcommand>
// <args…>` 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 }
}
)
}
/**

View File

@ -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() */
})

View File

@ -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()
})
})

View File

@ -8,28 +8,44 @@ import { submitFeedback } from './feedback'
import type { CrashReportStore } from '../crash-reporting/crash-report-store'
const inFlightSubmissions = new Set<string>()
const uploadedReportIds = new Set<string>()
const submittedReportIds = new Set<string>()
async function getLatestPendingReport(
store: CrashReportStore
): Promise<Awaited<ReturnType<CrashReportStore['getLatestPending']>>> {
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<Awaited<ReturnType<CrashReportStore['getLatestPending']>>> {
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

View File

@ -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<string, (_event: unknown, ...args: unknown[]) => 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<typeof NodeFs>('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<string, infer V> ? V : never
]
handlers.set(channel, handler)
}
}
function makeBundle(overrides: Partial<CollectedBundle> = {}): 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'
})
})
})

367
src/main/ipc/diagnostics.ts Normal file
View File

@ -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<CollectedBundle, 'payload'>
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<string, PendingBundle>()
// 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<void> {
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<void> => {
// 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<UploadBundleResult> => {
// 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<void> => {
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 })
})
}

View File

@ -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<string, (_event: unknown, args?: unknown) => 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<string, unknown> {
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<typeof submitFeedback>[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
})
})
})

View File

@ -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<Resp
})
}
export async function submitFeedback(args: FeedbackSubmitArgs): Promise<FeedbackSubmitResult> {
export async function submitFeedback(
args: InternalFeedbackSubmitArgs
): Promise<FeedbackSubmitResult> {
const body = buildSubmitBody(args)
try {
const res = await postFeedback(FEEDBACK_API_URL, body)
@ -103,5 +108,9 @@ export async function submitFeedback(args: FeedbackSubmitArgs): Promise<Feedback
export function registerFeedbackHandlers(): void {
ipcMain.removeHandler('feedback:submit')
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => 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' })
)
}

View File

@ -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()

View File

@ -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)

View File

@ -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<CreateWorktreeResult> => {
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 12.
// 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
}
)

View File

@ -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 '<path>'`, `from "<path>"`, dynamic `import('<path>')` and
// `require('<path>')`. The `<path>` 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([])
})
})

View File

@ -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<string, unknown> = {}): Record<string, unknown> {
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<string>()
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<void>((resolve) => {
if (!server) {
resolve()
return
}
server.close(() => {
server = null
resolve()
})
})
)
function listen(handler: RequestListener): Promise<string> {
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}`])
})
})

View File

@ -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 `<upload_url>` with `Authorization: Bearer <token>` 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<string> {
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
}

View File

@ -0,0 +1 @@
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 // 10 MB cap enforced before upload

View File

@ -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<UploadBundleResult> {
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<void> {
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()
}

View File

@ -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<unknown> {
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<string, string>
readonly timeoutMs: number
}): Promise<unknown> {
return postRaw(url, body, { ...headers, accept: 'application/json' }, timeoutMs)
}
function postRaw(
url: string,
body: string,
headers: Record<string, string>,
timeoutMs: number
): Promise<unknown> {
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()
})
}

View File

@ -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<string, unknown>
if (r.type === 'effect-span') {
const { type: _t, ...spanFields } = r
void _t
exporter.exportSpan(spanFields as Parameters<OtlpExporter['exportSpan']>[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<void> {
// 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<UploadBundleResult> {
return _uploadBundle(opts)
}
export async function deleteDiagnosticBundle(opts: DeleteBundleOptions): Promise<void> {
return _deleteBundle(opts)
}

View File

@ -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<T>(meta: GitSpanArgs, fn: () => Promise<T>): Promise<T> {
return withSpan(
'git.exec',
async (span) => {
span.setAttribute('git.subcommand', meta.args[0] ?? '<none>')
// 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<T>(meta: IpcSpanArgs, fn: () => Promise<T> | T): Promise<T> {
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.<stage>` span. */
export async function withWorktreeSpan<T>(
meta: WorktreeSpanArgs,
fn: () => Promise<T>
): Promise<T> {
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.<stage>` 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<T>(meta: PtySpanArgs, fn: () => Promise<T> | T): Promise<T> {
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<T>(meta: AgentSpanArgs, fn: () => Promise<T> | T): Promise<T> {
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<T>(
meta: ExternalEditorSpanArgs,
fn: () => Promise<T> | T
): Promise<T> {
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<T>(
meta: UpdaterSpanArgs,
fn: (span: ActiveSpan) => Promise<T> | T
): Promise<T> {
return withSpan(
`updater.${meta.stage}`,
async (span) => {
span.setAttribute('updater.stage', meta.stage)
return await fn(span)
},
{ attributes: { kind: 'updater' } }
)
}

View File

@ -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<string, unknown> = { 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()
})
})

View File

@ -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 */
}
}
}
}

View File

@ -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<void>((resolve) => {
if (!server) {
resolve()
return
}
server.close(() => {
server = null
resolve()
})
})
)
function listen(handler: RequestListener): Promise<string> {
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> = {}): 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<typeof encodeOtlpPayload>
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' }
})
})
})

View File

@ -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<void>
/** 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<void> | 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<void> {
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<void> {
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<void> {
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<string, unknown>): 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<string, unknown>)
}
}
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<typeof eventToOtlp>[]
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<string, unknown>),
events: s.events.map(eventToOtlp),
...(status ? { status } : {})
}
})
}
]
}
]
}
}
function postJson(url: string, body: unknown, timeoutMs: number): Promise<void> {
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
}

View File

@ -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 "<secret>" 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<string, unknown>
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<string, unknown> } }
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<string, unknown>
}
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<string, string>
>
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<string, unknown> = {}
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>): RedactableSpan {
return {
name: 'test.span',
traceId: '00000000000000000000000000000001',
spanId: '0000000000000001',
kind: 'internal',
startTimeUnixNano: '1000',
endTimeUnixNano: '2000',
durationMs: 1.0,
attributes: {},
events: [],
exit: { _tag: 'Success' },
...overrides
}
}

View File

@ -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 14 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 <jwt>` / `Authorization: Token <pat>` 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://<token>@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 <jwt>` 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 14 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 14;
* 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<object> = 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<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
// 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<Record<string, unknown>>,
mode: RedactorMode = 'client'
): Record<string, unknown> {
const out: Record<string, unknown> = {}
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<Record<string, unknown>>
}
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<Record<string, unknown>>
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 14 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
}

View File

@ -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<string, unknown> }
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<string, string> }[]
}
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<string, unknown> }
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)
})
})

View File

@ -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<string, unknown>): 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<string, unknown>
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<SpanContext>()
// 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<T>(
name: string,
fn: (span: ActiveSpan) => Promise<T> | T,
options?: { kind?: string; attributes?: Record<string, unknown> }
): Promise<T> {
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<string, unknown> }
): 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<string, unknown>) {
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.
}

View File

@ -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 {

View File

@ -462,6 +462,33 @@ export type StatsApi = {
getSummary: () => Promise<StatsSummary>
}
// 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<MemorySnapshot>
}
@ -769,12 +796,13 @@ export type PreloadApi = {
}
crashReports: {
getLatestPending: () => Promise<CrashReportRecord | null>
getLatestReport: () => Promise<CrashReportRecord | null>
dismiss: (args: { reportId: string }) => Promise<CrashReportRecord | null>
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
copyLatestDiagnostics: (args?: {
reportId?: string
notes?: string
}) => Promise<{ ok: true } | { ok: false; error: string }>
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
}
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<void>
/** 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<DiagnosticsStatusPayload>
openTraceFolder: () => Promise<void>
clearTraces: () => Promise<void>
collectBundle: (lookbackMinutes?: number) => Promise<DiagnosticsBundlePayload>
openBundlePreview: (bundleSubmissionId: string) => Promise<void>
discardBundlePreview: (bundleSubmissionId: string) => Promise<void>
uploadBundle: (bundleSubmissionId: string) => Promise<DiagnosticsUploadPayload>
deleteBundle: (ticketId: string) => Promise<void>
}
/** 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

View File

@ -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<CrashReportSubmitResult> =>
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<TelemetryConsentState> =>
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<unknown> => ipcRenderer.invoke('diagnostics:getStatus'),
openTraceFolder: (): Promise<void> => ipcRenderer.invoke('diagnostics:openTraceFolder'),
clearTraces: (): Promise<void> => ipcRenderer.invoke('diagnostics:clearTraces'),
collectBundle: (lookbackMinutes?: number): Promise<unknown> =>
ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes),
openBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId),
discardBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId),
uploadBundle: (bundleSubmissionId: string): Promise<unknown> =>
ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId),
deleteBundle: (ticketId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId)
},
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),

View File

@ -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<GitHubViewer | null>(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<void> => {
const loadCrashReport = async (promptIfPresent: boolean): Promise<void> => {
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 {
</div>
) : (
<div className="rounded-md border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
{loading ? 'Checking for crash reports...' : 'No pending crash report is available.'}
{loading ? 'Checking for crash reports...' : 'No crash report is available.'}
</div>
)}

View File

@ -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<void>
readonly onOpenPreview: () => Promise<void>
readonly onUpload: () => Promise<void>
readonly onDiscard: () => Promise<void>
readonly onCopyTicket: () => Promise<void>
readonly onDeleteUploadedBundle: () => Promise<void>
readonly onDismissTicket: () => void
}): React.JSX.Element {
if (ticketId) {
return (
<>
<Button
variant="outline"
size="sm"
disabled={copyingTicket}
onClick={() => void onCopyTicket()}
>
<ActionIcon busy={copyingTicket} icon={<Clipboard className="size-3.5" />} />
Copy ticket
</Button>
<Button
variant="destructive"
size="sm"
disabled={deletingTicket}
onClick={() => void onDeleteUploadedBundle()}
>
<ActionIcon busy={deletingTicket} icon={<Trash2 className="size-3.5" />} />
Delete bundle
</Button>
<Button variant="ghost" size="sm" disabled={deletingTicket} onClick={onDismissTicket}>
<Check className="size-3.5" />
Done
</Button>
</>
)
}
if (bundle) {
return (
<>
<Button
variant="outline"
size="sm"
disabled={openingPreview}
onClick={() => void onOpenPreview()}
>
<ActionIcon busy={openingPreview} icon={<Eye className="size-3.5" />} />
Open preview
</Button>
<Button size="sm" disabled={!previewOpened || uploading} onClick={() => void onUpload()}>
<ActionIcon busy={uploading} icon={<UploadCloud className="size-3.5" />} />
Upload
</Button>
<Button variant="ghost" size="sm" disabled={discarding} onClick={() => void onDiscard()}>
<ActionIcon busy={discarding} icon={<X className="size-3.5" />} />
Discard
</Button>
</>
)
}
return (
<Button
variant="outline"
size="sm"
disabled={!status?.bundleEnabled || collecting}
onClick={() => void onCollect()}
>
<ActionIcon busy={collecting} icon={<FileText className="size-3.5" />} />
Create preview
</Button>
)
}
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 ? <Loader2 className="size-3.5 animate-spin" /> : 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`
}

View File

@ -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<DiagnosticsStatusPayload | null>(null)
const [bundle, setBundle] = useState<DiagnosticsBundlePayload | null>(null)
const [previewOpened, setPreviewOpened] = useState(false)
const [ticketId, setTicketId] = useState<string | null>(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<string | null>(null)
const refreshStatus = useCallback(async (): Promise<void> => {
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<void> => {
try {
await window.api.diagnostics.openTraceFolder()
} catch {
toast.error('Could not open trace folder')
}
}, [])
const handleClear = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 ? (
<DiagnosticsDisabledStateNote reason={status.disabledReason} />
) : null}
<Separator />
<Section
icon={<FileText className="size-4" />}
title="Diagnostic bundle"
description={getDiagnosticBundleDescription({ bundle, previewOpened, ticketId })}
>
<PrivacyDiagnosticBundleControls
status={status}
bundle={bundle}
previewOpened={previewOpened}
ticketId={ticketId}
collecting={collecting}
openingPreview={openingPreview}
uploading={uploading}
discarding={discarding}
copyingTicket={copyingTicket}
deletingTicket={deletingTicket}
onCollect={handleCollectBundle}
onOpenPreview={handleOpenPreview}
onUpload={handleUploadBundle}
onDiscard={handleDiscardBundle}
onCopyTicket={handleCopyTicket}
onDeleteUploadedBundle={handleDeleteUploadedBundle}
onDismissTicket={() => setTicketId(null)}
/>
</Section>
<Separator />
<Section
icon={<Folder className="size-4" />}
title="Open trace folder"
description={`Reveals ${status?.traceFilePath || 'the trace folder'} in your file manager.`}
>
<Button variant="outline" size="sm" onClick={() => void handleOpenFolder()}>
Open trace folder
</Button>
</Section>
<Separator />
<Section
icon={<Trash2 className="size-4" />}
title="Clear local traces"
description="Deletes every rotated trace file on this machine."
>
<Button
variant="outline"
size="sm"
disabled={!status?.localFileEnabled}
onClick={() => void handleClear()}
>
Clear local traces
</Button>
</Section>
<Separator />
<Section
icon={<Globe className="size-4" />}
title="OTLP export"
description={
status?.otlpStatus ??
'Set ORCA_OTLP_TRACES_URL to point Orca at your own OpenTelemetry collector.'
}
>
<span
className={
status?.otlpEnabled
? 'text-xs font-medium text-foreground'
: 'text-xs text-muted-foreground'
}
>
{status?.otlpEnabled ? 'Enabled' : 'Disabled'}
</span>
</Section>
</>
)
}
function getDiagnosticsErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function DiagnosticsDisabledStateNote({
reason
}: {
reason: NonNullable<DiagnosticsStatusPayload['disabledReason']>
}): 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 (
<div className="rounded border border-dashed border-border/60 bg-card/30 px-3 py-2 text-xs text-muted-foreground">
{message}
</div>
)
}
function Section({
icon,
title,
description,
children
}: {
icon: React.ReactNode
title: string
description: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div className="flex flex-col gap-3 py-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="mt-1 text-muted-foreground">{icon}</div>
<div className="space-y-1">
<Label className="text-sm">{title}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 self-start sm:justify-end sm:self-center">
{children}
</div>
</div>
)
}

View File

@ -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<TelemetryConsentState | null>(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<void> => {
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 (
<div className="space-y-1">
<div className="space-y-4">
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
@ -180,8 +88,7 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {
</div>
<p className="text-xs text-muted-foreground">
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.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-foreground"
@ -212,36 +119,25 @@ export function PrivacyPane({ settings }: PrivacyPaneProps): React.JSX.Element {
</div>
{blocked ? <BlockedHelper blocked={blocked} id={PRIVACY_PANE_BLOCKED_HELPER_ID} /> : null}
<PrivacyDiagnosticsSection />
</div>
)
}
// 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 (
<div id={id} className="px-1 pb-2 text-xs text-muted-foreground">
<EnvHelperBody reason={blocked.reason} />
{blocked.reason === 'ci' ? (
<p>Telemetry is disabled because a CI environment variable is set. Unset it and restart.</p>
) : (
<p>
Telemetry is disabled by the{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{envVarNameForReason(blocked.reason)}
</code>{' '}
environment variable. Unset it and restart to re-enable.
</p>
)}
</div>
)
}
function EnvHelperBody({ reason }: { reason: EnvBlockedReason }): React.JSX.Element {
if (reason === 'ci') {
return (
<p>
Telemetry is disabled because a CI environment variable is set. Unset it and restart to
re-enable.
</p>
)
}
const varName = envVarNameForReason(reason)
return (
<p>
Telemetry is disabled by the{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{varName}</code>{' '}
environment variable. Unset it and restart to re-enable.
</p>
)
}

View File

@ -109,6 +109,7 @@ type SettingsNavTarget =
| 'shortcuts'
| 'stats'
| 'ssh'
| 'privacy'
| 'experimental'
| 'agents'
| 'orchestration'

View File

@ -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.',

View File

@ -109,9 +109,34 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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()),

View File

@ -256,7 +256,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
buildArgs: ({ model, thinkingLevel }) => [
'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',

View File

@ -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.]')
})
})

View File

@ -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}`
}

View File

@ -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