Simplify support diagnostics settings (#5554)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-16 23:11:27 -07:00 committed by GitHub
parent 928c1ee106
commit 00df8288dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 336 additions and 1264 deletions

View File

@ -258,9 +258,9 @@ export const LOCALE_KEY_OVERRIDES = {
ja: '再試行'
},
'auto.components.settings.PrivacyDiagnosticBundleControls.2801d4ce22': {
ko: '티켓 복사',
zh: '复制工单',
ja: 'チケットをコピー'
ko: '참조 ID 복사',
zh: '复制参考 ID',
ja: '参照 ID をコピー'
},
'auto.components.settings.ComputerUsePane.4b65070096': {
ko: 'darwin',

View File

@ -146,7 +146,7 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /细绳/g, replacement: '字符串', whenEnIncludes: 'string' },
{ pattern: /在职的/g, replacement: '处理中', whenEnIncludes: 'Working' },
{ pattern: /编曲/g, replacement: '编排', whenEnIncludes: 'Orchestration' },
{ pattern: /复印门票/g, replacement: '复制工单', whenEnIncludes: 'Copy ticket' },
{ pattern: /复制票据/g, replacement: '复制参考 ID', whenEnIncludes: 'Copy reference ID' },
{ pattern: /达尔文/g, replacement: 'darwin', whenEnIncludes: 'darwin' },
{ pattern: /外貌/g, replacement: '外观', whenEnIncludes: 'Appearance' },
{ pattern: /一般的/g, replacement: '通用', whenEnIncludes: 'General' },

View File

@ -135,8 +135,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'IDE',
'ui',
'UI',
'otlp',
'OTLP',
'calt',
'ai',
'AI',

View File

@ -114,7 +114,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'Search Linear projects...': 'Linear 프로젝트 검색...',
'Launch agent': '에이전트 실행',
'Git AI Author': 'Git AI Author',
'Copy ticket': '티켓 복사',
'Copy reference ID': '참조 ID 복사',
'Try Again': '다시 시도',
'Restart now': '지금 재시작',
'Restart Orca': 'Orca 재시작',
@ -273,7 +273,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'Search Linear projects...': '搜索 Linear 项目...',
'Launch agent': '启动代理',
'Git AI Author': 'Git AI Author',
'Copy ticket': '复制工单',
'Copy reference ID': '复制参考 ID',
'Try Again': '重试',
'Restart now': '立即重启',
'Restart Orca': '重启 Orca',
@ -481,7 +481,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'Search Linear projects...': 'Linear プロジェクトを検索...',
'Launch agent': 'エージェントを起動',
'Git AI Author': 'Git AI Author',
'Copy ticket': 'チケットをコピー',
'Copy reference ID': '参照 ID をコピー',
'Try Again': '再試行',
'Restart now': '今すぐ再起動',
'Restart Orca': 'Orca を再起動',

View File

@ -13,11 +13,9 @@ const {
writeFileSyncMock,
showMessageBoxMock,
openPathMock,
showItemInFolderMock,
collectDiagnosticBundleMock,
deleteDiagnosticBundleMock,
getDiagnosticsStatusMock,
getTraceFilePathMock,
uploadDiagnosticBundleMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
@ -26,11 +24,9 @@ const {
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()
}))
@ -48,15 +44,13 @@ vi.mock('electron', () => ({
app: { getPath: () => '/tmp', getVersion: () => '1.2.3-test' },
dialog: { showMessageBox: showMessageBoxMock },
ipcMain: { handle: handleMock },
shell: { openPath: openPathMock, showItemInFolder: showItemInFolderMock }
shell: { openPath: openPathMock }
}))
vi.mock('../observability', () => ({
clearLocalTraces: vi.fn(),
collectDiagnosticBundle: collectDiagnosticBundleMock,
deleteDiagnosticBundle: deleteDiagnosticBundleMock,
getDiagnosticsStatus: getDiagnosticsStatusMock,
getTraceFilePath: getTraceFilePathMock,
uploadDiagnosticBundle: uploadDiagnosticBundleMock
}))
@ -93,20 +87,16 @@ describe('diagnostics IPC handlers', () => {
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
})
@ -175,7 +165,7 @@ describe('diagnostics IPC handlers', () => {
})
})
it('requires main-owned user confirmation before upload', async () => {
it('returns a quiet cancellation when the user declines upload confirmation', async () => {
const bundle = makeBundle({ bundleSubmissionId: 'bundleabcdefghijklmnop' })
collectDiagnosticBundleMock.mockReturnValue(bundle)
showMessageBoxMock.mockResolvedValue({ response: 1 })
@ -185,7 +175,7 @@ describe('diagnostics IPC handlers', () => {
await collect({}, 30)
await openPreview({}, bundle.bundleSubmissionId)
await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/cancelled/)
await expect(upload({}, bundle.bundleSubmissionId)).resolves.toEqual({ canceled: true })
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled()
})
@ -244,14 +234,14 @@ describe('diagnostics IPC handlers', () => {
)
})
it('requires opening the retained preview before upload', async () => {
it('requires opening the retained review file before sending', 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/)
await expect(upload({}, bundle.bundleSubmissionId)).rejects.toThrow(/open.*review file/)
expect(uploadDiagnosticBundleMock).not.toHaveBeenCalled()
})
@ -285,19 +275,6 @@ describe('diagnostics IPC handlers', () => {
}
})
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)

View File

@ -1,9 +1,7 @@
// IPC surface for the error-tracking lane (telemetry-error-tracking.md
// §User controls). Seven renderer-facing channels:
// §User controls). Six 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.
@ -26,11 +24,9 @@ import { arch as osArch, platform as osPlatform, release as osRelease } from 'no
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
clearLocalTraces,
collectDiagnosticBundle,
deleteDiagnosticBundle,
getDiagnosticsStatus,
getTraceFilePath,
uploadDiagnosticBundle,
type DiagnosticsStatus
} from '../observability'
@ -42,6 +38,7 @@ import {
} from '../observability/diagnostic-upload-endpoint'
export type DiagnosticsBundlePreview = Omit<CollectedBundle, 'payload'>
type UploadBundleIpcResult = UploadBundleResult | { canceled: true }
const PENDING_BUNDLE_TTL_MS = 15 * 60 * 1000
const MAX_PENDING_BUNDLES = 8
@ -117,10 +114,10 @@ function getPendingBundleForUpload(bundleSubmissionId: unknown): {
prunePendingBundles()
const pending = pendingBundles.get(bundleSubmissionId)
if (!pending) {
throw new Error('diagnostic bundle has expired; collect a new preview before uploading')
throw new Error('review file has expired; create a new one before sending')
}
if (!pending.previewOpened) {
throw new Error('open the diagnostic bundle preview before uploading')
throw new Error('open the review file before sending')
}
// Why: the preview file is user-editable once opened in the OS. Upload only
// the redacted bytes main collected and retained before preview.
@ -137,7 +134,7 @@ function getPendingPreviewFilePath(bundleSubmissionId: unknown): string {
prunePendingBundles()
const pending = pendingBundles.get(bundleSubmissionId)
if (!pending) {
throw new Error('diagnostic bundle has expired; collect a new preview before opening')
throw new Error('review file has expired; create a new one before opening')
}
return pending.previewFilePath
}
@ -189,31 +186,23 @@ function deletePreviewFile(filePath: string): void {
}
}
function discardAllPendingBundles(): void {
for (const bundleSubmissionId of Array.from(pendingBundles.keys())) {
deletePendingBundle(bundleSubmissionId)
}
}
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> {
async function confirmBundleUpload(bundle: CollectedBundle): Promise<boolean> {
const result = await dialog.showMessageBox({
type: 'question',
buttons: ['Upload', 'Cancel'],
buttons: ['Send', '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(
title: 'Send this file to support?',
message: 'This uploads the redacted app diagnostics file you reviewed.',
detail: `Diagnostic ID: ${bundle.bundleSubmissionId}\nDiagnostic records: ${bundle.spanCount}\nSize: ${Math.round(
bundle.bytes / 1024
)} KB\n\nThe exact redacted NDJSON preview was opened before this upload confirmation.`
)} KB`
})
if (result.response !== 0) {
throw new Error('diagnostic bundle upload cancelled')
}
return result.response === 0
}
export function registerDiagnosticsHandlers(): void {
@ -221,23 +210,6 @@ export function registerDiagnosticsHandlers(): void {
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 => {
@ -247,7 +219,7 @@ export function registerDiagnosticsHandlers(): void {
// user has disabled diagnostic-bundle collection in Settings → Privacy.
const status = getDiagnosticsStatus()
if (!status.bundleEnabled) {
throw new Error('diagnostic bundle collection is disabled')
throw new Error('creating review files is disabled')
}
// Renderer-controlled input → narrow at the boundary. The default
// (DEFAULT_LOOKBACK_MINUTES in bundle.ts) is fine for the common
@ -271,7 +243,7 @@ export function registerDiagnosticsHandlers(): void {
ipcMain.handle(
'diagnostics:uploadBundle',
async (_event, bundleSubmissionId: unknown): Promise<UploadBundleResult> => {
async (_event, bundleSubmissionId: unknown): Promise<UploadBundleIpcResult> => {
// 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)
@ -279,18 +251,21 @@ export function registerDiagnosticsHandlers(): void {
// 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')
throw new Error('sending diagnostics is disabled')
}
const confirmed = await confirmBundleUpload(pendingForConfirmation.bundle)
if (!confirmed) {
return { canceled: true }
}
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')
throw new Error('sending diagnostics is disabled')
}
const tokenEndpoint = resolveDiagnosticTokenEndpoint()
if (!tokenEndpoint) {
throw new Error('diagnostic upload endpoint is not configured for this build')
throw new Error('sending diagnostics is not configured for this build')
}
const result = await uploadDiagnosticBundle({
tokenEndpoint,
@ -309,7 +284,7 @@ export function registerDiagnosticsHandlers(): void {
const previewFilePath = getPendingPreviewFilePath(bundleSubmissionId)
const errorMessage = await shell.openPath(previewFilePath)
if (errorMessage) {
throw new Error('could not open diagnostic bundle preview')
throw new Error('could not open review file')
}
const pending = pendingBundles.get(bundleSubmissionId as string)
if (pending) {

View File

@ -17,8 +17,8 @@
// 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.
// 4. (renderer) — surface the support reference ID; offer copy/delete
// controls. Delete posts only the server-issued ID.
//
// Server-side endpoint contract is fully specified in
// telemetry-error-tracking.md §Endpoint contract. Implementation of those

View File

@ -1,7 +1,7 @@
// 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`.
// §Architecture). Wires the local NDJSON sink 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
@ -12,7 +12,7 @@
//
// Consent boundaries (telemetry-error-tracking.md §Consent boundaries):
//
// DO_NOT_TRACK=1 → disable OTLP + bundle button. KEEP local file.
// DO_NOT_TRACK=1 → disable 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.
@ -32,7 +32,6 @@ import { app } from 'electron'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
import {
clearRotatedFamily,
createLocalFileSink,
DEFAULT_MAX_FILES,
getRotatedFamilySize,
@ -50,8 +49,7 @@ import {
type UploadBundleOptions,
type UploadBundleResult
} from './diagnostic-bundle-upload'
import { createOtlpExporterFromEnv, type OtlpExporter } from './otlp-exporter'
import { setActiveSink, type TracerSink } from './tracer'
import { setActiveSink } from './tracer'
const CI_ENV_VARS = [
'CI',
@ -67,12 +65,8 @@ const CI_ENV_VARS = [
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'
@ -107,18 +101,14 @@ export function resolveObservabilityConsent(): ObservabilityConsent {
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'
}
}
@ -127,24 +117,14 @@ export function resolveObservabilityConsent(): ObservabilityConsent {
// 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)'
bundleEnabled: true
}
}
@ -176,77 +156,14 @@ export function getTraceFilePath(): string {
// ── 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). */
/** Create the local file sink, install it as the active tracer sink, and
* update module-level `sink`. */
function installLocalSink(): void {
const localSink = createLocalFileSink({ filePath: getTraceFilePath() })
sink = localSink
setActiveSink(makeCompositeSink(localSink, otlp))
setActiveSink(localSink)
}
export function initObservability(): ObservabilityConsent {
@ -257,24 +174,14 @@ export function initObservability(): ObservabilityConsent {
// 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).
// Order matters: tracer first so no new pushes arrive while the local sink
// is closing and flushing buffered lines.
setActiveSink(null)
if (otlp) {
try {
await otlp.flush()
} catch {
/* swallow */
}
otlp.close()
otlp = null
}
if (sink) {
sink.close()
sink = null
@ -290,9 +197,7 @@ export function getObservabilityConsent(): ObservabilityConsent | null {
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']
@ -304,42 +209,13 @@ export function getDiagnosticsStatus(): DiagnosticsStatus {
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

View File

@ -1,4 +1,4 @@
// Sink tests: rotation behavior under size pressure, listing, clearing.
// Sink tests: rotation behavior under size pressure and listing.
import {
chmodSync,
@ -12,12 +12,7 @@ import {
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'
import { createLocalFileSink, getRotatedFamilySize, listRotatedFiles } from './local-file-sink'
let dir: string
@ -245,26 +240,6 @@ describe('local-file-sink — listing + clearing', () => {
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', () => {

View File

@ -319,18 +319,3 @@ export function listRotatedFiles(filePath: string, maxFiles: number = DEFAULT_MA
}
return out
}
/** Delete every file in the rotated family. Wired up to the "Clear local
* traces" button in Settings Privacy Diagnostics. */
export function clearRotatedFamily(filePath: string, maxFiles: number = DEFAULT_MAX_FILES): void {
for (let i = 0; i < maxFiles; i++) {
const path = i === 0 ? filePath : `${filePath}.${i}`
if (existsSync(path)) {
try {
unlinkSync(path)
} catch {
/* swallow — best-effort delete; user can always reveal-in-finder */
}
}
}
}

View File

@ -1,101 +0,0 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import type { RedactableSpan } from './redactor'
const { httpRequestMock, httpsRequestMock } = vi.hoisted(() => ({
httpRequestMock: vi.fn(),
httpsRequestMock: vi.fn()
}))
vi.mock('node:http', () => ({ request: httpRequestMock }))
vi.mock('node:https', () => ({ request: httpsRequestMock }))
import { createOtlpExporter } from './otlp-exporter'
class FakeRequest extends EventEmitter {
destroy = vi.fn()
end = vi.fn()
write = vi.fn()
}
class FakeResponse extends EventEmitter {
statusCode = 204
resume = vi.fn()
}
function span(): RedactableSpan {
return {
name: 'unit',
traceId: 'a'.repeat(32),
spanId: 'b'.repeat(16),
kind: 'internal',
startTimeUnixNano: '1000',
endTimeUnixNano: '2000',
durationMs: 1,
attributes: {},
events: [],
exit: { _tag: 'Success' }
}
}
describe('otlp exporter HTTP cleanup', () => {
it('removes request listeners after a successful export response', async () => {
const request = new FakeRequest()
const response = new FakeResponse()
let responseCallback: ((response: FakeResponse) => void) | null = null
httpRequestMock.mockImplementationOnce(
(_options: unknown, callback: (response: FakeResponse) => void) => {
responseCallback = callback
return request
}
)
const exporter = createOtlpExporter({
tracesUrl: 'http://collector.example/v1/traces',
serviceName: 'orca-test',
timeoutMs: 1000
})
exporter.exportSpan(span())
const flush = exporter.flush()
expect(request.listenerCount('error')).toBe(1)
expect(request.listenerCount('timeout')).toBe(1)
const respond = responseCallback as ((response: FakeResponse) => void) | null
if (!respond) {
throw new Error('HTTP response callback was not registered')
}
respond(response)
await flush
exporter.close()
expect(response.resume).toHaveBeenCalledTimes(1)
expect(request.listenerCount('error')).toBe(0)
expect(request.listenerCount('timeout')).toBe(0)
})
it('removes request listeners after an export timeout', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const request = new FakeRequest()
httpRequestMock.mockReturnValueOnce(request)
const exporter = createOtlpExporter({
tracesUrl: 'http://collector.example/v1/traces',
serviceName: 'orca-test',
timeoutMs: 1000
})
exporter.exportSpan(span())
const flush = exporter.flush()
request.emit('timeout')
await flush
exporter.close()
expect(request.destroy).toHaveBeenCalledTimes(1)
expect(request.listenerCount('error')).toBe(0)
expect(request.listenerCount('timeout')).toBe(0)
} finally {
warnSpy.mockRestore()
}
})
})

View File

@ -1,226 +0,0 @@
// OTLP exporter tests. Most cases lock the wire encoding; the flush suite uses
// a local HTTP server to verify batching without requiring an LGTM container.
import { createServer, type RequestListener, type Server } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { _internalsForTests, createOtlpExporter, createOtlpExporterFromEnv } from './otlp-exporter'
import type { RedactableSpan } from './redactor'
const { encodeOtlpPayload, toOtlpAttributes, spanKindToOtlp } = _internalsForTests
let server: Server | null = null
afterEach(
() =>
new Promise<void>((resolve) => {
if (!server) {
resolve()
return
}
server.close(() => {
server = null
resolve()
})
})
)
function listen(handler: RequestListener): Promise<string> {
server = createServer(handler)
return new Promise((resolve) => {
server?.listen(0, '127.0.0.1', () => {
const address = server?.address()
if (address && typeof address === 'object') {
resolve(`http://127.0.0.1:${address.port}`)
}
})
})
}
function span(overrides: Partial<RedactableSpan> = {}): RedactableSpan {
return {
name: 'unit',
traceId: 'a'.repeat(32),
spanId: 'b'.repeat(16),
kind: 'internal',
startTimeUnixNano: '1000',
endTimeUnixNano: '2000',
durationMs: 1.0,
attributes: {},
events: [],
exit: { _tag: 'Success' },
...overrides
}
}
describe('otlp-exporter — env gating', () => {
it('returns null when ORCA_OTLP_TRACES_URL is unset', () => {
const before = process.env.ORCA_OTLP_TRACES_URL
delete process.env.ORCA_OTLP_TRACES_URL
expect(createOtlpExporterFromEnv()).toBeNull()
if (before !== undefined) {
process.env.ORCA_OTLP_TRACES_URL = before
}
})
})
describe('otlp-exporter — flushing', () => {
it('serializes batch POSTs and awaits in-flight flushes', async () => {
let activeRequests = 0
let maxConcurrentRequests = 0
let requestCount = 0
const baseUrl = await listen((req, res) => {
req.resume()
requestCount += 1
activeRequests += 1
maxConcurrentRequests = Math.max(maxConcurrentRequests, activeRequests)
setTimeout(() => {
activeRequests -= 1
res.setHeader('content-type', 'application/json')
res.end('{}')
}, 20)
})
const exporter = createOtlpExporter({
tracesUrl: `${baseUrl}/v1/traces`,
serviceName: 'orca-test',
timeoutMs: 1_000
})
for (let i = 0; i < 128; i++) {
exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') }))
}
await exporter.flush()
exporter.close()
expect(requestCount).toBe(2)
expect(maxConcurrentRequests).toBe(1)
})
it('caps queued spans and keeps the newest records', async () => {
const receivedSpanIds: string[] = []
const baseUrl = await listen((req, res) => {
let body = ''
req.setEncoding('utf8')
req.on('data', (chunk) => {
body += chunk
})
req.on('end', () => {
const payload = JSON.parse(body) as ReturnType<typeof encodeOtlpPayload>
receivedSpanIds.push(
...payload.resourceSpans.flatMap((resourceSpan) =>
resourceSpan.scopeSpans.flatMap((scopeSpan) =>
scopeSpan.spans.map((exportedSpan) => exportedSpan.spanId)
)
)
)
res.setHeader('content-type', 'application/json')
res.end('{}')
})
})
const exporter = createOtlpExporter({
tracesUrl: `${baseUrl}/v1/traces`,
serviceName: 'orca-test',
timeoutMs: 1_000,
maxQueueSpans: 4
})
for (let i = 0; i < 6; i++) {
exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') }))
}
await exporter.flush()
exporter.close()
expect(receivedSpanIds).toEqual([
'0000000000000002',
'0000000000000003',
'0000000000000004',
'0000000000000005'
])
})
})
describe('otlp-exporter — attribute encoding', () => {
it('encodes strings, ints, floats, bools', () => {
const out = toOtlpAttributes({ s: 'x', i: 5, f: 1.5, b: true })
expect(out).toEqual([
{ key: 's', value: { stringValue: 'x' } },
{ key: 'i', value: { intValue: '5' } },
{ key: 'f', value: { doubleValue: 1.5 } },
{ key: 'b', value: { boolValue: true } }
])
})
it('JSON-encodes objects and arrays', () => {
const out = toOtlpAttributes({ list: [1, 2], obj: { a: 1 } })
expect(out).toContainEqual({ key: 'list', value: { stringValue: '[1,2]' } })
expect(out).toContainEqual({ key: 'obj', value: { stringValue: '{"a":1}' } })
})
it('drops null/undefined', () => {
const out = toOtlpAttributes({ keep: 'x', drop: null, alsodrop: undefined })
expect(out.find((kv) => kv.key === 'drop')).toBeUndefined()
expect(out.find((kv) => kv.key === 'alsodrop')).toBeUndefined()
expect(out.find((kv) => kv.key === 'keep')).toBeDefined()
})
})
describe('otlp-exporter — span kind mapping', () => {
it('maps OTel SpanKind names to numeric codes', () => {
expect(spanKindToOtlp('internal')).toBe(1)
expect(spanKindToOtlp('server')).toBe(2)
expect(spanKindToOtlp('client')).toBe(3)
expect(spanKindToOtlp('producer')).toBe(4)
expect(spanKindToOtlp('consumer')).toBe(5)
expect(spanKindToOtlp('unknown')).toBe(1)
})
})
describe('otlp-exporter — payload encoding', () => {
it('builds a valid OTLP payload skeleton with service.name', () => {
const out = encodeOtlpPayload('orca-test', [span()])
expect(out.resourceSpans).toHaveLength(1)
expect(out.resourceSpans[0].resource.attributes).toContainEqual({
key: 'service.name',
value: { stringValue: 'orca-test' }
})
expect(out.resourceSpans[0].scopeSpans[0].spans).toHaveLength(1)
})
it('includes parentSpanId when present', () => {
const out = encodeOtlpPayload('s', [span({ parentSpanId: 'p'.repeat(16) })])
const s = out.resourceSpans[0].scopeSpans[0].spans[0]
expect(s.parentSpanId).toBe('p'.repeat(16))
})
it('omits parentSpanId for root spans', () => {
const out = encodeOtlpPayload('s', [span()])
const s = out.resourceSpans[0].scopeSpans[0].spans[0]
expect(s.parentSpanId).toBeUndefined()
})
it('sets ERROR status on Failure exits', () => {
const out = encodeOtlpPayload('s', [span({ exit: { _tag: 'Failure', cause: 'boom' } })])
const s = out.resourceSpans[0].scopeSpans[0].spans[0]
expect(s.status?.code).toBe(2)
expect(s.status?.message).toBe('boom')
})
it('omits status for Success exits', () => {
const out = encodeOtlpPayload('s', [span()])
const s = out.resourceSpans[0].scopeSpans[0].spans[0]
expect(s.status).toBeUndefined()
})
it('encodes events with their attributes', () => {
const out = encodeOtlpPayload('s', [
span({
events: [{ name: 'log', timeUnixNano: '1500', attributes: { msg: 'hi' } }]
})
])
const s = out.resourceSpans[0].scopeSpans[0].spans[0]
expect(s.events).toHaveLength(1)
expect(s.events[0].name).toBe('log')
expect(s.events[0].timeUnixNano).toBe('1500')
expect(s.events[0].attributes).toContainEqual({
key: 'msg',
value: { stringValue: 'hi' }
})
})
})

View File

@ -1,375 +0,0 @@
// 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, type ClientRequest } 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 settled = false
let req: ClientRequest | null = null
const cleanupListeners = (): void => {
req?.off('error', onError)
req?.off('timeout', onTimeout)
}
const resolveOnce = (): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
resolve()
}
const rejectOnce = (error: Error, options?: { destroy?: boolean }): void => {
if (settled) {
return
}
settled = true
if (options?.destroy) {
req?.destroy()
}
cleanupListeners()
reject(error)
}
const onError = (error: Error): void => rejectOnce(error)
const onTimeout = (): void => rejectOnce(new Error('OTLP timeout'), { destroy: true })
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
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) {
resolveOnce()
} else {
rejectOnce(new Error(`OTLP HTTP ${status}`))
}
}
)
req.on('error', onError)
req.on('timeout', onTimeout)
req.write(data)
req.end()
})
}
// Test-only export so the encoder can be verified without a network round-
// trip. Not part of the runtime API.
export const _internalsForTests = {
encodeOtlpPayload,
toOtlpAttributes,
spanKindToOtlp
}

View File

@ -1,8 +1,7 @@
// 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.
// 1. Sink-write time — every span is redacted before NDJSON serialization.
// 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

View File

@ -4,8 +4,8 @@
// 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.
// The compact shape keeps local diagnostic files readable and cheap to
// collect for user-reviewed support uploads.
//
// Concurrency model: in-process span tree maintained via Node's
// `AsyncLocalStorage`, so a child span created inside an `await` chain
@ -89,10 +89,8 @@ const noopSpan: ActiveSpan = {
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.
// 16-byte traceId / 8-byte spanId — compact hex IDs keep local NDJSON
// records close to standard trace shapes without introducing UUID dashes.
function genTraceId(): string {
return randomBytes(16).toString('hex')
}
@ -127,9 +125,8 @@ export function getActiveSpanContext(): SpanContext | undefined {
* 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.
* This is the function 90% of call sites should reach for: it keeps span
* lifetime scoped to the async work it measures.
*/
export async function withSpan<T>(
name: string,

View File

@ -589,9 +589,7 @@ export type StatsApi = {
// 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?:
@ -605,9 +603,13 @@ export type DiagnosticsBundlePayload = {
readonly bytes: number
readonly spanCount: number
}
export type DiagnosticsUploadPayload = {
readonly ticketId: string
}
export type DiagnosticsUploadPayload =
| {
readonly ticketId: string
}
| {
readonly canceled: true
}
export type MemoryApi = {
getSnapshot: () => Promise<MemorySnapshot>
@ -1729,15 +1731,13 @@ 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. */
/** Diagnostic file 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>

View File

@ -1551,14 +1551,11 @@ const api = {
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.
// lane (telemetry-error-tracking.md §User controls). Handlers type-narrow
// their inputs in main (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> =>

View File

@ -55,7 +55,7 @@ export function PrivacyDiagnosticBundleControls({
<ActionIcon busy={copyingTicket} icon={<Clipboard className="size-3.5" />} />
{translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.2801d4ce22',
'Copy ticket'
'Copy reference ID'
)}
</Button>
<Button
@ -67,7 +67,7 @@ export function PrivacyDiagnosticBundleControls({
<ActionIcon busy={deletingTicket} icon={<Trash2 className="size-3.5" />} />
{translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.7f14a1733c',
'Delete bundle'
'Delete sent file'
)}
</Button>
<Button variant="ghost" size="sm" disabled={deletingTicket} onClick={onDismissTicket}>
@ -90,14 +90,26 @@ export function PrivacyDiagnosticBundleControls({
<ActionIcon busy={openingPreview} icon={<Eye className="size-3.5" />} />
{translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.798b6f0be5',
'Open preview'
'Open review file'
)}
</Button>
<Button size="sm" disabled={!previewOpened || uploading} onClick={() => void onUpload()}>
<Button
size="sm"
title={
previewOpened
? undefined
: translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.d8be621237',
'Open the review file first.'
)
}
disabled={!previewOpened || uploading}
onClick={() => void onUpload()}
>
<ActionIcon busy={uploading} icon={<UploadCloud className="size-3.5" />} />
{translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.aca2c8a367',
'Upload'
'Send to support'
)}
</Button>
<Button variant="ghost" size="sm" disabled={discarding} onClick={() => void onDiscard()}>
@ -121,7 +133,7 @@ export function PrivacyDiagnosticBundleControls({
<ActionIcon busy={collecting} icon={<FileText className="size-3.5" />} />
{translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.dc8404a930',
'Create preview'
'Create diagnostic file'
)}
</Button>
)
@ -137,13 +149,31 @@ export function getDiagnosticBundleDescription({
readonly ticketId: string | null
}): string {
if (ticketId) {
return `Uploaded ticket ${ticketId}.`
return translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.61676df223',
'Diagnostics sent. Share this reference ID with support: {{value0}}.',
{ value0: ticketId }
)
}
if (bundle) {
const previewState = previewOpened ? 'Ready to upload.' : 'Open the preview before uploading.'
return `${bundle.spanCount} span(s), ${formatBytes(bundle.bytes)}. ${previewState}`
const size = formatBytes(bundle.bytes)
if (previewOpened) {
return translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.fd7b3891af',
'You opened the review file ({{value0}}). Send that file to support, or discard it.',
{ value0: size }
)
}
return translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.62340d4439',
'Your review file is ready ({{value0}}). Open it to see what would be sent, then choose whether to send it to support.',
{ value0: size }
)
}
return 'Creates a redacted NDJSON preview for support upload.'
return translate(
'auto.components.settings.PrivacyDiagnosticBundleControls.19ec5e29b3',
'Collects recent app activity and errors into a redacted file you can review before sending. Nothing is uploaded until you choose to send it.'
)
}
function ActionIcon({ busy, icon }: { readonly busy: boolean; readonly icon: React.ReactNode }) {

View File

@ -1,11 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FileText, Folder, Globe, Trash2 } from 'lucide-react'
import { FileText } 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 {
@ -53,33 +52,6 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
}
}, [])
const handleOpenFolder = useCallback(async (): Promise<void> => {
try {
await window.api.diagnostics.openTraceFolder()
} catch {
toast.error(translate("auto.components.settings.PrivacyDiagnosticsSection.b85fe972cd", "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(translate("auto.components.settings.PrivacyDiagnosticsSection.32d767f84d", "Local trace files cleared"))
} catch {
if (mountedRef.current) {
toast.error(translate("auto.components.settings.PrivacyDiagnosticsSection.9666a05580", "Could not clear trace files"))
}
}
}, [refreshStatus])
const handleCollectBundle = useCallback(async (): Promise<void> => {
setCollecting(true)
try {
@ -94,10 +66,15 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
setBundle(nextBundle)
setPreviewOpened(false)
setTicketId(null)
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.a2b3505c77", "Diagnostic bundle preview created"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.a2b3505c77',
'Review file created'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(getDiagnosticsErrorMessage(error, 'Could not create diagnostic bundle'))
toast.error(getDiagnosticsErrorMessage(error, 'Could not create review file'))
}
} finally {
if (mountedRef.current) {
@ -117,10 +94,15 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
return
}
setPreviewOpened(true)
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.db3228e01a", "Diagnostic bundle preview opened"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.db3228e01a',
'Review file opened'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(getDiagnosticsErrorMessage(error, 'Could not open diagnostic bundle preview'))
toast.error(getDiagnosticsErrorMessage(error, 'Could not open review file'))
}
} finally {
if (mountedRef.current) {
@ -139,14 +121,22 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
if (!mountedRef.current) {
return
}
if ('canceled' in upload) {
return
}
activeBundleSubmissionIdRef.current = null
setBundle(null)
setPreviewOpened(false)
setTicketId(upload.ticketId)
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.49fc6c80e8", "Diagnostic bundle uploaded"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.49fc6c80e8',
'Diagnostics sent'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(getDiagnosticsErrorMessage(error, 'Could not upload diagnostic bundle'))
toast.error(getDiagnosticsErrorMessage(error, 'Could not send diagnostics'))
}
} finally {
if (mountedRef.current) {
@ -168,12 +158,15 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
activeBundleSubmissionIdRef.current = null
setBundle(null)
setPreviewOpened(false)
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.860bca9ec9", "Diagnostic bundle preview discarded"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.860bca9ec9',
'Review file discarded'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(
getDiagnosticsErrorMessage(error, 'Could not discard diagnostic bundle preview')
)
toast.error(getDiagnosticsErrorMessage(error, 'Could not discard review file'))
}
} finally {
if (mountedRef.current) {
@ -192,10 +185,20 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
if (!mountedRef.current) {
return
}
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.13eb2c65a1", "Diagnostic ticket copied"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.13eb2c65a1',
'Reference ID copied'
)
)
} catch {
if (mountedRef.current) {
toast.error(translate("auto.components.settings.PrivacyDiagnosticsSection.7a4944595b", "Could not copy diagnostic ticket"))
toast.error(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.7a4944595b',
'Could not copy reference ID'
)
)
}
} finally {
if (mountedRef.current) {
@ -215,10 +218,15 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
return
}
setTicketId(null)
toast.success(translate("auto.components.settings.PrivacyDiagnosticsSection.c18cbe45df", "Uploaded diagnostic bundle deleted"))
toast.success(
translate(
'auto.components.settings.PrivacyDiagnosticsSection.c18cbe45df',
'Sent diagnostics deleted'
)
)
} catch (error) {
if (mountedRef.current) {
toast.error(getDiagnosticsErrorMessage(error, 'Could not delete diagnostic bundle'))
toast.error(getDiagnosticsErrorMessage(error, 'Could not delete sent diagnostics'))
}
} finally {
if (mountedRef.current) {
@ -233,9 +241,12 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
<DiagnosticsDisabledStateNote reason={status.disabledReason} />
) : null}
<Separator />
<Section
<PrivacyDiagnosticsRow
icon={<FileText className="size-4" />}
title={translate("auto.components.settings.PrivacyDiagnosticsSection.af2fc82cde", "Diagnostic bundle")}
title={translate(
'auto.components.settings.PrivacyDiagnosticsSection.af2fc82cde',
'Send app diagnostics to support'
)}
description={getDiagnosticBundleDescription({ bundle, previewOpened, ticketId })}
>
<PrivacyDiagnosticBundleControls
@ -257,49 +268,7 @@ export function PrivacyDiagnosticsSection(): React.JSX.Element {
onDeleteUploadedBundle={handleDeleteUploadedBundle}
onDismissTicket={() => setTicketId(null)}
/>
</Section>
<Separator />
<Section
icon={<Folder className="size-4" />}
title={translate("auto.components.settings.PrivacyDiagnosticsSection.fe81a52cb2", "Open trace folder")}
description={translate("auto.components.settings.PrivacyDiagnosticsSection.5ff57fc986", "Reveals {{value0}} in your file manager.", { value0: status?.traceFilePath || 'the trace folder' })}
>
<Button variant="outline" size="sm" onClick={() => void handleOpenFolder()}>
{translate("auto.components.settings.PrivacyDiagnosticsSection.fe81a52cb2", "Open trace folder")}</Button>
</Section>
<Separator />
<Section
icon={<Trash2 className="size-4" />}
title={translate("auto.components.settings.PrivacyDiagnosticsSection.4ff08ff3a7", "Clear local traces")}
description={translate("auto.components.settings.PrivacyDiagnosticsSection.9ca08a9f8f", "Deletes every rotated trace file on this machine.")}
>
<Button
variant="outline"
size="sm"
disabled={!status?.localFileEnabled}
onClick={() => void handleClear()}
>
{translate("auto.components.settings.PrivacyDiagnosticsSection.4ff08ff3a7", "Clear local traces")}</Button>
</Section>
<Separator />
<Section
icon={<Globe className="size-4" />}
title={translate("auto.components.settings.PrivacyDiagnosticsSection.acc7c66e6e", "OTLP export")}
description={
status?.otlpStatus ??
translate("auto.components.settings.PrivacyDiagnosticsSection.7c9d9820b6", "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 ? translate("auto.components.settings.PrivacyDiagnosticsSection.46ea3fb2d0", "Enabled") : translate("auto.components.settings.PrivacyDiagnosticsSection.1fb00a8995", "Disabled")}
</span>
</Section>
</PrivacyDiagnosticsRow>
</>
)
}
@ -315,14 +284,29 @@ function DiagnosticsDisabledStateNote({
}): 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.'
? translate(
'auto.components.settings.PrivacyDiagnosticsRows.5a7cbe069a',
'DO_NOT_TRACK=1 is set — creating and sending diagnostic files is disabled.'
)
: reason === 'orca_telemetry_disabled'
? 'ORCA_TELEMETRY_DISABLED=1 is set — network-bound diagnostics are disabled. The local trace file is still active.'
? translate(
'auto.components.settings.PrivacyDiagnosticsRows.63d03261d1',
'ORCA_TELEMETRY_DISABLED=1 is set — creating and sending diagnostic files is disabled.'
)
: reason === 'orca_diagnostics_disabled'
? 'ORCA_DIAGNOSTICS_DISABLED=1 is set — every diagnostics surface is off, including local trace writes.'
? translate(
'auto.components.settings.PrivacyDiagnosticsRows.d37e92a06b',
'ORCA_DIAGNOSTICS_DISABLED=1 is set — app diagnostics are off.'
)
: reason === 'ci'
? 'Running in CI — diagnostics are off.'
: 'Diagnostics are disabled by an environment variable.'
? translate(
'auto.components.settings.PrivacyDiagnosticsRows.5ebb31e1fb',
'Running in CI — diagnostics are off.'
)
: translate(
'auto.components.settings.PrivacyDiagnosticsRows.e27c8d45bf',
'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">
@ -331,7 +315,7 @@ function DiagnosticsDisabledStateNote({
)
}
function Section({
function PrivacyDiagnosticsRow({
icon,
title,
description,

View File

@ -47,20 +47,13 @@ export const getPrivacyPaneSearchEntries = createLocalizedCatalog(() => [
title: translate('auto.components.settings.privacy.search.6d258d2ed6', 'Diagnostics'),
description: translate(
'auto.components.settings.privacy.search.8b08f32366',
'Trace files and OTLP export controls.'
'App diagnostics and support sharing controls.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.privacy.search.c0494ff48a',
'diagnostics'
),
...translateSearchKeyword('auto.components.settings.privacy.search.40de3c2f19', 'trace'),
...translateSearchKeyword('auto.components.settings.privacy.search.685c68a81f', 'logs'),
...translateSearchKeyword('auto.components.settings.privacy.search.9ea93ce3d6', 'otlp'),
...translateSearchKeyword(
'auto.components.settings.privacy.search.4a583f3a2f',
'opentelemetry'
),
...translateSearchKeyword('auto.components.settings.privacy.search.1686c07fee', 'support')
]
},

View File

@ -5235,34 +5235,28 @@
"239bf9132b": "Copied install command."
},
"PrivacyDiagnosticBundleControls": {
"dc8404a930": "Create preview",
"dc8404a930": "Create diagnostic file",
"a5acaffdb6": "Discard",
"aca2c8a367": "Upload",
"798b6f0be5": "Open preview",
"aca2c8a367": "Send to support",
"798b6f0be5": "Open review file",
"2ae9a6b63e": "Done",
"7f14a1733c": "Delete bundle",
"2801d4ce22": "Copy ticket"
"7f14a1733c": "Delete sent file",
"2801d4ce22": "Copy reference ID",
"d8be621237": "Open the review file first.",
"61676df223": "Diagnostics sent. Share this reference ID with support: {{value0}}.",
"fd7b3891af": "You opened the review file ({{value0}}). Send that file to support, or discard it.",
"62340d4439": "Your review file is ready ({{value0}}). Open it to see what would be sent, then choose whether to send it to support.",
"19ec5e29b3": "Collects recent app activity and errors into a redacted file you can review before sending. Nothing is uploaded until you choose to send it."
},
"PrivacyDiagnosticsSection": {
"acc7c66e6e": "OTLP export",
"4ff08ff3a7": "Clear local traces",
"9ca08a9f8f": "Deletes every rotated trace file on this machine.",
"fe81a52cb2": "Open trace folder",
"5ff57fc986": "Reveals {{value0}} in your file manager.",
"af2fc82cde": "Diagnostic bundle",
"c18cbe45df": "Uploaded diagnostic bundle deleted",
"7a4944595b": "Could not copy diagnostic ticket",
"13eb2c65a1": "Diagnostic ticket copied",
"860bca9ec9": "Diagnostic bundle preview discarded",
"49fc6c80e8": "Diagnostic bundle uploaded",
"db3228e01a": "Diagnostic bundle preview opened",
"a2b3505c77": "Diagnostic bundle preview created",
"9666a05580": "Could not clear trace files",
"32d767f84d": "Local trace files cleared",
"b85fe972cd": "Could not open trace folder",
"1fb00a8995": "Disabled",
"46ea3fb2d0": "Enabled",
"7c9d9820b6": "Set ORCA_OTLP_TRACES_URL to point Orca at your own OpenTelemetry collector."
"af2fc82cde": "Send app diagnostics to support",
"c18cbe45df": "Sent diagnostics deleted",
"7a4944595b": "Could not copy reference ID",
"13eb2c65a1": "Reference ID copied",
"860bca9ec9": "Review file discarded",
"49fc6c80e8": "Diagnostics sent",
"db3228e01a": "Review file opened",
"a2b3505c77": "Review file created"
},
"PrivacyPane": {
"36e0e2e63b": "environment variable. Unset it and restart to re-enable.",
@ -7248,12 +7242,8 @@
"f7a2d9f137": "Environment variables that disable telemetry transmission.",
"e058a3c98d": "Telemetry environment variables",
"1686c07fee": "support",
"4a583f3a2f": "opentelemetry",
"9ea93ce3d6": "otlp",
"685c68a81f": "logs",
"40de3c2f19": "trace",
"c0494ff48a": "diagnostics",
"8b08f32366": "Trace files and OTLP export controls.",
"8b08f32366": "App diagnostics and support sharing controls.",
"6d258d2ed6": "Diagnostics",
"ead1deded2": "share",
"27a27b2f63": "opt out",
@ -7963,6 +7953,13 @@
"ask": "Ask",
"safeAuto": "Safe Auto",
"off": "Off"
},
"PrivacyDiagnosticsRows": {
"5a7cbe069a": "DO_NOT_TRACK=1 is set — creating and sending diagnostic files is disabled.",
"63d03261d1": "ORCA_TELEMETRY_DISABLED=1 is set — creating and sending diagnostic files is disabled.",
"d37e92a06b": "ORCA_DIAGNOSTICS_DISABLED=1 is set — app diagnostics are off.",
"5ebb31e1fb": "Running in CI — diagnostics are off.",
"e27c8d45bf": "Diagnostics are disabled by an environment variable."
}
},
"right": {

View File

@ -5198,34 +5198,28 @@
"239bf9132b": "Comando de instalación copiado."
},
"PrivacyDiagnosticBundleControls": {
"dc8404a930": "Crear vista previa",
"dc8404a930": "Crear archivo de diagnóstico",
"a5acaffdb6": "Desechar",
"aca2c8a367": "Subir",
"798b6f0be5": "Abrir vista previa",
"aca2c8a367": "Enviar a soporte",
"798b6f0be5": "Abrir archivo para revisar",
"2ae9a6b63e": "Hecho",
"7f14a1733c": "Eliminar paquete",
"2801d4ce22": "Copiar billete"
"7f14a1733c": "Eliminar archivo enviado",
"2801d4ce22": "Copiar ID de referencia",
"d8be621237": "Abre primero el archivo para revisar.",
"61676df223": "Diagnósticos enviados. Comparte este ID de referencia con soporte: {{value0}}.",
"fd7b3891af": "Abriste el archivo para revisar ({{value0}}). Envía ese archivo a soporte o descártalo.",
"62340d4439": "Tu archivo para revisar está listo ({{value0}}). Ábrelo para ver qué se enviaría y luego elige si quieres enviarlo a soporte.",
"19ec5e29b3": "Recopila actividad reciente y errores de la app en un archivo redactado que puedes revisar antes de enviarlo. Nada se sube hasta que elijas enviarlo."
},
"PrivacyDiagnosticsSection": {
"acc7c66e6e": "exportación OTLP",
"4ff08ff3a7": "Borrar rastros locales",
"9ca08a9f8f": "Elimina todos los archivos de seguimiento rotados en esta máquina.",
"fe81a52cb2": "Abrir carpeta de seguimiento",
"5ff57fc986": "Revela {{value0}} en su administrador de archivos.",
"af2fc82cde": "Paquete de diagnóstico",
"c18cbe45df": "Paquete de diagnóstico cargado eliminado",
"7a4944595b": "No se pudo copiar el ticket de diagnóstico",
"13eb2c65a1": "Ticket de diagnóstico copiado",
"860bca9ec9": "Vista previa del paquete de diagnóstico descartada",
"49fc6c80e8": "Paquete de diagnóstico subido",
"db3228e01a": "Vista previa del paquete de diagnóstico abierta",
"a2b3505c77": "Vista previa del paquete de diagnóstico creada",
"9666a05580": "No se pudieron borrar los archivos de seguimiento",
"32d767f84d": "Archivos de seguimiento locales borrados",
"b85fe972cd": "No se pudo abrir la carpeta de seguimiento",
"1fb00a8995": "Desactivado",
"46ea3fb2d0": "Activado",
"7c9d9820b6": "Configure ORCA_OTLP_TRACES_URL para que Orca apunte a su propio recopilador OpenTelemetry."
"af2fc82cde": "Enviar diagnósticos de la app a soporte",
"c18cbe45df": "Diagnósticos enviados eliminados",
"7a4944595b": "No se pudo copiar el ID de referencia",
"13eb2c65a1": "ID de referencia copiado",
"860bca9ec9": "Archivo para revisar descartado",
"49fc6c80e8": "Diagnósticos enviados",
"db3228e01a": "Archivo para revisar abierto",
"a2b3505c77": "Archivo para revisar creado"
},
"PrivacyPane": {
"36e0e2e63b": "variable de entorno. Desconfigúrelo y reinicie para volver a habilitarlo.",
@ -7211,12 +7205,8 @@
"f7a2d9f137": "Variables de entorno que deshabilitan la transmisión de telemetría.",
"e058a3c98d": "Variables de entorno de telemetría",
"1686c07fee": "apoyo",
"4a583f3a2f": "telemetría abierta",
"9ea93ce3d6": "otlp",
"685c68a81f": "registros",
"40de3c2f19": "rastro",
"c0494ff48a": "diagnóstico",
"8b08f32366": "Archivos de seguimiento y controles de exportación OTLP.",
"8b08f32366": "Diagnósticos de la aplicación y controles para compartir con soporte.",
"6d258d2ed6": "Diagnóstico",
"ead1deded2": "compartir",
"27a27b2f63": "optar por no participar",
@ -7963,6 +7953,13 @@
"ask": "Preguntar",
"safeAuto": "Seguro automático",
"off": "Desactivado"
},
"PrivacyDiagnosticsRows": {
"5a7cbe069a": "DO_NOT_TRACK=1 está configurado: crear y enviar archivos de diagnóstico está deshabilitado.",
"63d03261d1": "ORCA_TELEMETRY_DISABLED=1 está configurado: crear y enviar archivos de diagnóstico está deshabilitado.",
"d37e92a06b": "ORCA_DIAGNOSTICS_DISABLED=1 está configurado: los diagnósticos de la app están desactivados.",
"5ebb31e1fb": "Se está ejecutando en CI: los diagnósticos están desactivados.",
"e27c8d45bf": "Los diagnósticos están desactivados por una variable de entorno."
}
},
"right": {

View File

@ -5220,34 +5220,28 @@
"239bf9132b": "インストールコマンドをコピーしました。"
},
"PrivacyDiagnosticBundleControls": {
"dc8404a930": "プレビューの作成",
"dc8404a930": "診断ファイルを作成",
"a5acaffdb6": "破棄",
"aca2c8a367": "アップロード",
"798b6f0be5": "プレビューを開く",
"aca2c8a367": "サポートに送信",
"798b6f0be5": "確認用ファイルを開く",
"2ae9a6b63e": "完了",
"7f14a1733c": "バンドルの削除",
"2801d4ce22": "チケットをコピー"
"7f14a1733c": "送信済みファイルを削除",
"2801d4ce22": "参照 ID をコピー",
"d8be621237": "先に確認用ファイルを開いてください。",
"61676df223": "診断情報を送信しました。サポートにこの参照 ID を共有してください: {{value0}}。",
"fd7b3891af": "確認用ファイル({{value0}})を開きました。このファイルをサポートに送信するか、破棄できます。",
"62340d4439": "確認用ファイル({{value0}})の準備ができました。開いて送信内容を確認してから、サポートに送信するか選択してください。",
"19ec5e29b3": "最近のアプリ操作とエラーを、送信前に確認できる編集済みファイルにまとめます。送信を選ぶまで何もアップロードされません。"
},
"PrivacyDiagnosticsSection": {
"acc7c66e6e": "OTLP エクスポート",
"4ff08ff3a7": "ローカルトレースをクリアする",
"9ca08a9f8f": "このマシン上のローテーションされたすべてのトレース ファイルを削除します。",
"fe81a52cb2": "トレースフォルダーを開く",
"5ff57fc986": "ファイル マネージャーに {{value0}} が表示されます。",
"af2fc82cde": "診断バンドル",
"c18cbe45df": "アップロードされた診断バンドルが削除されました",
"7a4944595b": "診断チケットをコピーできませんでした",
"13eb2c65a1": "診断チケットがコピーされました",
"860bca9ec9": "診断バンドルのプレビューが破棄されました",
"49fc6c80e8": "診断バンドルがアップロードされました",
"db3228e01a": "診断バンドルのプレビューが開きました",
"a2b3505c77": "診断バンドルのプレビューが作成されました",
"9666a05580": "トレースファイルをクリアできませんでした",
"32d767f84d": "ローカル トレース ファイルがクリアされました",
"b85fe972cd": "トレースフォルダーを開けませんでした",
"1fb00a8995": "無効",
"46ea3fb2d0": "有効",
"7c9d9820b6": "Orca が独自の OpenTelemetry コレクターを指すように ORCA_OTLP_TRACES_URL を設定します。"
"af2fc82cde": "アプリ診断情報をサポートに送信",
"c18cbe45df": "送信済みの診断情報を削除しました",
"7a4944595b": "参照 ID をコピーできませんでした",
"13eb2c65a1": "参照 ID をコピーしました",
"860bca9ec9": "確認用ファイルを破棄しました",
"49fc6c80e8": "診断情報を送信しました",
"db3228e01a": "確認用ファイルを開きました",
"a2b3505c77": "確認用ファイルを作成しました"
},
"PrivacyPane": {
"36e0e2e63b": "環境変数。設定を解除し、再起動して再度有効にします。",
@ -7233,12 +7227,8 @@
"f7a2d9f137": "テレメトリ送信を無効にする環境変数。",
"e058a3c98d": "テレメトリ環境変数",
"1686c07fee": "サポート",
"4a583f3a2f": "オープンテレメトリー",
"9ea93ce3d6": "otlp",
"685c68a81f": "ログ",
"40de3c2f19": "トレース",
"c0494ff48a": "診断",
"8b08f32366": "トレース ファイルと OTLP エクスポート コントロール。",
"8b08f32366": "アプリ診断とサポート共有のコントロール。",
"6d258d2ed6": "診断",
"ead1deded2": "共有",
"27a27b2f63": "身を引く",
@ -7963,6 +7953,13 @@
"ask": "確認する",
"safeAuto": "安全に自動",
"off": "オフ"
},
"PrivacyDiagnosticsRows": {
"5a7cbe069a": "DO_NOT_TRACK=1 が設定されています — 診断ファイルの作成と送信は無効です。",
"63d03261d1": "ORCA_TELEMETRY_DISABLED=1 が設定されています — 診断ファイルの作成と送信は無効です。",
"d37e92a06b": "ORCA_DIAGNOSTICS_DISABLED=1 が設定されています — アプリ診断はオフです。",
"5ebb31e1fb": "CI で実行中です — 診断はオフです。",
"e27c8d45bf": "環境変数により診断は無効になっています。"
}
},
"right": {

View File

@ -5183,34 +5183,28 @@
"239bf9132b": "설치 명령을 복사했습니다."
},
"PrivacyDiagnosticBundleControls": {
"dc8404a930": "미리보기 만들기",
"dc8404a930": "진단 파일 만들기",
"a5acaffdb6": "버리기",
"aca2c8a367": "업로드",
"798b6f0be5": "미리보기 열기",
"aca2c8a367": "지원팀에 보내기",
"798b6f0be5": "검토 파일 열기",
"2ae9a6b63e": "완료",
"7f14a1733c": "번들 삭제",
"2801d4ce22": "티켓 복사"
"7f14a1733c": "보낸 파일 삭제",
"2801d4ce22": "참조 ID 복사",
"d8be621237": "먼저 검토 파일을 여세요.",
"61676df223": "진단 정보를 보냈습니다. 이 참조 ID를 지원팀에 공유하세요: {{value0}}.",
"fd7b3891af": "검토 파일({{value0}})을 열었습니다. 이 파일을 지원팀에 보내거나 버릴 수 있습니다.",
"62340d4439": "검토 파일({{value0}})이 준비되었습니다. 열어서 보낼 내용을 확인한 다음 지원팀에 보낼지 선택하세요.",
"19ec5e29b3": "최근 앱 활동과 오류를 보내기 전에 검토할 수 있는 수정된 파일로 모읍니다. 보내기를 선택하기 전에는 아무것도 업로드되지 않습니다."
},
"PrivacyDiagnosticsSection": {
"acc7c66e6e": "OTLP 내보내기",
"4ff08ff3a7": "로컬 흔적 지우기",
"9ca08a9f8f": "이 시스템에서 회전된 모든 추적 파일을 삭제합니다.",
"fe81a52cb2": "추적 폴더 열기",
"5ff57fc986": "파일 관리자에 {{value0}}이 표시됩니다.",
"af2fc82cde": "진단 번들",
"c18cbe45df": "업로드된 진단 번들이 삭제되었습니다.",
"7a4944595b": "진단 티켓을 복사할 수 없습니다.",
"13eb2c65a1": "진단 티켓이 복사되었습니다.",
"860bca9ec9": "진단 번들 미리보기가 삭제되었습니다.",
"49fc6c80e8": "진단 번들이 업로드되었습니다.",
"db3228e01a": "진단 번들 미리보기 열림",
"a2b3505c77": "진단 번들 미리보기가 생성되었습니다.",
"9666a05580": "추적 파일을 지울 수 없습니다.",
"32d767f84d": "로컬 추적 파일이 지워졌습니다.",
"b85fe972cd": "추적 폴더를 열 수 없습니다.",
"1fb00a8995": "비활성",
"46ea3fb2d0": "활성화됨",
"7c9d9820b6": "Orca가 자체 OpenTelemetry 수집기를 가리키도록 ORCA_OTLP_TRACES_URL을 설정합니다."
"af2fc82cde": "앱 진단 정보를 지원팀에 보내기",
"c18cbe45df": "보낸 진단 정보가 삭제되었습니다",
"7a4944595b": "참조 ID를 복사할 수 없습니다",
"13eb2c65a1": "참조 ID가 복사되었습니다",
"860bca9ec9": "검토 파일을 버렸습니다",
"49fc6c80e8": "진단 정보를 보냈습니다",
"db3228e01a": "검토 파일을 열었습니다",
"a2b3505c77": "검토 파일을 만들었습니다"
},
"PrivacyPane": {
"36e0e2e63b": "환경 변수. 다시 활성화하려면 설정을 해제하고 다시 시작하세요.",
@ -7196,12 +7190,8 @@
"f7a2d9f137": "텔레메트리 전송을 비활성화하는 환경 변수입니다.",
"e058a3c98d": "텔레메트리 환경 변수",
"1686c07fee": "지원",
"4a583f3a2f": "개방형 텔레메트리",
"9ea93ce3d6": "otlp",
"685c68a81f": "로그",
"40de3c2f19": "추적",
"c0494ff48a": "진단",
"8b08f32366": "추적 파일 및 OTLP 내보내기 제어.",
"8b08f32366": "앱 진단 및 지원 공유 제어.",
"6d258d2ed6": "진단",
"ead1deded2": "공유",
"27a27b2f63": "탈퇴",
@ -7963,6 +7953,13 @@
"ask": "묻기",
"safeAuto": "안전 자동",
"off": "끄기"
},
"PrivacyDiagnosticsRows": {
"5a7cbe069a": "DO_NOT_TRACK=1이 설정되어 있어 진단 파일 만들기와 보내기가 비활성화되었습니다.",
"63d03261d1": "ORCA_TELEMETRY_DISABLED=1이 설정되어 있어 진단 파일 만들기와 보내기가 비활성화되었습니다.",
"d37e92a06b": "ORCA_DIAGNOSTICS_DISABLED=1이 설정되어 있어 앱 진단이 꺼져 있습니다.",
"5ebb31e1fb": "CI에서 실행 중입니다. 진단이 꺼져 있습니다.",
"e27c8d45bf": "환경 변수로 인해 진단이 비활성화되었습니다."
}
},
"right": {

View File

@ -5183,34 +5183,28 @@
"239bf9132b": "复制安装命令。"
},
"PrivacyDiagnosticBundleControls": {
"dc8404a930": "创建预览",
"dc8404a930": "创建诊断文件",
"a5acaffdb6": "放弃",
"aca2c8a367": "上传",
"798b6f0be5": "打开预览",
"aca2c8a367": "发送给支持团队",
"798b6f0be5": "打开查看文件",
"2ae9a6b63e": "完成",
"7f14a1733c": "删除捆绑包",
"2801d4ce22": "复制工单"
"7f14a1733c": "删除已发送文件",
"2801d4ce22": "复制参考 ID",
"d8be621237": "请先打开查看文件。",
"61676df223": "诊断信息已发送。请将此参考 ID 分享给支持团队:{{value0}}。",
"fd7b3891af": "你已打开查看文件({{value0}})。可以将此文件发送给支持团队,或将其丢弃。",
"62340d4439": "查看文件已准备好({{value0}})。打开它查看将发送的内容,然后选择是否发送给支持团队。",
"19ec5e29b3": "将最近的应用活动和错误收集到一个已编辑的文件中,供你在发送前查看。只有在你选择发送后才会上传。"
},
"PrivacyDiagnosticsSection": {
"acc7c66e6e": "OTLP 导出",
"4ff08ff3a7": "清除局部痕迹",
"9ca08a9f8f": "删除该计算机上的每个轮换跟踪文件。",
"fe81a52cb2": "打开跟踪文件夹",
"5ff57fc986": "在文件管理器中显示 {{value0}}。",
"af2fc82cde": "诊断包",
"c18cbe45df": "上传的诊断包已删除",
"7a4944595b": "无法复制诊断票",
"13eb2c65a1": "已复制诊断票",
"860bca9ec9": "诊断包预览已放弃",
"49fc6c80e8": "诊断包已上传",
"db3228e01a": "诊断包预览已打开",
"a2b3505c77": "创建诊断包预览",
"9666a05580": "无法清除跟踪文件",
"32d767f84d": "本地跟踪文件已清除",
"b85fe972cd": "无法打开跟踪文件夹",
"1fb00a8995": "已禁用",
"46ea3fb2d0": "启用",
"7c9d9820b6": "设置 ORCA_OTLP_TRACES_URL 将 Orca 指向您自己的 OpenTelemetry 收集器。"
"af2fc82cde": "将应用诊断信息发送给支持团队",
"c18cbe45df": "已删除发送的诊断信息",
"7a4944595b": "无法复制参考 ID",
"13eb2c65a1": "已复制参考 ID",
"860bca9ec9": "已丢弃查看文件",
"49fc6c80e8": "诊断信息已发送",
"db3228e01a": "已打开查看文件",
"a2b3505c77": "已创建查看文件"
},
"PrivacyPane": {
"36e0e2e63b": "环境变量。取消设置并重新启动以重新启用。",
@ -7196,12 +7190,8 @@
"f7a2d9f137": "禁用遥测传输的环境变量。",
"e058a3c98d": "遥测环境变量",
"1686c07fee": "支持",
"4a583f3a2f": "开放式遥测",
"9ea93ce3d6": "otlp",
"685c68a81f": "日志",
"40de3c2f19": "痕迹",
"c0494ff48a": "诊断",
"8b08f32366": "跟踪文件和 OTLP 导出控制。",
"8b08f32366": "应用诊断和支持共享控件。",
"6d258d2ed6": "诊断",
"ead1deded2": "分享",
"27a27b2f63": "选择退出",
@ -7963,6 +7953,13 @@
"ask": "询问",
"safeAuto": "安全自动",
"off": "关闭"
},
"PrivacyDiagnosticsRows": {
"5a7cbe069a": "已设置 DO_NOT_TRACK=1创建和发送诊断文件已停用。",
"63d03261d1": "已设置 ORCA_TELEMETRY_DISABLED=1创建和发送诊断文件已停用。",
"d37e92a06b": "已设置 ORCA_DIAGNOSTICS_DISABLED=1应用诊断已关闭。",
"5ebb31e1fb": "正在 CI 中运行,诊断已关闭。",
"e27c8d45bf": "诊断已被环境变量停用。"
}
},
"right": {

View File

@ -485,20 +485,15 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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.')),
collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')),
openBundlePreview: () => Promise.reject(new Error('Review files 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.'))
uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')),
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.'))
},
session: {
// hostId mirrors the desktop bridge: omitted/'local' targets the existing

View File

@ -1,5 +1,5 @@
import { execFileSync } from 'child_process'
import { existsSync, readFileSync, realpathSync } from 'fs'
import { existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'fs'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@ -91,8 +91,16 @@ async function readDiagnosticsStatus(page: Page): Promise<DiagnosticsStatus> {
return page.evaluate(() => window.api.diagnostics.getStatus() as Promise<DiagnosticsStatus>)
}
async function clearTraceFile(page: Page): Promise<void> {
await page.evaluate(() => window.api.diagnostics.clearTraces())
function clearTraceFile(diagnostics: DiagnosticsStatus): void {
if (existsSync(diagnostics.traceFilePath)) {
writeFileSync(diagnostics.traceFilePath, '', 'utf8')
}
for (let i = 1; i < 10; i++) {
const rotatedPath = `${diagnostics.traceFilePath}.${i}`
if (existsSync(rotatedPath)) {
unlinkSync(rotatedPath)
}
}
}
async function flushTraceFile(page: Page, diagnostics: DiagnosticsStatus): Promise<void> {
@ -195,7 +203,7 @@ test.describe('Git no-upstream polling churn repro', () => {
const diagnostics = await readDiagnosticsStatus(orcaPage)
test.skip(!diagnostics.localFileEnabled, 'local diagnostic traces are disabled')
await clearTraceFile(orcaPage)
clearTraceFile(diagnostics)
const measurement = await measureRendererDuringPolling(orcaPage)
await flushTraceFile(orcaPage, diagnostics)
const counts = readGitProbeFailureCounts(diagnostics.traceFilePath, repoPath)