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:
程序员阿江(Relakkes) 2026-05-05 17:57:50 +08:00
parent 2459488703
commit f2437bf724
16 changed files with 516 additions and 20 deletions

View File

@ -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 {

View 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')
})
})

View File

@ -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),

View File

@ -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'),

View 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
}
}

View File

@ -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.',

View File

@ -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 官方登录页面,授权后自动回到这里。',

View 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
}

View File

@ -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>,
)

View File

@ -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}`
}

View File

@ -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 () => {

View File

@ -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)

View File

@ -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'
}

View File

@ -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'

View File

@ -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,
}
})
}

View File

@ -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