mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: Make diagnostics actionable for runtime failures
Diagnostics exports previously preserved only compact summaries, which made user-uploaded issue bundles hard to debug. This records richer sanitized details from server, CLI, SDK, browser, React, and desktop API failure paths while keeping request bodies and secrets out of client-side reports. Constraint: Diagnostic bundles must be useful for GitHub issues without including chat contents, file contents, full environments, or API keys. Rejected: Only expand the Settings UI summary | the exported bundle would still miss hidden runtime failures. Confidence: high Scope-risk: moderate Directive: Keep diagnostics write paths best-effort and non-blocking; do not add request-body capture without a redaction review. Tested: bun test src/server/__tests__/diagnostics-service.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test src/api/client.test.ts src/__tests__/diagnosticsSettings.test.tsx --run Tested: bun run check:server Tested: bun run check:desktop Tested: agent-browser E2E on ports 37652/41752 captured client_unhandled_rejection and exported a redacted tar.gz bundle
This commit is contained in:
parent
2459488703
commit
f2437bf724
@ -87,6 +87,7 @@ describe('Settings > Diagnostics tab', () => {
|
||||
diagnosticsApiMock.getStatus.mockResolvedValue({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-diagnostics.jsonl',
|
||||
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
|
||||
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
|
||||
retentionDays: 7,
|
||||
@ -104,6 +105,10 @@ describe('Settings > Diagnostics tab', () => {
|
||||
severity: 'error',
|
||||
summary: 'CLI exited during startup with code 1',
|
||||
sessionId: 'session-1',
|
||||
details: {
|
||||
exitCode: 1,
|
||||
capturedOutput: 'stderr:\nprovider rejected request',
|
||||
},
|
||||
}],
|
||||
})
|
||||
diagnosticsApiMock.exportBundle.mockResolvedValue({
|
||||
@ -131,6 +136,7 @@ describe('Settings > Diagnostics tab', () => {
|
||||
expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports a diagnostics bundle from the settings page', async () => {
|
||||
@ -159,6 +165,7 @@ describe('Settings > Diagnostics tab', () => {
|
||||
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
|
||||
},
|
||||
})
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
@ -169,6 +176,7 @@ describe('Settings > Diagnostics tab', () => {
|
||||
await waitFor(() => {
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('capturedOutput'))
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.')
|
||||
} finally {
|
||||
|
||||
67
desktop/src/api/client.test.ts
Normal file
67
desktop/src/api/client.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, rawRecordDiagnosticEvent } from './client'
|
||||
|
||||
describe('api diagnostics reporting', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports non-diagnostics API failures without request bodies', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Nope' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await expect(api.post('/api/providers/test', { apiKey: 'sk-should-not-report' })).rejects.toThrow('Nope')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
const diagnosticCall = fetchMock.mock.calls[1]
|
||||
expect(diagnosticCall).toBeDefined()
|
||||
const [diagnosticUrl, diagnosticInit] = diagnosticCall!
|
||||
expect(String(diagnosticUrl)).toContain('/api/diagnostics/events')
|
||||
const body = JSON.parse(String((diagnosticInit as RequestInit).body))
|
||||
expect(body.type).toBe('client_api_request_failed')
|
||||
expect(body.details.path).toBe('/api/providers/test')
|
||||
expect(JSON.stringify(body)).not.toContain('sk-should-not-report')
|
||||
})
|
||||
|
||||
it('does not recursively report diagnostics endpoint failures', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await expect(api.get('/api/diagnostics/status')).rejects.toThrow('diagnostics down')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('can report raw client exceptions', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await rawRecordDiagnosticEvent({
|
||||
type: 'client_window_error',
|
||||
severity: 'error',
|
||||
summary: 'boom',
|
||||
details: { filename: 'App.tsx' },
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const call = fetchMock.mock.calls[0]
|
||||
expect(call).toBeDefined()
|
||||
const [, init] = call!
|
||||
const body = JSON.parse(String((init as RequestInit).body))
|
||||
expect(body.type).toBe('client_window_error')
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ const ENV_BASE_URL =
|
||||
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||
|
||||
let baseUrl = DEFAULT_BASE_URL
|
||||
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
|
||||
|
||||
function getErrorMessage(status: number, body: unknown) {
|
||||
if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') {
|
||||
@ -71,12 +72,52 @@ async function request<T>(method: string, path: string, body?: unknown, options?
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
|
||||
const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
|
||||
reportApiFailure(method, path, timeoutError)
|
||||
throw timeoutError
|
||||
}
|
||||
reportApiFailure(method, path, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function reportApiFailure(method: string, path: string, error: unknown) {
|
||||
if (path.startsWith('/api/diagnostics')) return
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
method,
|
||||
path,
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
details.status = error.status
|
||||
details.response = error.body
|
||||
}
|
||||
|
||||
void rawRecordDiagnosticEvent({
|
||||
type: 'client_api_request_failed',
|
||||
severity: 'warn',
|
||||
summary: `${method} ${path} failed: ${details.message}`,
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
export function rawRecordDiagnosticEvent(event: {
|
||||
type: string
|
||||
severity?: 'debug' | 'info' | 'warn' | 'error'
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}) {
|
||||
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, options?: { timeout?: number }) => request<T>('GET', path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: { timeout?: number }) => request<T>('POST', path, body, options),
|
||||
|
||||
@ -12,9 +12,18 @@ export type DiagnosticEvent = {
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticEventInput = {
|
||||
type: string
|
||||
severity?: DiagnosticSeverity
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticsStatus = {
|
||||
logDir: string
|
||||
diagnosticsPath: string
|
||||
cliDiagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
@ -34,6 +43,7 @@ export type DiagnosticsBundle = {
|
||||
export const diagnosticsApi = {
|
||||
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
|
||||
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
|
||||
recordEvent: (event: DiagnosticEventInput) => api.post<{ ok: true }>('/api/diagnostics/events', event, { timeout: 5_000 }),
|
||||
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
|
||||
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),
|
||||
clear: () => api.delete<{ ok: true }>('/api/diagnostics'),
|
||||
|
||||
40
desktop/src/components/ErrorBoundary.tsx
Normal file
40
desktop/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { t } from '../i18n'
|
||||
import { reportReactError } from '../lib/diagnosticsCapture'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type State = {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) {
|
||||
void reportReactError(error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="text-base font-semibold">{t('errorBoundary.title')}</div>
|
||||
<div className="mt-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('errorBoundary.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@ -139,6 +139,9 @@ export const en = {
|
||||
'settings.diagnostics.confirmClear': 'Clear all local diagnostic logs and exported bundles?',
|
||||
'settings.diagnostics.cleared': 'Diagnostics cleared.',
|
||||
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
|
||||
'settings.diagnostics.eventDetails': 'Details',
|
||||
'errorBoundary.title': 'Something went wrong.',
|
||||
'errorBoundary.description': 'The error was recorded in Diagnostics.',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
|
||||
|
||||
@ -141,6 +141,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.diagnostics.confirmClear': '确定清理所有本地诊断日志和已导出的诊断包?',
|
||||
'settings.diagnostics.cleared': '诊断日志已清理。',
|
||||
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
|
||||
'settings.diagnostics.eventDetails': '详情',
|
||||
'errorBoundary.title': '出现异常。',
|
||||
'errorBoundary.description': '错误已记录到诊断日志。',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',
|
||||
|
||||
65
desktop/src/lib/diagnosticsCapture.ts
Normal file
65
desktop/src/lib/diagnosticsCapture.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { rawRecordDiagnosticEvent } from '../api/client'
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installClientDiagnosticsCapture() {
|
||||
if (installed || typeof window === 'undefined') return
|
||||
installed = true
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
void reportClientError('client_window_error', event.message || 'Window error', {
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
error: normalizeError(event.error),
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
void reportClientError('client_unhandled_rejection', summarizeUnknown(event.reason), {
|
||||
reason: normalizeError(event.reason),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function reportReactError(error: unknown, errorInfo: React.ErrorInfo) {
|
||||
return reportClientError('client_react_error_boundary', summarizeUnknown(error), {
|
||||
error: normalizeError(error),
|
||||
componentStack: errorInfo.componentStack,
|
||||
})
|
||||
}
|
||||
|
||||
function reportClientError(type: string, summary: string, details: Record<string, unknown>) {
|
||||
return rawRecordDiagnosticEvent({
|
||||
type,
|
||||
severity: 'error',
|
||||
summary,
|
||||
details: {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
...details,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeUnknown(value: unknown): string {
|
||||
if (value instanceof Error) return value.message || value.name
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -1,13 +1,18 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import './theme/globals.css'
|
||||
import { installClientDiagnosticsCapture } from './lib/diagnosticsCapture'
|
||||
import { initializeTheme } from './stores/uiStore'
|
||||
|
||||
initializeTheme()
|
||||
installClientDiagnosticsCapture()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@ -43,7 +43,7 @@ export function DiagnosticsSettings() {
|
||||
return events
|
||||
.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
.slice(0, 20)
|
||||
.map((event) => `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}`)
|
||||
.map(formatEventForCopy)
|
||||
.join('\n')
|
||||
}, [events])
|
||||
|
||||
@ -172,7 +172,11 @@ export function DiagnosticsSettings() {
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{events.map((event) => (
|
||||
<EventRow key={event.id} event={event} />
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
detailsLabel={t('settings.diagnostics.eventDetails')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -190,13 +194,20 @@ function Metric({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: DiagnosticEvent }) {
|
||||
function EventRow({
|
||||
event,
|
||||
detailsLabel,
|
||||
}: {
|
||||
event: DiagnosticEvent
|
||||
detailsLabel: string
|
||||
}) {
|
||||
const severityClass =
|
||||
event.severity === 'error'
|
||||
? 'text-[var(--color-error)]'
|
||||
: event.severity === 'warn'
|
||||
? 'text-[var(--color-warning)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
const detailsText = formatDetails(event.details)
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 grid grid-cols-[120px_92px_1fr] gap-3 items-start">
|
||||
@ -212,7 +223,34 @@ function EventRow({ event }: { event: DiagnosticEvent }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
|
||||
{detailsText && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--color-text-tertiary)] select-none">
|
||||
{detailsLabel}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] p-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{detailsText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDetails(details: unknown): string {
|
||||
if (details === null || details === undefined) return ''
|
||||
if (typeof details === 'string') return details
|
||||
try {
|
||||
return JSON.stringify(details, null, 2)
|
||||
} catch {
|
||||
return String(details)
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventForCopy(event: DiagnosticEvent): string {
|
||||
const header = `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`
|
||||
const details = formatDetails(event.details)
|
||||
if (!details) return `${header}: ${event.summary}`
|
||||
return `${header}: ${event.summary}\nDetails:\n${details}`
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ describe('ConversationService', () => {
|
||||
let originalEntrypoint: string | undefined
|
||||
let originalOAuthToken: string | undefined
|
||||
let originalProviderManagedByHost: string | undefined
|
||||
let originalDiagnosticsFile: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
||||
@ -24,6 +25,7 @@ describe('ConversationService', () => {
|
||||
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
||||
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
||||
@ -34,6 +36,7 @@ describe('ConversationService', () => {
|
||||
// buildChildEnv injects it or not without interference from the shell env.
|
||||
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
||||
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
||||
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@ -58,6 +61,9 @@ describe('ConversationService', () => {
|
||||
if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
||||
else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost
|
||||
|
||||
if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@ -68,6 +74,8 @@ describe('ConversationService', () => {
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
||||
expect(env.ANTHROPIC_MODEL).toBe('test-model')
|
||||
expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'))
|
||||
await expect(fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
test('strips inherited provider env when desktop provider config exists', async () => {
|
||||
|
||||
@ -52,6 +52,7 @@ describe('DiagnosticsService', () => {
|
||||
|
||||
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')
|
||||
expect(runtime).toContain('cli_start_failed')
|
||||
expect(runtime).toContain('"nested"')
|
||||
expect(runtime).toContain('[REDACTED]')
|
||||
expect(runtime).not.toContain('sk-secret-token')
|
||||
})
|
||||
@ -82,13 +83,21 @@ describe('DiagnosticsService', () => {
|
||||
summary: 'provider failed with token=provider-secret',
|
||||
details: { accessToken: 'provider-secret' },
|
||||
})
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'),
|
||||
'{"event":"cli_streaming_idle_timeout","data":{"authorization":"Bearer provider-secret"}}\n',
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
expect(bundle.path).toEndWith('.tar.gz')
|
||||
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
||||
expect(archiveText).toContain('README.txt')
|
||||
expect(archiveText).toContain('recent-errors.md')
|
||||
expect(archiveText).toContain('cli-diagnostics.jsonl')
|
||||
expect(archiveText).toContain('providers-summary.json')
|
||||
expect(archiveText).toContain('sessions-summary.json')
|
||||
expect(archiveText).toContain('cli_streaming_idle_timeout')
|
||||
expect(archiveText).toContain('Test Provider')
|
||||
expect(archiveText).toContain('api.example.com')
|
||||
expect(archiveText).not.toContain('sk-provider-secret')
|
||||
@ -108,8 +117,9 @@ describe('diagnostics API', () => {
|
||||
const statusReq = makeRequest('GET', '/api/diagnostics/status')
|
||||
const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments)
|
||||
expect(statusRes.status).toBe(200)
|
||||
const status = await statusRes.json() as { logDir: string; recentErrorCount: number }
|
||||
const status = await statusRes.json() as { logDir: string; cliDiagnosticsPath: string; recentErrorCount: number }
|
||||
expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics'))
|
||||
expect(status.cliDiagnosticsPath).toContain('cli-diagnostics.jsonl')
|
||||
expect(status.recentErrorCount).toBe(1)
|
||||
|
||||
const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10')
|
||||
@ -118,6 +128,28 @@ describe('diagnostics API', () => {
|
||||
const events = await eventsRes.json() as { events: Array<{ type: string }> }
|
||||
expect(events.events[0].type).toBe('api_unhandled_error')
|
||||
|
||||
const clientEventReq = new Request('http://localhost:3456/api/diagnostics/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client_unhandled_rejection',
|
||||
severity: 'error',
|
||||
summary: 'frontend exploded token=client-secret',
|
||||
details: { accessToken: 'client-secret', stack: 'Error: boom' },
|
||||
}),
|
||||
})
|
||||
const clientEventUrl = new URL(clientEventReq.url)
|
||||
const clientEventRes = await handleDiagnosticsApi(
|
||||
clientEventReq,
|
||||
clientEventUrl,
|
||||
clientEventUrl.pathname.split('/').filter(Boolean),
|
||||
)
|
||||
expect(clientEventRes.status).toBe(200)
|
||||
const clientEvents = await service.readRecentEvents(10)
|
||||
expect(clientEvents[0].type).toBe('client_unhandled_rejection')
|
||||
expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]')
|
||||
expect(JSON.stringify(clientEvents[0])).not.toContain('client-secret')
|
||||
|
||||
const exportReq = makeRequest('POST', '/api/diagnostics/export')
|
||||
const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments)
|
||||
expect(exportRes.status).toBe(200)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*
|
||||
* GET /api/diagnostics/status — log directory, retention and counters
|
||||
* GET /api/diagnostics/events — recent sanitized diagnostic events
|
||||
* POST /api/diagnostics/events — append a sanitized client diagnostic event
|
||||
* POST /api/diagnostics/export — write a sanitized tar.gz bundle
|
||||
* POST /api/diagnostics/open-log-dir — open the diagnostics directory
|
||||
* DELETE /api/diagnostics — clear diagnostics files
|
||||
@ -34,6 +35,26 @@ export async function handleDiagnosticsApi(
|
||||
return Response.json({ events })
|
||||
}
|
||||
|
||||
if (action === 'events' && req.method === 'POST') {
|
||||
const body = await parseJsonBody(req)
|
||||
const type = typeof body.type === 'string' && body.type.trim()
|
||||
? body.type.trim().slice(0, 128)
|
||||
: 'client_diagnostic_event'
|
||||
const severity = isDiagnosticSeverity(body.severity) ? body.severity : 'error'
|
||||
const summary = typeof body.summary === 'string' && body.summary.trim()
|
||||
? body.summary
|
||||
: type
|
||||
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
|
||||
await diagnosticsService.recordEvent({
|
||||
type,
|
||||
severity,
|
||||
summary,
|
||||
sessionId,
|
||||
details: body.details,
|
||||
})
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
if (action === 'export' && req.method === 'POST') {
|
||||
return Response.json({ bundle: await diagnosticsService.exportBundle() })
|
||||
}
|
||||
@ -48,3 +69,16 @@ export async function handleDiagnosticsApi(
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await req.json()
|
||||
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
function isDiagnosticSeverity(value: unknown): value is 'debug' | 'info' | 'warn' | 'error' {
|
||||
return value === 'debug' || value === 'info' || value === 'warn' || value === 'error'
|
||||
}
|
||||
|
||||
@ -51,6 +51,7 @@ const HOST = SERVER_OPTIONS.host
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
enableConfigs()
|
||||
diagnosticsService.installConsoleCapture()
|
||||
diagnosticsService.installProcessCapture()
|
||||
ProviderService.setServerPort(port)
|
||||
const localConnectHost =
|
||||
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
|
||||
|
||||
@ -17,6 +17,10 @@ import {
|
||||
resolveClaudeCliLauncher,
|
||||
} from '../../utils/desktopBundledCli.js'
|
||||
|
||||
const MAX_CAPTURED_PROCESS_LINES = 80
|
||||
const MAX_CAPTURED_SDK_MESSAGES = 40
|
||||
const MAX_CAPTURED_SDK_SUMMARY = 20
|
||||
|
||||
type AttachmentRef = {
|
||||
type: 'file' | 'image'
|
||||
name?: string
|
||||
@ -496,8 +500,8 @@ export class ConversationService {
|
||||
try {
|
||||
const msg = JSON.parse(line)
|
||||
session.sdkMessages.push(msg)
|
||||
if (session.sdkMessages.length > 40) {
|
||||
session.sdkMessages.splice(0, 20)
|
||||
if (session.sdkMessages.length > MAX_CAPTURED_SDK_MESSAGES) {
|
||||
session.sdkMessages.splice(0, session.sdkMessages.length - MAX_CAPTURED_SDK_MESSAGES)
|
||||
}
|
||||
const sdkError = this.extractSdkErrorEvent(msg)
|
||||
if (sdkError) {
|
||||
@ -604,8 +608,8 @@ export class ConversationService {
|
||||
const lines =
|
||||
streamName === 'stderr' ? session.stderrLines : session.stdoutLines
|
||||
lines.push(this.redactProcessOutput(line))
|
||||
if (lines.length > 20) {
|
||||
lines.splice(0, 10)
|
||||
if (lines.length > MAX_CAPTURED_PROCESS_LINES) {
|
||||
lines.splice(0, lines.length - MAX_CAPTURED_PROCESS_LINES)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -782,10 +786,18 @@ export class ConversationService {
|
||||
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
|
||||
}
|
||||
|
||||
const cliDiagnosticsPath = diagnosticsService.getCliDiagnosticsPath()
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cliDiagnosticsPath), { recursive: true })
|
||||
} catch {
|
||||
// Diagnostics must never block session startup.
|
||||
}
|
||||
|
||||
return {
|
||||
...cleanEnv,
|
||||
CLAUDE_CODE_ENABLE_TASKS: '1',
|
||||
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1',
|
||||
CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath,
|
||||
CALLER_DIR: workDir,
|
||||
PWD: workDir,
|
||||
...(sdkUrl
|
||||
@ -1122,6 +1134,9 @@ export class ConversationService {
|
||||
sdkType: message.type,
|
||||
error: typeof message.error === 'string' ? message.error : undefined,
|
||||
isApiErrorMessage: message.isApiErrorMessage === true,
|
||||
messageText: this.extractAssistantText(message)
|
||||
? this.redactProcessOutput(this.extractAssistantText(message))
|
||||
: undefined,
|
||||
errorDetails:
|
||||
typeof message.errorDetails === 'string'
|
||||
? this.redactProcessOutput(message.errorDetails)
|
||||
@ -1141,6 +1156,15 @@ export class ConversationService {
|
||||
sdkType: message.type,
|
||||
subtype: message.subtype,
|
||||
isError: true,
|
||||
result:
|
||||
typeof message.result === 'string'
|
||||
? this.redactProcessOutput(message.result)
|
||||
: undefined,
|
||||
status:
|
||||
typeof message.status === 'string'
|
||||
? this.redactProcessOutput(message.status)
|
||||
: undefined,
|
||||
usage: message.usage,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1149,17 +1173,36 @@ export class ConversationService {
|
||||
}
|
||||
|
||||
private summarizeSdkMessages(messages: any[]): unknown[] {
|
||||
return messages.slice(-10).map((message) => {
|
||||
return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return message
|
||||
}
|
||||
const content = Array.isArray(message.message?.content)
|
||||
? message.message.content.map((block: unknown) => {
|
||||
if (!block || typeof block !== 'object') return block
|
||||
const typedBlock = block as Record<string, unknown>
|
||||
return {
|
||||
type: typedBlock.type,
|
||||
text:
|
||||
typeof typedBlock.text === 'string'
|
||||
? this.redactProcessOutput(typedBlock.text)
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
type: message.type,
|
||||
subtype: message.subtype,
|
||||
is_error: message.is_error,
|
||||
status: typeof message.status === 'string' ? message.status : undefined,
|
||||
result: typeof message.result === 'string' ? message.result : undefined,
|
||||
message: typeof message.message === 'string' ? message.message : undefined,
|
||||
result: typeof message.result === 'string' ? this.redactProcessOutput(message.result) : undefined,
|
||||
error: typeof message.error === 'string' ? this.redactProcessOutput(message.error) : undefined,
|
||||
errorDetails:
|
||||
typeof message.errorDetails === 'string'
|
||||
? this.redactProcessOutput(message.errorDetails)
|
||||
: undefined,
|
||||
message: typeof message.message === 'string' ? this.redactProcessOutput(message.message) : undefined,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ export type DiagnosticEvent = {
|
||||
export type DiagnosticsStatus = {
|
||||
logDir: string
|
||||
diagnosticsPath: string
|
||||
cliDiagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
@ -46,6 +47,7 @@ export type DiagnosticsExportResult = {
|
||||
const RETENTION_DAYS = 7
|
||||
const MAX_BYTES = 50 * 1024 * 1024
|
||||
const MAX_STRING_LENGTH = 4096
|
||||
const MAX_TEXT_FILE_EXPORT_LENGTH = 256 * 1024
|
||||
const MAX_ARRAY_ITEMS = 40
|
||||
const MAX_OBJECT_KEYS = 80
|
||||
const MAX_EVENTS_IN_EXPORT = 5000
|
||||
@ -53,6 +55,7 @@ const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_
|
||||
|
||||
export class DiagnosticsService {
|
||||
private consoleCaptureInstalled = false
|
||||
private processCaptureInstalled = false
|
||||
private originalConsoleError: typeof console.error | null = null
|
||||
private originalConsoleWarn: typeof console.warn | null = null
|
||||
|
||||
@ -64,6 +67,10 @@ export class DiagnosticsService {
|
||||
return path.join(this.getLogDir(), 'diagnostics.jsonl')
|
||||
}
|
||||
|
||||
getCliDiagnosticsPath(): string {
|
||||
return path.join(this.getLogDir(), 'cli-diagnostics.jsonl')
|
||||
}
|
||||
|
||||
getRuntimeErrorsPath(): string {
|
||||
return path.join(this.getLogDir(), 'runtime-errors.log')
|
||||
}
|
||||
@ -87,7 +94,7 @@ export class DiagnosticsService {
|
||||
await this.ensureLogDir()
|
||||
await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8')
|
||||
if (event.severity === 'warn' || event.severity === 'error') {
|
||||
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogLine(event), 'utf-8')
|
||||
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogEntry(event), 'utf-8')
|
||||
}
|
||||
await this.enforceRetention().catch(() => {})
|
||||
} catch {
|
||||
@ -128,6 +135,29 @@ export class DiagnosticsService {
|
||||
this.originalConsoleWarn = null
|
||||
}
|
||||
|
||||
installProcessCapture(): void {
|
||||
if (this.processCaptureInstalled) return
|
||||
this.processCaptureInstalled = true
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
void this.recordEvent({
|
||||
type: 'server_uncaught_exception',
|
||||
severity: 'error',
|
||||
summary: error.message || 'Uncaught exception',
|
||||
details: { error },
|
||||
})
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
void this.recordEvent({
|
||||
type: 'server_unhandled_rejection',
|
||||
severity: 'error',
|
||||
summary: this.formatUnknownReason(reason),
|
||||
details: { reason },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getStatus(): Promise<DiagnosticsStatus> {
|
||||
await this.ensureLogDir()
|
||||
const events = await this.readRecentEvents(500)
|
||||
@ -136,6 +166,7 @@ export class DiagnosticsService {
|
||||
return {
|
||||
logDir: this.getLogDir(),
|
||||
diagnosticsPath: this.getDiagnosticsPath(),
|
||||
cliDiagnosticsPath: this.getCliDiagnosticsPath(),
|
||||
runtimeErrorsPath: this.getRuntimeErrorsPath(),
|
||||
exportDir: this.getExportDir(),
|
||||
retentionDays: RETENTION_DAYS,
|
||||
@ -194,9 +225,17 @@ export class DiagnosticsService {
|
||||
name: 'diagnostics.jsonl',
|
||||
content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''),
|
||||
},
|
||||
{
|
||||
name: 'recent-errors.md',
|
||||
content: this.buildRecentErrorsSummary(events),
|
||||
},
|
||||
{
|
||||
name: 'runtime-errors.log',
|
||||
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath()),
|
||||
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
},
|
||||
{
|
||||
name: 'cli-diagnostics.jsonl',
|
||||
content: await this.readSanitizedTextFile(this.getCliDiagnosticsPath(), MAX_TEXT_FILE_EXPORT_LENGTH),
|
||||
},
|
||||
{
|
||||
name: 'providers-summary.json',
|
||||
@ -305,8 +344,26 @@ export class DiagnosticsService {
|
||||
}).join(' '))
|
||||
}
|
||||
|
||||
private formatRuntimeLogLine(event: DiagnosticEvent): string {
|
||||
return `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}\n`
|
||||
private formatUnknownReason(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message || reason.name
|
||||
if (typeof reason === 'string') return this.sanitizeString(reason)
|
||||
try {
|
||||
return this.sanitizeString(JSON.stringify(this.sanitizeValue(reason)))
|
||||
} catch {
|
||||
return this.sanitizeString(String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
private formatRuntimeLogEntry(event: DiagnosticEvent): string {
|
||||
const lines = [
|
||||
`[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`,
|
||||
`summary: ${event.summary}`,
|
||||
]
|
||||
if (event.details !== undefined) {
|
||||
lines.push('details:')
|
||||
lines.push(JSON.stringify(event.details, null, 2))
|
||||
}
|
||||
return `${lines.join('\n')}\n\n`
|
||||
}
|
||||
|
||||
private buildReadme(): string {
|
||||
@ -320,13 +377,51 @@ export class DiagnosticsService {
|
||||
'Files:',
|
||||
'- app-info.json: runtime and platform summary.',
|
||||
'- diagnostics.jsonl: sanitized structured diagnostic events.',
|
||||
'- runtime-errors.log: sanitized warning/error summaries.',
|
||||
'- recent-errors.md: human-readable warning/error timeline for GitHub issues.',
|
||||
'- runtime-errors.log: sanitized warning/error timeline with captured runtime details.',
|
||||
'- cli-diagnostics.jsonl: sanitized no-PII CLI internal diagnostics emitted by the child process.',
|
||||
'- providers-summary.json: provider count, active id, base URL host, model ids, and API format without API keys.',
|
||||
'- sessions-summary.json: session ids observed in diagnostic events, without transcript content.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private buildRecentErrorsSummary(events: DiagnosticEvent[]): string {
|
||||
const errorEvents = events
|
||||
.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
.slice(0, 50)
|
||||
|
||||
const lines = [
|
||||
'# cc-haha recent diagnostics',
|
||||
'',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Events included: ${errorEvents.length}`,
|
||||
'',
|
||||
]
|
||||
|
||||
if (errorEvents.length === 0) {
|
||||
lines.push('No recent warnings or errors were recorded.')
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
for (const event of errorEvents) {
|
||||
lines.push(`## ${event.timestamp} ${event.severity.toUpperCase()} ${event.type}`)
|
||||
if (event.sessionId) lines.push(`session: ${event.sessionId}`)
|
||||
lines.push('')
|
||||
lines.push(event.summary)
|
||||
if (event.details !== undefined) {
|
||||
lines.push('')
|
||||
lines.push('```json')
|
||||
lines.push(JSON.stringify(event.details, null, 2))
|
||||
lines.push('```')
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private buildAppInfo(): Record<string, unknown> {
|
||||
return this.sanitizeValue({
|
||||
appVersion: process.env.APP_VERSION || '999.0.0-local',
|
||||
@ -404,9 +499,12 @@ export class DiagnosticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async readSanitizedTextFile(filePath: string): Promise<string> {
|
||||
private async readSanitizedTextFile(
|
||||
filePath: string,
|
||||
maxLength = 2 * MAX_STRING_LENGTH,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), 2 * MAX_STRING_LENGTH)
|
||||
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), maxLength)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
||||
throw err
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user