mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
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
937 lines
32 KiB
TypeScript
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)
|
|
})
|
|
})
|