Files
zeekay ec6f6b50c1 chore: kill ALL posthog — zero refs, zero filenames (1452 -> 0)
We own the ingestion server (hanzoai/insights: Django + rust/capture + nodejs
plugin) AND this SDK, so there is no external wire contract to preserve. The
server is already 100% posthog-free; this SDK was the last laggard.

- $last_posthog_reset -> $last_insights_reset. This FIXES A LIVE MISMATCH: the
  server taxonomy (insights/taxonomy/taxonomy.py, insights/insightsql/ai.py)
  already expected the insights form while the SDK emitted the posthog form.
- __PosthogExtensions__ -> __InsightsExtensions__; all PostHog/posthog/POSTHOG
  identifiers -> Insights/insights/INSIGHTS (src, tests, playground, playwright,
  scripts, rrweb subpackages, package.json keywords).
- Hosts: DEFAULT_PLUGIN_HOST + DEFAULT_NUXT_HOST + the core host default pointed
  at PostHog's cloud (us.i.posthog.com) — a real bug. All -> insights.hanzo.ai.
- Removed the us-assets/eu-assets CDN split in getRemoteConfig (PostHog-specific;
  Hanzo serves remote config from the same host) — it had degenerated to a no-op.
- Fixed generate-docs writing to a stray references/@hanzo/ subdir.
- Renamed the 3 posthog-named test files; dropped 58MB of stale reference
  snapshots named for upstream posthog versions we never shipped (they are
  generated output — references/ is now gitignored).

Verified: typecheck errors 42 (HEAD) -> 18 (all pre-existing test-mock
looseness); types package 0; every package.json valid with @hanzo/* names.
Guard: grep -rioE 'posthog' packages ... | wc -l  => 0
2026-07-16 09:09:18 -07:00

937 lines
32 KiB
TypeScript

import { Insights } from '@hanzo/insights-node'
import { captureSpan, createInsightsSpanProcessor, InsightsSpanProcessor, InsightsSpanMapper } from '../src/otel'
import * as captureModule from '../src/otel/capture'
import { flushPromises } from './test-utils'
jest.mock('@opentelemetry/exporter-trace-otlp-http', () => {
const MockExporter = jest.fn()
MockExporter.prototype.export = jest.fn()
MockExporter.prototype.shutdown = jest.fn().mockResolvedValue(undefined)
MockExporter.prototype.forceFlush = jest.fn().mockResolvedValue(undefined)
return { OTLPTraceExporter: MockExporter }
})
jest.mock('@hanzo/insights-node', () => {
return {
Insights: jest.fn().mockImplementation(() => {
return {
capture: jest.fn(),
captureImmediate: jest.fn(),
privacy_mode: false,
}
}),
}
})
describe('OTEL span mapping', () => {
let mockInsightsClient: Insights
function getSuperExport(): jest.Mock {
return OTLPTraceExporter.prototype.export as jest.Mock
}
function getSuperShutdown(): jest.Mock {
return OTLPTraceExporter.prototype.shutdown as jest.Mock
}
function getSuperForceFlush(): jest.Mock {
return OTLPTraceExporter.prototype.forceFlush as jest.Mock
}
describe('InsightsTraceExporter', () => {
beforeEach(() => {
jest.clearAllMocks()
mockInsightsClient = new (Insights as any)()
})
it('maps doGenerate spans into Insights AI generation events', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }]),
'ai.response.text': 'Hi there!',
'ai.usage.promptTokens': 12,
'ai.usage.completionTokens': 8,
'ai.usage.totalTokens': 20,
'ai.settings.temperature': 0.2,
'ai.settings.maxOutputTokens': 200,
'ai.telemetry.metadata.conversation_id': 'conv_123',
},
instrumentationScope: { name: 'ai', version: '6.1.0' },
duration: [1, 500000000],
status: { code: 1 },
spanContext: () => mockSpanContext('otel-trace-123'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'user_123',
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
const properties = captureCall[0].properties
expect(properties.$ai_framework).toBe('vercel')
expect(properties.$ai_framework_version).toBe('6')
expect(properties.$ai_model).toBe('gpt-4o-mini')
expect(properties.$ai_provider).toBe('openai')
expect(properties.$ai_trace_id).toBe('otel-trace-123')
expect(properties.$ai_input_tokens).toBe(12)
expect(properties.$ai_output_tokens).toBe(8)
expect(properties.$ai_model_parameters).toEqual(
expect.objectContaining({
temperature: 0.2,
})
)
expect(properties.conversation_id).toBe('conv_123')
})
it('does not send framework version when SDK version is unavailable', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }]),
'ai.response.text': 'Hi there!',
'ai.usage.promptTokens': 12,
'ai.usage.completionTokens': 8,
},
duration: [1, 500000000],
status: { code: 1 },
spanContext: () => mockSpanContext('otel-trace-no-sdk-version'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'user_123',
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_framework_version).toBeUndefined()
})
it('maps framework version from ai request user-agent when instrumentation scope is missing', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.request.headers.user-agent': 'ai/6.0.90',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }]),
'ai.response.text': 'Hi there!',
'ai.usage.promptTokens': 12,
'ai.usage.completionTokens': 8,
},
duration: [1, 500000000],
status: { code: 1 },
spanContext: () => mockSpanContext('otel-trace-user-agent-version'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'user_123',
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_framework_version).toBe('6')
})
it('maps stream span TTFT from ai.response.msToFirstChunk', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.streamText.doStream',
'ai.model.id': 'claude-3-7-sonnet',
'ai.model.provider': 'anthropic',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }]),
'ai.response.text': 'Hello',
'ai.response.msToFirstChunk': 250,
'ai.usage.promptTokens': 10,
'ai.usage.completionTokens': 6,
},
duration: [0, 900000000],
status: { code: 1 },
spanContext: () => mockSpanContext('otel-stream-trace'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'user_abc',
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_time_to_first_token).toBe(0.25)
})
it('respects privacy mode for telemetry spans', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Secret text' }] }]),
'ai.response.text': 'Secret response',
'ai.usage.promptTokens': 4,
'ai.usage.completionTokens': 3,
},
duration: [0, 300000000],
status: { code: 1 },
spanContext: () => mockSpanContext('otel-private-trace'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'user_private',
insightsPrivacyMode: true,
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_input).toBeNull()
expect(captureCall[0].properties.$ai_output_choices).toBeNull()
})
it('span processor filters non-model spans and captures doGenerate spans', async () => {
const spanProcessor = createInsightsSpanProcessor(mockInsightsClient, {
insightsDistinctId: 'processor-user',
})
spanProcessor.onEnd({
attributes: {
'ai.operationId': 'ai.generateText',
'ai.model.id': 'gpt-4o-mini',
},
status: { code: 1 },
spanContext: () => mockSpanContext('ignored-trace'),
} as any)
spanProcessor.onEnd({
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Ping' }] }]),
'ai.response.text': 'Pong',
'ai.usage.promptTokens': 3,
'ai.usage.completionTokens': 2,
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('accepted-trace'),
} as any)
await flushPromises()
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_trace_id).toBe('accepted-trace')
})
it('accepts deprecated apiKey', () => {
new InsightsTraceExporter({ apiKey: DEFAULT_TOKEN })
expect(OTLPTraceExporter).toHaveBeenCalledWith({
url: 'https://insights.hanzo.ai/i/v0/ai/otel',
headers: { Authorization: `Bearer ${DEFAULT_TOKEN}` },
})
const spanProcessor = createInsightsSpanProcessor(mockInsightsClient, {
insightsDistinctId: 'processor-user',
})
spanProcessor.onEnd({
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('flush-trace'),
} as any)
let flushed = false
const flushPromise = spanProcessor.forceFlush().then(() => {
flushed = true
})
await flushPromises()
expect(flushed).toBe(false)
resolveCapture?.()
await flushPromise
expect(flushed).toBe(true)
captureSpy.mockRestore()
})
it.each([
['missing', {}],
['empty', { projectToken: '' }],
['blank', { projectToken: ' \n\t ' }],
])('disables and no-ops when projectToken is %s', (_case, options) => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
const exporter = new InsightsTraceExporter(options as any)
const callback = jest.fn()
const spanProcessor = createInsightsSpanProcessor(mockInsightsClient, {
insightsDistinctId: 'processor-user',
})
expect(getSuperExport()).not.toHaveBeenCalled()
expect(callback).toHaveBeenCalledWith({ code: 0 })
expect(warnSpy).toHaveBeenCalledWith(
'[InsightsTraceExporter] projectToken is missing or blank; the exporter will be disabled.'
)
warnSpy.mockRestore()
})
it('creates a Insights span processor', async () => {
const spanProcessor = createInsightsSpanProcessor(mockInsightsClient, {
insightsDistinctId: 'processor-user',
})
expect(spanProcessor).toBeInstanceOf(InsightsSpanProcessor)
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Ping' }] }]),
'ai.response.text': 'Pong',
'ai.usage.promptTokens': 3,
'ai.usage.completionTokens': 2,
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('alias-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'processor-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
})
it('supports custom mappers', async () => {
const customMapper: InsightsSpanMapper = {
name: 'custom-openai',
canMap: (span) => Boolean(span.attributes?.['openai.request.model']),
map: (span) => {
const model = String(span.attributes?.['openai.request.model'] || 'gpt-4.1')
return {
provider: 'openai',
model,
input: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
output: [{ role: 'assistant', content: 'world' }],
latency: 0.1,
usage: { inputTokens: 1, outputTokens: 1 },
insightsProperties: { source: 'custom-mapper' },
}
},
}
await captureSpan(
{
attributes: {
'openai.request.model': 'gpt-4.1',
},
duration: [0, 100000000],
status: { code: 1 },
spanContext: () => mockSpanContext('custom-mapper-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'custom-user', mappers: [customMapper] }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_model).toBe('gpt-4.1')
expect(captureCall[0].properties.source).toBe('custom-mapper')
})
it('supports shouldExportSpan gating', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Ping' }] }]),
'ai.response.text': 'Pong',
'ai.usage.promptTokens': 3,
'ai.usage.completionTokens': 2,
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('gated-trace'),
} as any,
mockInsightsClient,
{
insightsDistinctId: 'gated-user',
shouldExportSpan: () => false,
}
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(0)
})
it('maps generateObject.doGenerate spans', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateObject.doGenerate',
'ai.model.id': 'gpt-4.1',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Profile Jane' }] }]),
'ai.response.object': JSON.stringify({ name: 'Jane', age: 31 }),
'ai.response.finishReason': 'stop',
'ai.usage.promptTokens': 22,
'ai.usage.completionTokens': 11,
'ai.schema.name': 'UserProfile',
},
duration: [0, 200000000],
status: { code: 1 },
spanContext: () => mockSpanContext('generate-object-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'object-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_model).toBe('gpt-4.1')
expect(captureCall[0].properties.ai_finish_reason).toBe('stop')
expect(captureCall[0].properties.ai_schema_name).toBe('UserProfile')
expect(captureCall[0].properties.$ai_output_choices[0].content[0].type).toBe('object')
})
it('maps embed.doEmbed spans as embedding events', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.embed.doEmbed',
'ai.model.id': 'text-embedding-3-large',
'ai.model.provider': 'openai',
'ai.value': 'hello world',
'ai.embedding': JSON.stringify([0.1, 0.2, 0.3]),
'ai.usage.tokens': 6,
},
duration: [0, 50000000],
status: { code: 1 },
spanContext: () => mockSpanContext('embed-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'embed-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].event).toBe('$ai_embedding')
expect(captureCall[0].properties.$ai_input_tokens).toBe(6)
expect(captureCall[0].properties.$ai_output_choices).toBeNull()
})
it('maps ai.prompt.tools into $ai_tools', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Weather?' }] }]),
'ai.prompt.tools': [
JSON.stringify({
type: 'function',
name: 'get_weather',
description: 'Get weather for location',
}),
],
'ai.response.text': 'It is sunny',
'ai.usage.promptTokens': 3,
'ai.usage.completionTokens': 4,
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('tools-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'tools-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_tools).toEqual([
{
type: 'function',
name: 'get_weather',
description: 'Get weather for location',
},
])
})
it('maps ai.response.files image entries into output image blocks', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Generate image' }] }]),
'ai.response.files': JSON.stringify([
{
mimeType: 'image/png',
data: 'iVBORw0KGgoAAAANSUhEUgAA',
},
]),
'ai.usage.promptTokens': 7,
'ai.usage.completionTokens': 3,
},
duration: [0, 220000000],
status: { code: 1 },
spanContext: () => mockSpanContext('files-image-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'image-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices[0].content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'file',
mediaType: 'image/png',
data: expect.any(String),
}),
])
)
})
it('maps providerMetadata inlineData (Gemini) into output media blocks', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Generate image' }] }]),
'ai.response.providerMetadata': JSON.stringify({
google: {
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/jpeg',
data: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD',
},
},
],
},
},
],
},
}),
'ai.usage.promptTokens': 9,
'ai.usage.completionTokens': 4,
},
duration: [0, 190000000],
status: { code: 1 },
spanContext: () => mockSpanContext('provider-inline-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'provider-inline-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
const content = captureCall[0].properties.$ai_output_choices[0].content
expect(content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'file',
mediaType: 'image/jpeg',
data: expect.any(String),
}),
])
)
})
it('maps ai.response.files URL image entries when inline data is absent', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Generate image' }] }]),
'ai.response.files': JSON.stringify([
{
mimeType: 'image/webp',
url: 'https://example.com/generated.webp',
},
]),
'ai.usage.promptTokens': 9,
'ai.usage.completionTokens': 4,
},
duration: [0, 190000000],
status: { code: 1 },
spanContext: () => mockSpanContext('files-url-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'files-url-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
const content = captureCall[0].properties.$ai_output_choices[0].content
expect(content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'file',
mediaType: 'image/webp',
data: 'https://example.com/generated.webp',
}),
])
)
})
it('falls back to providerMetadata text parts when ai.response.text is missing', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Generate image' }] }]),
'ai.response.providerMetadata': JSON.stringify({
google: {
candidates: [
{
content: {
parts: [{ text: 'Here is your generated image' }],
},
},
],
},
}),
'ai.usage.promptTokens': 9,
'ai.usage.completionTokens': 4,
},
duration: [0, 190000000],
status: { code: 1 },
spanContext: () => mockSpanContext('provider-text-fallback-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'provider-text-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
const content = captureCall[0].properties.$ai_output_choices[0].content
expect(content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: 'Here is your generated image',
}),
])
)
})
it('maps unknown ai.response.* media shapes via generic fallback', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash-image',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Draw a cat' }] }]),
'ai.response.generated': JSON.stringify({
result: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'iVBORw0KGgoAAAANSUhEUgAA',
},
},
],
},
}),
'ai.usage.promptTokens': 11,
'ai.usage.completionTokens': 1301,
},
duration: [0, 300000000],
status: { code: 1 },
spanContext: () => mockSpanContext('unknown-response-media-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'unknown-response-media-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices[0].content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'file',
mediaType: 'image/png',
data: expect.any(String),
}),
])
)
})
it('keeps text and tool-call output mapping unchanged', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Use a tool' }] }]),
'ai.response.text': 'Let me check',
'ai.response.toolCalls': JSON.stringify([
{ toolName: 'lookup', toolCallId: 'tc-1', input: { q: 'weather' } },
]),
'ai.usage.promptTokens': 3,
'ai.usage.completionTokens': 4,
},
duration: [0, 120000000],
status: { code: 1 },
spanContext: () => mockSpanContext('text-tool-regression'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'regression-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices).toEqual([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Let me check' },
{
type: 'tool-call',
id: 'tc-1',
function: {
name: 'lookup',
arguments: '{"q":"weather"}',
},
},
],
},
])
})
it('maps ai.response.messages with multiple assistant parts including image files', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash-image',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Draw a cat' }] }]),
'ai.response.messages': JSON.stringify([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Here is your image' },
{
type: 'file',
mediaType: 'image/png',
data: 'iVBORw0KGgoAAAANSUhEUgAA',
},
],
},
]),
'ai.usage.promptTokens': 11,
'ai.usage.completionTokens': 1200,
},
duration: [0, 300000000],
status: { code: 1 },
spanContext: () => mockSpanContext('response-messages-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'response-messages-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices).toEqual([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Here is your image' },
{
type: 'file',
name: 'generated_file',
mediaType: 'image/png',
data: expect.any(String),
},
],
},
])
})
it('maps ai.response.messages image-typed parts into file output blocks', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash-image',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Draw a cat' }] }]),
'ai.response.messages': JSON.stringify([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Image ready' },
{ type: 'image', image_url: 'https://example.com/generated.png', mediaType: 'image/png' },
],
},
]),
'ai.usage.promptTokens': 11,
'ai.usage.completionTokens': 1200,
},
duration: [0, 300000000],
status: { code: 1 },
spanContext: () => mockSpanContext('response-messages-image-type-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'response-messages-image-type-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices).toEqual([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Image ready' },
{
type: 'file',
name: 'generated_file',
mediaType: 'image/png',
data: 'https://example.com/generated.png',
},
],
},
])
})
it('maps media blocks from generic gen_ai.response.* attributes', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gemini-2.5-flash-image',
'ai.model.provider': 'google',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Draw a cat' }] }]),
'gen_ai.response.payload': JSON.stringify({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'iVBORw0KGgoAAAANSUhEUgAA',
},
},
],
},
},
],
}),
'ai.usage.promptTokens': 11,
'ai.usage.completionTokens': 1200,
},
duration: [0, 300000000],
status: { code: 1 },
spanContext: () => mockSpanContext('generic-gen-ai-response-media-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'generic-gen-ai-response-media-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices[0].content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'file',
mediaType: 'image/png',
data: expect.any(String),
}),
])
)
})
it('maps multiple response messages when present', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText.doGenerate',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.prompt.messages': JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'Two lines' }] }]),
'ai.response.messages': JSON.stringify([
{ role: 'assistant', content: [{ type: 'text', text: 'Line one' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Line two' }] },
]),
'ai.usage.promptTokens': 5,
'ai.usage.completionTokens': 10,
},
duration: [0, 150000000],
status: { code: 1 },
spanContext: () => mockSpanContext('multiple-messages-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'multiple-messages-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(1)
const [captureCall] = (mockInsightsClient.capture as jest.Mock).mock.calls
expect(captureCall[0].properties.$ai_output_choices).toEqual([
{ role: 'assistant', content: [{ type: 'text', text: 'Line one' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Line two' }] },
])
})
it('does not map parent ai.generateText span even if it has response attributes', async () => {
await captureSpan(
{
attributes: {
'ai.operationId': 'ai.generateText',
'ai.model.id': 'gpt-4o-mini',
'ai.model.provider': 'openai',
'ai.response.text': 'Parent span text',
'ai.usage.promptTokens': 10,
'ai.usage.completionTokens': 8,
},
duration: [0, 100000000],
status: { code: 1 },
spanContext: () => mockSpanContext('parent-trace'),
} as any,
mockInsightsClient,
{ insightsDistinctId: 'parent-user' }
)
expect(mockInsightsClient.capture).toHaveBeenCalledTimes(0)
})
})