Simplify support diagnostics settings (#5554)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
928c1ee106
commit
00df8288dc
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -135,8 +135,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
|||
'IDE',
|
||||
'ui',
|
||||
'UI',
|
||||
'otlp',
|
||||
'OTLP',
|
||||
'calt',
|
||||
'ai',
|
||||
'AI',
|
||||
|
|
|
|||
|
|
@ -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 を再起動',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue