Merge actionable diagnostics logging into main

Bring the runtime diagnostics worktree back onto local main while preserving the existing local main commits for provider context windows and IM localization.

Constraint: Preserve both independent local main commits and the diagnostics worktree commit.
Confidence: high
Scope-risk: moderate
Directive: Keep diagnostics capture non-blocking and sanitized when modifying this area again.
Tested: merged cleanly on local main after prior check:server, check:desktop, and agent-browser diagnostics export verification
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 17:59:46 +08:00
commit 851b33ab70
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