Add local diagnostics error tracking (#2351)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a7d77aabaa
commit
a140313d4a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() */
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 1–2.
|
||||
// The branch name and remote URL are intentionally not added as
|
||||
// attributes — branch names can carry user-content (e.g. an issue
|
||||
// title) and the redactor would have to learn yet another rule;
|
||||
// the repo ID is the safer correlator for the bundle.
|
||||
return withWorktreeSpan({ stage: 'create' }, async () => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
// Why: only wrap the helpers themselves. The pre-validation throws
|
||||
// above (`Repo not found`, `Folder mode does not support creating
|
||||
// worktrees`) signal IPC-shape bugs, not the user-visible
|
||||
// git/filesystem failures the funnel cares about — bucketing them
|
||||
// into `unknown` would pollute the failure taxonomy.
|
||||
result = repo.connectionId
|
||||
? await createRemoteWorktree(args, repo, store, mainWindow)
|
||||
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
track('workspace_create_failed', {
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
// Why: only wrap the helpers themselves. The pre-validation throws
|
||||
// above (`Repo not found`, `Folder mode does not support creating
|
||||
// worktrees`) signal IPC-shape bugs, not the user-visible
|
||||
// git/filesystem failures the funnel cares about — bucketing them
|
||||
// into `unknown` would pollute the failure taxonomy.
|
||||
result = repo.connectionId
|
||||
? await createRemoteWorktree(args, repo, store, mainWindow)
|
||||
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
track('workspace_create_failed', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: emit `workspace_created` only after the underlying create has
|
||||
// resolved (the helpers throw on failure, so reaching this line means
|
||||
// git-add succeeded — we deliberately do not also emit a separate
|
||||
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
|
||||
// `from_existing_branch` is true iff the caller specified a non-empty
|
||||
// baseBranch; an unspecified baseBranch means "branch from default
|
||||
// HEAD", which is the not-from-existing-branch case. We never send
|
||||
// the branch name itself.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: emit `workspace_created` only after the underlying create has
|
||||
// resolved (the helpers throw on failure, so reaching this line means
|
||||
// git-add succeeded — we deliberately do not also emit a separate
|
||||
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
|
||||
// `from_existing_branch` is true iff the caller specified a non-empty
|
||||
// baseBranch; an unspecified baseBranch means "branch from default
|
||||
// HEAD", which is the not-from-existing-branch case. We never send
|
||||
// the branch name itself.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
return result
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 // 10 MB cap enforced before upload
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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' } }
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
// Secrets scrubber for the error-tracking lane. Runs synchronously at three
|
||||
// well-defined locations (see telemetry-error-tracking.md §The redactor):
|
||||
//
|
||||
// 1. Sink-write time — every span is redacted before NDJSON serialization
|
||||
// and before any optional OTLP export.
|
||||
// 2. Bundle-collection time — a second pass before the user-preview window
|
||||
// renders. Belt-and-suspenders against a sink-write bug.
|
||||
// 3. Server-side ingest — a third pass. The client-side redactor runs on
|
||||
// an attacker-controllable binary; server-side redaction is the
|
||||
// defense-in-depth guarantee on the one path where bundle bytes reach
|
||||
// Orca infrastructure. We expose `serverSideRedact()` separately so
|
||||
// the server can additionally drop `install_id`/`installId`/
|
||||
// `distinct_id` keys (which are valid in product telemetry but must not
|
||||
// ride along on a bundle — see "Why bundles do not carry install_id").
|
||||
//
|
||||
// Five rule families, applied in this order:
|
||||
// 1. labeled key-value (`api_key:`, `Authorization=Bearer …`)
|
||||
// 2. provider-key fingerprints (8 shapes)
|
||||
// 3. URL userinfo strip (`https://user:pass@host` → `https://[redacted]@host`)
|
||||
// 4. .env-shape line redaction (`FOO_SECRET=…`)
|
||||
// 5. attribute-key block-list (drop key entirely)
|
||||
//
|
||||
// Rules 1–4 operate on string values; rule 5 drops attribute *keys* before
|
||||
// the values are even examined. The string passes are idempotent — running
|
||||
// the redactor twice in a row produces the same output as running it once,
|
||||
// which is what makes the three-location placement safe.
|
||||
//
|
||||
// Per-attribute length capping is deliberately NOT applied here. The spec
|
||||
// argues against it (see §The redactor "No per-attribute length cap"):
|
||||
// envelope-level bounds (10 MB × 10 file rotation; 10 MB Mode-3 upload cap)
|
||||
// already cover the worst case, and a per-attribute truncation would eat the
|
||||
// tail of long stack chains, which is the most diagnostic part. Spans that
|
||||
// dump a multi-MB blob into one attribute are a call-site bug to fix at the
|
||||
// call site, not at the sink.
|
||||
|
||||
// Word boundaries (`\b`) on the keyword alternation prevent the rule from
|
||||
// firing inside compound identifiers — e.g. `FOO_SECRET=…` (an .env-shape
|
||||
// line redacted by Rule 4) and `DB_PASSWORD=…` should NOT match the
|
||||
// `secret`/`password` keyword here, otherwise this rule would steal the
|
||||
// match from rule 4 and produce `FOO_[redacted:labeled-kv]` rather than
|
||||
// preserving the key name.
|
||||
//
|
||||
// The value alternation `(?:Bearer\s+\S+|Token\s+\S+|\S+)` lets the rule
|
||||
// consume the *whole* secret-bearing segment for the common
|
||||
// `Authorization=Bearer <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 1–4 to a string. Idempotent — running this twice yields the
|
||||
* same output as once, which is what makes triple-application safe.
|
||||
*/
|
||||
export function redactString(input: string): string {
|
||||
if (typeof input !== 'string' || input.length === 0) {
|
||||
return input
|
||||
}
|
||||
let out = input
|
||||
|
||||
// Rule 1 — labeled key-value. Replace the entire `key: value` segment with
|
||||
// a tagged token. We deliberately blow away the labeled-key alongside the
|
||||
// value because the label name itself ("api_key", "Authorization") leaks
|
||||
// no useful debug context once the value is gone.
|
||||
out = out.replace(LABELED_KV, '[redacted:labeled-kv]')
|
||||
|
||||
// Rule 2 — provider-key fingerprints. Each shape is tried independently,
|
||||
// so a string carrying multiple keys gets all of them redacted. The tag
|
||||
// names (e.g. `anthropic-key`) are stable wire identifiers — third-party
|
||||
// tools that read our NDJSON can grep for them.
|
||||
for (const { tag, re } of PROVIDER_PATTERNS) {
|
||||
out = out.replace(re, `[redacted:${tag}]`)
|
||||
}
|
||||
|
||||
// Rule 3 — URL userinfo. Preserves scheme + host + path; replaces only the
|
||||
// `user:pass@` segment with `[redacted]@`. Done after rule 2 so a userinfo
|
||||
// value that happens to look like a provider key gets the more specific
|
||||
// redaction first.
|
||||
out = out.replace(URL_USERINFO, '$1[redacted]@')
|
||||
|
||||
// Rule 4 — .env-shape line redaction. Keep the key name (`FOO_SECRET=`),
|
||||
// replace only the value with `[redacted:env-value]`. Done last among the
|
||||
// string passes so a labeled-kv match (rule 1) wins over a coincidentally
|
||||
// .env-shaped substring inside a longer line.
|
||||
out = out.replace(ENV_LINE, (_match, key) => `${String(key)}=[redacted:env-value]`)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redact a value of unknown shape — strings get rules 1–4;
|
||||
* objects/arrays/maps recurse; primitives pass through. Designed for the
|
||||
* span-attribute and span-event use cases where attribute *values* can be
|
||||
* any JSON-shaped thing.
|
||||
*
|
||||
* Loop guard: we track visited references in a `WeakSet` so a self-referential
|
||||
* cycle does not stack-overflow. Cycles are unusual in span attributes but
|
||||
* span-event payloads occasionally get serialized error objects with cycles.
|
||||
*/
|
||||
export function redactValue(
|
||||
value: unknown,
|
||||
mode: RedactorMode = 'client',
|
||||
seen: WeakSet<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 1–4 there.
|
||||
*
|
||||
* Span event attribute keys are redacted with the same blocklist as span
|
||||
* attributes; an `authorization` event-attribute is just as leaky as an
|
||||
* `authorization` span-attribute.
|
||||
*/
|
||||
export function redactSpan(span: RedactableSpan, mode: RedactorMode = 'client'): RedactableSpan {
|
||||
const redactedAttrs = redactAttributes(span.attributes, mode)
|
||||
const redactedEvents: SpanEvent[] = span.events.map((ev) => ({
|
||||
name: ev.name,
|
||||
timeUnixNano: ev.timeUnixNano,
|
||||
attributes: redactAttributes(ev.attributes, mode)
|
||||
}))
|
||||
const exit: SpanExit = span.exit.cause
|
||||
? { _tag: span.exit._tag, cause: redactString(span.exit.cause) }
|
||||
: { _tag: span.exit._tag }
|
||||
return {
|
||||
name: span.name,
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
||||
kind: span.kind,
|
||||
startTimeUnixNano: span.startTimeUnixNano,
|
||||
endTimeUnixNano: span.endTimeUnixNano,
|
||||
durationMs: span.durationMs,
|
||||
attributes: redactedAttrs,
|
||||
events: redactedEvents,
|
||||
exit
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test-only introspection (kept here so tests can verify the rule set
|
||||
// without re-deriving it from external assertions). ─────────────────────────
|
||||
|
||||
export const _internalsForTests = {
|
||||
PROVIDER_PATTERNS,
|
||||
CLIENT_ATTR_BLOCKLIST,
|
||||
SERVER_ATTR_BLOCKLIST_EXTRA,
|
||||
LABELED_KV,
|
||||
URL_USERINFO,
|
||||
ENV_LINE
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ type SettingsNavTarget =
|
|||
| 'shortcuts'
|
||||
| 'stats'
|
||||
| 'ssh'
|
||||
| 'privacy'
|
||||
| 'experimental'
|
||||
| 'agents'
|
||||
| 'orchestration'
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.]')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue