Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/ai/src/captureAiGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}
}
Expand Down
57 changes: 57 additions & 0 deletions packages/ai/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object> = 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<string, unknown> = {
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<string, unknown>)[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<string, unknown> = {}
for (const [k, v] of Object.entries(err as Record<string, unknown>)) {
out[k] = serializeError(v, seen)
}
return out
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

const POSTHOG_PARAMS_MAP: Record<keyof MonitoringParams, string> = {
posthogDistinctId: 'distinctId',
posthogTraceId: 'traceId',
Expand Down
37 changes: 37 additions & 0 deletions packages/ai/tests/captureAiGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down