diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index 127930312..35faf2670 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -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', diff --git a/config/scripts/locale-phrase-fixes.mjs b/config/scripts/locale-phrase-fixes.mjs index 9ea7b5c87..5b2522013 100644 --- a/config/scripts/locale-phrase-fixes.mjs +++ b/config/scripts/locale-phrase-fixes.mjs @@ -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' }, diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index efaa6fb0e..42da6b4bf 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -135,8 +135,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'IDE', 'ui', 'UI', - 'otlp', - 'OTLP', 'calt', 'ai', 'AI', diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index a64379330..4bda41106 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -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 を再起動', diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts index 3531af823..b30d013f6 100644 --- a/src/main/ipc/diagnostics.test.ts +++ b/src/main/ipc/diagnostics.test.ts @@ -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) diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts index 73c3ede99..53d8d2f6e 100644 --- a/src/main/ipc/diagnostics.ts +++ b/src/main/ipc/diagnostics.ts @@ -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 +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 { +async function confirmBundleUpload(bundle: CollectedBundle): Promise { 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 => { - // 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 => { + async (_event, bundleSubmissionId: unknown): Promise => { // Why: the renderer is in the threat model. Upload only a payload main // collected and retained for preview, never renderer-supplied bytes. const pendingForConfirmation = getPendingBundleForUpload(bundleSubmissionId) @@ -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) { diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index ae61f155f..812e354d5 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -17,8 +17,8 @@ // a) POST `/diagnostics/token` → token + upload_url // b) POST `` with `Authorization: Bearer ` and the // collected NDJSON payload. Returns ticket ID. -// 4. (renderer) — surface the ticket ID; offer "Copy ticket" and -// "Delete this bundle" controls. Delete posts only the ticket ID. +// 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 diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index d18c5c8c6..9ab216d50 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -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 - if (r.type === 'effect-span') { - const { type: _t, ...spanFields } = r - void _t - exporter.exportSpan(spanFields as Parameters[0]) - } - } catch { - /* swallow */ - } - } - }, - flush(): void { - try { - localSink.flush() - } catch { - /* */ - } - if (exporter) { - // Async flush — fire-and-forget on this synchronous path. The - // shutdown path awaits the OTLP flush separately. - void exporter.flush() - } - }, - close(): void { - try { - localSink.close() - } catch { - /* */ - } - if (exporter) { - // Fire-and-forget flush before close — prevents queued-span loss when - // callers invoke close() without separately awaiting flush(). Same - // fire-and-forget pattern documented above in flush(). - void exporter.flush() - exporter.close() - } - } - } -} - -/** Create the local file sink, install the composite (local + optional OTLP) - * as the active tracer sink, and update module-level `sink`. The OTLP - * exporter is reused from the current module-level `otlp` reference — only - * the local sink is recreated. Used by both `initObservability` (where - * `otlp` is freshly created) and `clearLocalTraces` (where `otlp` is - * already running and must be preserved across the sink swap). */ +/** 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 { - // 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 — diff --git a/src/main/observability/local-file-sink.test.ts b/src/main/observability/local-file-sink.test.ts index 442b70637..4420c0432 100644 --- a/src/main/observability/local-file-sink.test.ts +++ b/src/main/observability/local-file-sink.test.ts @@ -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', () => { diff --git a/src/main/observability/local-file-sink.ts b/src/main/observability/local-file-sink.ts index 07e581942..7e239cbfe 100644 --- a/src/main/observability/local-file-sink.ts +++ b/src/main/observability/local-file-sink.ts @@ -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 */ - } - } - } -} diff --git a/src/main/observability/otlp-exporter-http-cleanup.test.ts b/src/main/observability/otlp-exporter-http-cleanup.test.ts deleted file mode 100644 index 754846671..000000000 --- a/src/main/observability/otlp-exporter-http-cleanup.test.ts +++ /dev/null @@ -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() - } - }) -}) diff --git a/src/main/observability/otlp-exporter.test.ts b/src/main/observability/otlp-exporter.test.ts deleted file mode 100644 index f1658de2b..000000000 --- a/src/main/observability/otlp-exporter.test.ts +++ /dev/null @@ -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((resolve) => { - if (!server) { - resolve() - return - } - server.close(() => { - server = null - resolve() - }) - }) -) - -function listen(handler: RequestListener): Promise { - server = createServer(handler) - return new Promise((resolve) => { - server?.listen(0, '127.0.0.1', () => { - const address = server?.address() - if (address && typeof address === 'object') { - resolve(`http://127.0.0.1:${address.port}`) - } - }) - }) -} - -function span(overrides: Partial = {}): RedactableSpan { - return { - name: 'unit', - traceId: 'a'.repeat(32), - spanId: 'b'.repeat(16), - kind: 'internal', - startTimeUnixNano: '1000', - endTimeUnixNano: '2000', - durationMs: 1.0, - attributes: {}, - events: [], - exit: { _tag: 'Success' }, - ...overrides - } -} - -describe('otlp-exporter — env gating', () => { - it('returns null when ORCA_OTLP_TRACES_URL is unset', () => { - const before = process.env.ORCA_OTLP_TRACES_URL - delete process.env.ORCA_OTLP_TRACES_URL - expect(createOtlpExporterFromEnv()).toBeNull() - if (before !== undefined) { - process.env.ORCA_OTLP_TRACES_URL = before - } - }) -}) - -describe('otlp-exporter — flushing', () => { - it('serializes batch POSTs and awaits in-flight flushes', async () => { - let activeRequests = 0 - let maxConcurrentRequests = 0 - let requestCount = 0 - const baseUrl = await listen((req, res) => { - req.resume() - requestCount += 1 - activeRequests += 1 - maxConcurrentRequests = Math.max(maxConcurrentRequests, activeRequests) - setTimeout(() => { - activeRequests -= 1 - res.setHeader('content-type', 'application/json') - res.end('{}') - }, 20) - }) - const exporter = createOtlpExporter({ - tracesUrl: `${baseUrl}/v1/traces`, - serviceName: 'orca-test', - timeoutMs: 1_000 - }) - - for (let i = 0; i < 128; i++) { - exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') })) - } - await exporter.flush() - exporter.close() - - expect(requestCount).toBe(2) - expect(maxConcurrentRequests).toBe(1) - }) - - it('caps queued spans and keeps the newest records', async () => { - const receivedSpanIds: string[] = [] - const baseUrl = await listen((req, res) => { - let body = '' - req.setEncoding('utf8') - req.on('data', (chunk) => { - body += chunk - }) - req.on('end', () => { - const payload = JSON.parse(body) as ReturnType - receivedSpanIds.push( - ...payload.resourceSpans.flatMap((resourceSpan) => - resourceSpan.scopeSpans.flatMap((scopeSpan) => - scopeSpan.spans.map((exportedSpan) => exportedSpan.spanId) - ) - ) - ) - res.setHeader('content-type', 'application/json') - res.end('{}') - }) - }) - const exporter = createOtlpExporter({ - tracesUrl: `${baseUrl}/v1/traces`, - serviceName: 'orca-test', - timeoutMs: 1_000, - maxQueueSpans: 4 - }) - - for (let i = 0; i < 6; i++) { - exporter.exportSpan(span({ spanId: i.toString(16).padStart(16, '0') })) - } - await exporter.flush() - exporter.close() - - expect(receivedSpanIds).toEqual([ - '0000000000000002', - '0000000000000003', - '0000000000000004', - '0000000000000005' - ]) - }) -}) - -describe('otlp-exporter — attribute encoding', () => { - it('encodes strings, ints, floats, bools', () => { - const out = toOtlpAttributes({ s: 'x', i: 5, f: 1.5, b: true }) - expect(out).toEqual([ - { key: 's', value: { stringValue: 'x' } }, - { key: 'i', value: { intValue: '5' } }, - { key: 'f', value: { doubleValue: 1.5 } }, - { key: 'b', value: { boolValue: true } } - ]) - }) - it('JSON-encodes objects and arrays', () => { - const out = toOtlpAttributes({ list: [1, 2], obj: { a: 1 } }) - expect(out).toContainEqual({ key: 'list', value: { stringValue: '[1,2]' } }) - expect(out).toContainEqual({ key: 'obj', value: { stringValue: '{"a":1}' } }) - }) - it('drops null/undefined', () => { - const out = toOtlpAttributes({ keep: 'x', drop: null, alsodrop: undefined }) - expect(out.find((kv) => kv.key === 'drop')).toBeUndefined() - expect(out.find((kv) => kv.key === 'alsodrop')).toBeUndefined() - expect(out.find((kv) => kv.key === 'keep')).toBeDefined() - }) -}) - -describe('otlp-exporter — span kind mapping', () => { - it('maps OTel SpanKind names to numeric codes', () => { - expect(spanKindToOtlp('internal')).toBe(1) - expect(spanKindToOtlp('server')).toBe(2) - expect(spanKindToOtlp('client')).toBe(3) - expect(spanKindToOtlp('producer')).toBe(4) - expect(spanKindToOtlp('consumer')).toBe(5) - expect(spanKindToOtlp('unknown')).toBe(1) - }) -}) - -describe('otlp-exporter — payload encoding', () => { - it('builds a valid OTLP payload skeleton with service.name', () => { - const out = encodeOtlpPayload('orca-test', [span()]) - expect(out.resourceSpans).toHaveLength(1) - expect(out.resourceSpans[0].resource.attributes).toContainEqual({ - key: 'service.name', - value: { stringValue: 'orca-test' } - }) - expect(out.resourceSpans[0].scopeSpans[0].spans).toHaveLength(1) - }) - - it('includes parentSpanId when present', () => { - const out = encodeOtlpPayload('s', [span({ parentSpanId: 'p'.repeat(16) })]) - const s = out.resourceSpans[0].scopeSpans[0].spans[0] - expect(s.parentSpanId).toBe('p'.repeat(16)) - }) - - it('omits parentSpanId for root spans', () => { - const out = encodeOtlpPayload('s', [span()]) - const s = out.resourceSpans[0].scopeSpans[0].spans[0] - expect(s.parentSpanId).toBeUndefined() - }) - - it('sets ERROR status on Failure exits', () => { - const out = encodeOtlpPayload('s', [span({ exit: { _tag: 'Failure', cause: 'boom' } })]) - const s = out.resourceSpans[0].scopeSpans[0].spans[0] - expect(s.status?.code).toBe(2) - expect(s.status?.message).toBe('boom') - }) - - it('omits status for Success exits', () => { - const out = encodeOtlpPayload('s', [span()]) - const s = out.resourceSpans[0].scopeSpans[0].spans[0] - expect(s.status).toBeUndefined() - }) - - it('encodes events with their attributes', () => { - const out = encodeOtlpPayload('s', [ - span({ - events: [{ name: 'log', timeUnixNano: '1500', attributes: { msg: 'hi' } }] - }) - ]) - const s = out.resourceSpans[0].scopeSpans[0].spans[0] - expect(s.events).toHaveLength(1) - expect(s.events[0].name).toBe('log') - expect(s.events[0].timeUnixNano).toBe('1500') - expect(s.events[0].attributes).toContainEqual({ - key: 'msg', - value: { stringValue: 'hi' } - }) - }) -}) diff --git a/src/main/observability/otlp-exporter.ts b/src/main/observability/otlp-exporter.ts deleted file mode 100644 index 7bdfe8c77..000000000 --- a/src/main/observability/otlp-exporter.ts +++ /dev/null @@ -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 - /** Stop the periodic timer. Called from shutdown. */ - close(): void -} - -const FLUSH_INTERVAL_MS = 1_000 -const MAX_BATCH = 64 -const DEFAULT_MAX_QUEUE_SPANS = 1_024 - -type InternalSpan = { - span: RedactableSpan -} - -/** - * Build an exporter from env vars. Returns `null` if the relevant env vars - * are not set — callers can compose this with the consent gate by simply - * not invoking it when consent disallows network paths. - */ -export function createOtlpExporterFromEnv(): OtlpExporter | null { - const tracesUrl = process.env.ORCA_OTLP_TRACES_URL - if (!tracesUrl || tracesUrl.length === 0) { - return null - } - const serviceName = process.env.ORCA_OTLP_SERVICE_NAME ?? 'orca-desktop' - return createOtlpExporter({ tracesUrl, serviceName }) -} - -export function createOtlpExporter(opts: OtlpExporterOptions): OtlpExporter { - let queue: InternalSpan[] = [] - let timer: NodeJS.Timeout | null = null - let warned = false - let closed = false - let flushPromise: Promise | null = null - const maxQueueSpans = Math.max(1, Math.floor(opts.maxQueueSpans ?? DEFAULT_MAX_QUEUE_SPANS)) - - function ensureTimer(): void { - if (timer || closed) { - return - } - timer = setTimeout(() => { - timer = null - void runFlushLoop() - }, FLUSH_INTERVAL_MS) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - - async function flushBatch(): Promise { - if (queue.length === 0) { - return - } - const batch = queue.splice(0, MAX_BATCH) - const payload = encodeOtlpPayload( - opts.serviceName, - batch.map((b) => b.span) - ) - try { - await postJson(opts.tracesUrl, payload, opts.timeoutMs ?? 5_000) - } catch (err) { - if (!warned) { - warned = true - console.warn('[observability:otlp] export failed; further failures will be silent:', err) - } - // Drop the batch on the floor; this is a best-effort path. Re-queueing - // forever would risk unbounded memory growth on a misconfigured URL. - } - } - - function runFlushLoop(): Promise { - if (flushPromise) { - return flushPromise - } - // Why: MAX_BATCH flushes can be triggered by both the timer and exportSpan. - // Serialize them so shutdown can await the currently-posting batch and a - // slow collector cannot create overlapping POST bursts. - flushPromise = (async () => { - while (queue.length > 0) { - await flushBatch() - } - })().finally(() => { - flushPromise = null - }) - return flushPromise - } - - return { - exportSpan(span: RedactableSpan): void { - if (closed) { - return - } - // Apply the redactor regardless of whether the caller already did — - // idempotence makes this safe and the OTLP destination is one of the - // three locations the spec calls for redactor application. - const redacted = redactSpan(span, 'client') - queue.push({ span: redacted }) - if (queue.length > maxQueueSpans) { - // Why: a slow or misconfigured collector must not turn optional OTLP - // export into unbounded memory growth. Keep the newest spans because - // they are closest to the user action being diagnosed. - queue.splice(0, queue.length - maxQueueSpans) - } - if (queue.length >= MAX_BATCH) { - void runFlushLoop() - } else { - ensureTimer() - } - }, - async flush(): Promise { - if (timer) { - clearTimeout(timer) - timer = null - } - await runFlushLoop() - }, - close(): void { - if (timer) { - clearTimeout(timer) - timer = null - } - closed = true - } - } -} - -// ── OTLP/HTTP JSON encoding ────────────────────────────────────────────── -// -// Minimal subset of the OTLP trace ProtoBuf JSON encoding — the parts an -// LGTM / Tempo / Jaeger receiver uses. Full schema: opentelemetry-proto's -// `trace/v1/trace.proto`. Anything we don't emit (status code, scope, links) -// is optional in the spec and defaults sensibly receiver-side. - -type OtlpKeyValue = { - key: string - value: - | { stringValue: string } - | { intValue: string } - | { boolValue: boolean } - | { doubleValue: number } -} - -function toOtlpAttributes(input: Record): OtlpKeyValue[] { - const out: OtlpKeyValue[] = [] - for (const [k, v] of Object.entries(input)) { - if (v === null || v === undefined) { - continue - } - if (typeof v === 'string') { - out.push({ key: k, value: { stringValue: v } }) - } else if (typeof v === 'boolean') { - out.push({ key: k, value: { boolValue: v } }) - } else if (typeof v === 'number') { - // Integers fit in intValue (OTLP requires string-encoded int); floats go - // to doubleValue. JS Number distinguishes via `Number.isInteger`. - if (Number.isInteger(v)) { - out.push({ key: k, value: { intValue: String(v) } }) - } else { - out.push({ key: k, value: { doubleValue: v } }) - } - } else { - // Objects / arrays — flatten to a JSON string. OTLP supports an - // array/kvlist value but the marginal cost of a structured encoder is - // not worth it for a v1 minimal exporter. The redactor has already - // run, so the JSON is safe to ship. - out.push({ key: k, value: { stringValue: JSON.stringify(v) } }) - } - } - return out -} - -function eventToOtlp(ev: SpanEvent): { - timeUnixNano: string - name: string - attributes: OtlpKeyValue[] -} { - return { - timeUnixNano: ev.timeUnixNano, - name: ev.name, - attributes: toOtlpAttributes(ev.attributes as Record) - } -} - -type OtlpPayload = { - resourceSpans: { - resource: { attributes: OtlpKeyValue[] } - scopeSpans: { - scope: { name: string } - spans: { - traceId: string - spanId: string - parentSpanId?: string - name: string - kind: number - startTimeUnixNano: string - endTimeUnixNano: string - attributes: OtlpKeyValue[] - events: ReturnType[] - status?: { code: number; message?: string } - }[] - }[] - }[] -} - -function spanKindToOtlp(kind: string): number { - // SPAN_KIND_INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5. - switch (kind) { - case 'server': - return 2 - case 'client': - return 3 - case 'producer': - return 4 - case 'consumer': - return 5 - default: - return 1 - } -} - -function encodeOtlpPayload(serviceName: string, spans: RedactableSpan[]): OtlpPayload { - return { - resourceSpans: [ - { - resource: { - attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] - }, - scopeSpans: [ - { - scope: { name: 'orca-observability' }, - spans: spans.map((s) => { - // STATUS_CODE: UNSET=0, OK=1, ERROR=2 — Failure → ERROR, the - // others map to UNSET so receivers default-render as "no - // status" rather than synthesizing OK. - const status = - s.exit._tag === 'Failure' - ? { code: 2, ...(s.exit.cause ? { message: s.exit.cause } : {}) } - : undefined - return { - traceId: s.traceId, - spanId: s.spanId, - ...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}), - name: s.name, - kind: spanKindToOtlp(s.kind), - startTimeUnixNano: s.startTimeUnixNano, - endTimeUnixNano: s.endTimeUnixNano, - attributes: toOtlpAttributes(s.attributes as Record), - events: s.events.map(eventToOtlp), - ...(status ? { status } : {}) - } - }) - } - ] - } - ] - } -} - -function postJson(url: string, body: unknown, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let 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 -} diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts index 2f4d4e792..4787abcd5 100644 --- a/src/main/observability/redactor.ts +++ b/src/main/observability/redactor.ts @@ -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 diff --git a/src/main/observability/tracer.ts b/src/main/observability/tracer.ts index 62cfcf293..bb1739acf 100644 --- a/src/main/observability/tracer.ts +++ b/src/main/observability/tracer.ts @@ -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() -// 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( name: string, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 47790dd72..4d4ef9ef1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 @@ -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 - /** 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 - openTraceFolder: () => Promise - clearTraces: () => Promise collectBundle: (lookbackMinutes?: number) => Promise openBundlePreview: (bundleSubmissionId: string) => Promise discardBundlePreview: (bundleSubmissionId: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 08a337dbd..3e75bffa6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('diagnostics:getStatus'), - openTraceFolder: (): Promise => ipcRenderer.invoke('diagnostics:openTraceFolder'), - clearTraces: (): Promise => ipcRenderer.invoke('diagnostics:clearTraces'), collectBundle: (lookbackMinutes?: number): Promise => ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), openBundlePreview: (bundleSubmissionId: string): Promise => diff --git a/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx b/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx index 54af2be9a..d231cd221 100644 --- a/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx +++ b/src/renderer/src/components/settings/PrivacyDiagnosticBundleControls.tsx @@ -55,7 +55,7 @@ export function PrivacyDiagnosticBundleControls({ } /> {translate( 'auto.components.settings.PrivacyDiagnosticBundleControls.2801d4ce22', - 'Copy ticket' + 'Copy reference ID' )} - ) @@ -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 }) { diff --git a/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx index 70bfd0fb7..78d664988 100644 --- a/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx +++ b/src/renderer/src/components/settings/PrivacyDiagnosticsSection.tsx @@ -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 => { - 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 => { - 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 => { 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 { ) : null} -
} - 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 })} > setTicketId(null)} /> -
- -
} - 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' })} - > - -
- -
} - 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.")} - > - -
- -
} - 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.") - } - > - - {status?.otlpEnabled ? translate("auto.components.settings.PrivacyDiagnosticsSection.46ea3fb2d0", "Enabled") : translate("auto.components.settings.PrivacyDiagnosticsSection.1fb00a8995", "Disabled")} - -
+ ) } @@ -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 (
@@ -331,7 +315,7 @@ function DiagnosticsDisabledStateNote({ ) } -function Section({ +function PrivacyDiagnosticsRow({ icon, title, description, diff --git a/src/renderer/src/components/settings/privacy-search.ts b/src/renderer/src/components/settings/privacy-search.ts index ab74dab96..6e0c744f5 100644 --- a/src/renderer/src/components/settings/privacy-search.ts +++ b/src/renderer/src/components/settings/privacy-search.ts @@ -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') ] }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbeaa9dc4..4cbb616e1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0696c87b5..2fe3e365c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 46b3d2252..f71af3fcf 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e0099987a..a07b30d10 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2620bbdbf..fac82d234 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 05afee94e..919d1bf61 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -485,20 +485,15 @@ function createWebPreloadApi(): Partial { 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 diff --git a/tests/e2e/git-no-upstream-polling-churn.spec.ts b/tests/e2e/git-no-upstream-polling-churn.spec.ts index 4cb0ee2ac..44709d8f5 100644 --- a/tests/e2e/git-no-upstream-polling-churn.spec.ts +++ b/tests/e2e/git-no-upstream-polling-churn.spec.ts @@ -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 { return page.evaluate(() => window.api.diagnostics.getStatus() as Promise) } -async function clearTraceFile(page: Page): Promise { - 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 { @@ -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)