diff --git a/packages/ai/src/captureAiGeneration.ts b/packages/ai/src/captureAiGeneration.ts index 328da8cde5..5d8b9c889b 100644 --- a/packages/ai/src/captureAiGeneration.ts +++ b/packages/ai/src/captureAiGeneration.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid' import { uuidv7, ErrorTracking as CoreErrorTracking } from '@posthog/core' import { version } from '../package.json' import type { TokenUsage } from './types' -import { AIEvent, CostOverride, getTokensSource, sanitizeValues, withPrivacyMode } from './utils' +import { AIEvent, CostOverride, getTokensSource, sanitizeValues, serializeError, withPrivacyMode } from './utils' type AnthropicTool = AnthropicOriginal.Tool @@ -115,7 +115,12 @@ export const captureAiGeneration = async (client: PostHog, options: CaptureAiGen errorData = { $ai_is_error: true, - $ai_error: sanitizeValues(JSON.stringify(options.error)), + // JSON.stringify on an Error instance drops `message`, `stack`, and the + // `cause` chain because those fields are non-enumerable, which made + // `$ai_error` show up as an empty-ish blob on the dashboard. Normalize + // Errors to plain objects first so the fields survive serialization. + // See https://github.com/PostHog/posthog-js/issues/3556 + $ai_error: sanitizeValues(JSON.stringify(serializeError(options.error))), $exception_event_id: exceptionId, } } diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 2a5aafc4a2..490fe38a0e 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -591,6 +591,63 @@ export function sanitizeValues(obj: any): any { return jsonSafe } +/** + * Convert an unknown thrown value into a JSON-serializable representation that + * preserves Error fields the engine marks non-enumerable (`name`, `message`, + * `stack`, `cause`), so they survive `JSON.stringify` for storage on + * `$ai_error`. Recurses into the `cause` chain and into any own enumerable + * properties (e.g. SDK-attached fields like `status`, `response`, + * `validationError`). Tracks a `seen` set to guard against circular cause + * graphs. Non-object inputs are returned as-is. + * + * NOTE: `seen` is shared across the entire traversal rather than scoped to the + * current ancestor path. Any object that appears at two distinct locations in + * the error structure — a "diamond" reference such as the same `response` + * object attached to both `err.response` and `err.cause.response` — will be + * serialized the first time it is encountered and replaced with `'[Circular]'` + * on subsequent visits, even when no actual cycle exists. This is intentional: + * it keeps allocation O(visited) and is correct for the primary use case + * (linear `cause` chains). Callers that need exact graph reproduction should + * not use this function. + * + * Fixes https://github.com/PostHog/posthog-js/issues/3556 + */ +export function serializeError(err: unknown, seen: WeakSet = new WeakSet()): unknown { + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) { + return err + } + if (seen.has(err as object)) { + return '[Circular]' + } + seen.add(err as object) + if (err instanceof Error) { + const obj: Record = { + name: err.name, + message: err.message, + stack: err.stack, + } + if ('cause' in err && (err as { cause?: unknown }).cause !== undefined) { + obj.cause = serializeError((err as { cause: unknown }).cause, seen) + } + // Pick up own enumerable properties added by SDKs (statusCode, response, ...). + for (const key of Object.keys(err)) { + if (!(key in obj)) { + obj[key] = serializeError((err as Record)[key], seen) + } + } + return obj + } + if (Array.isArray(err)) { + return err.map((v) => serializeError(v, seen)) + } + // Plain object — recurse so nested Errors (e.g. inside `validationError`) are unwrapped too. + const out: Record = {} + for (const [k, v] of Object.entries(err as Record)) { + out[k] = serializeError(v, seen) + } + return out +} + const POSTHOG_PARAMS_MAP: Record = { posthogDistinctId: 'distinctId', posthogTraceId: 'traceId', diff --git a/packages/ai/tests/captureAiGeneration.test.ts b/packages/ai/tests/captureAiGeneration.test.ts index 9558c2be42..6d878eec09 100644 --- a/packages/ai/tests/captureAiGeneration.test.ts +++ b/packages/ai/tests/captureAiGeneration.test.ts @@ -143,6 +143,43 @@ describe('captureAiGeneration', () => { expect(properties.$ai_http_status).toBe(expected) }) + // https://github.com/PostHog/posthog-js/issues/3556 + it('serializes the message, stack, and cause chain of an Error onto $ai_error', async () => { + const client = buildClient() + const root = new Error('zod validation failed') + const middle = Object.assign(new Error('type validation'), { cause: root }) + const top = Object.assign(new Error('gateway responded with error'), { + name: 'GatewayResponseError', + statusCode: 500, + cause: middle, + }) + + await captureAiGeneration(client, { ...baseRequiredOptions, error: top }) + + const properties = lastCaptureProperties(client) + const parsed = JSON.parse(properties.$ai_error as string) + expect(parsed.name).toBe('GatewayResponseError') + expect(parsed.message).toBe('gateway responded with error') + expect(parsed.statusCode).toBe(500) + expect(typeof parsed.stack).toBe('string') + expect(parsed.cause.message).toBe('type validation') + expect(parsed.cause.cause.message).toBe('zod validation failed') + }) + + it('marks circular cause chains instead of recursing forever on $ai_error', async () => { + const client = buildClient() + const a = new Error('a') + const b = Object.assign(new Error('b'), { cause: a }) + ;(a as { cause?: unknown }).cause = b + + await captureAiGeneration(client, { ...baseRequiredOptions, error: a }) + + const parsed = JSON.parse(lastCaptureProperties(client).$ai_error as string) + expect(parsed.message).toBe('a') + expect(parsed.cause.message).toBe('b') + expect(parsed.cause.cause).toBe('[Circular]') + }) + it('mutates the original error in place when autocapture is enabled, so callers can re-throw safely', async () => { const client = buildClient({ enableExceptionAutocapture: true }) const error = new Error('boom')