diff --git a/nodejs/src/ingestion/ai/otel/middleware/traceloop.test.ts b/nodejs/src/ingestion/ai/otel/middleware/traceloop.test.ts index 7f29af5a1f58..5f81fbc9974b 100644 --- a/nodejs/src/ingestion/ai/otel/middleware/traceloop.test.ts +++ b/nodejs/src/ingestion/ai/otel/middleware/traceloop.test.ts @@ -204,6 +204,28 @@ describe('traceloop middleware', () => { expect(event.properties!['llm.usage.total_tokens']).toBeUndefined() expect(event.properties!['llm.response.finish_reason']).toBeUndefined() expect(event.properties!['llm.response.stop_reason']).toBeUndefined() + // stop_reason takes priority over finish_reason + expect(event.properties!['$ai_stop_reason']).toBe('end_turn') + }) + + it('maps finish_reason to $ai_stop_reason when stop_reason is absent', () => { + const event = createEvent('$ai_generation', { + 'llm.request.type': 'chat', + 'llm.response.finish_reason': 'stop', + }) + traceloop.process(event, () => mapOtelAttributes(event)) + + expect(event.properties!['llm.response.finish_reason']).toBeUndefined() + expect(event.properties!['$ai_stop_reason']).toBe('stop') + }) + + it('leaves $ai_stop_reason undefined when neither reason is present', () => { + const event = createEvent('$ai_generation', { + 'llm.request.type': 'chat', + }) + traceloop.process(event, () => mapOtelAttributes(event)) + + expect(event.properties!['$ai_stop_reason']).toBeUndefined() }) }) }) diff --git a/nodejs/src/ingestion/ai/otel/middleware/traceloop.ts b/nodejs/src/ingestion/ai/otel/middleware/traceloop.ts index e5260df709ae..d072c5c3f1bf 100644 --- a/nodejs/src/ingestion/ai/otel/middleware/traceloop.ts +++ b/nodejs/src/ingestion/ai/otel/middleware/traceloop.ts @@ -11,8 +11,6 @@ const STRIP_KEYS = [ 'traceloop.entity.output', 'llm.is_streaming', 'llm.usage.total_tokens', - 'llm.response.finish_reason', - 'llm.response.stop_reason', ] interface IndexedEntry { @@ -157,6 +155,16 @@ function process(event: PluginEvent, next: () => void): void { } } + // Map stop/finish reason to $ai_stop_reason before stripping + if (props['$ai_stop_reason'] === undefined) { + const stopReason = props['llm.response.stop_reason'] ?? props['llm.response.finish_reason'] + if (stopReason !== undefined) { + props['$ai_stop_reason'] = stopReason + } + } + delete props['llm.response.stop_reason'] + delete props['llm.response.finish_reason'] + props['$ai_lib'] = 'opentelemetry/traceloop' for (const key of STRIP_KEYS) { diff --git a/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.test.ts b/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.test.ts index fa53479e4ba1..e18b8fa0f6d1 100644 --- a/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.test.ts +++ b/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.test.ts @@ -94,6 +94,27 @@ describe('vercel-ai middleware', () => { expect(key).not.toBe('operation.name') expect(key).not.toBe('resource.name') } + expect(event.properties!['$ai_stop_reason']).toBe('stop') + }) + + it('maps gen_ai.response.finish_reasons array to $ai_stop_reason', () => { + const event = createEvent('$ai_generation', { + 'ai.operationId': 'ai.generateText.doGenerate', + 'gen_ai.response.finish_reasons': ['length'], + }) + convertOtelEvent(event) + + expect(event.properties!['gen_ai.response.finish_reasons']).toBeUndefined() + expect(event.properties!['$ai_stop_reason']).toBe('length') + }) + + it('leaves $ai_stop_reason undefined when no finish reason is present', () => { + const event = createEvent('$ai_generation', { + 'ai.operationId': 'ai.generateText.doGenerate', + }) + convertOtelEvent(event) + + expect(event.properties!['$ai_stop_reason']).toBeUndefined() }) }) diff --git a/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.ts b/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.ts index cb37071402e8..54aca212c6de 100644 --- a/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.ts +++ b/nodejs/src/ingestion/ai/otel/middleware/vercel-ai.ts @@ -18,7 +18,6 @@ const STRIP_KEYS = [ 'ai.usage.promptTokens', 'ai.usage.completionTokens', 'ai.usage.tokens', - 'ai.response.finishReason', 'ai.response.id', 'ai.response.model', 'ai.response.timestamp', @@ -43,7 +42,6 @@ const STRIP_KEYS = [ 'resource.name', // Standard GenAI semantic convention attributes not mapped to $ai_* properties 'gen_ai.request.max_tokens', - 'gen_ai.response.finish_reasons', 'gen_ai.response.id', ] @@ -157,6 +155,19 @@ function process(event: PluginEvent, next: () => void): void { } } + // Map finish reason to $ai_stop_reason before stripping + if (props['$ai_stop_reason'] === undefined) { + const vercelReason = props['ai.response.finishReason'] + const genAiReasons = props['gen_ai.response.finish_reasons'] + if (vercelReason !== undefined) { + props['$ai_stop_reason'] = vercelReason + } else if (Array.isArray(genAiReasons) && genAiReasons.length > 0) { + props['$ai_stop_reason'] = genAiReasons[0] + } + } + delete props['ai.response.finishReason'] + delete props['gen_ai.response.finish_reasons'] + props['$ai_lib'] = 'opentelemetry/vercel-ai' for (const key of STRIP_KEYS) {