mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
commit
851b33ab70
@ -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