From 5d59cce0db646adc128eeaf4dbcb898800cb2182 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 20 Jan 2026 17:18:14 +0100 Subject: [PATCH 1/6] wip(ai): Support scope-level conversation ID for AI tracing --- packages/core/src/exports.ts | 18 ++++++++++++++++++ packages/core/src/index.ts | 2 ++ packages/core/src/scope.ts | 21 +++++++++++++++++++++ packages/core/src/tracing/sentrySpan.ts | 17 +++++++++++++++++ packages/core/src/utils/spanUtils.ts | 23 +++++++++++++++++++++++ packages/node/src/index.ts | 2 ++ 6 files changed, 83 insertions(+) diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index a59e521febc7..eedb36f799ec 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -111,6 +111,24 @@ export function setUser(user: User | null): void { getIsolationScope().setUser(user); } +/** + * Sets the conversation ID for the current isolation scope. + * + * @param conversationId The conversation ID to set. Pass `null` or `undefined` to unset the conversation ID. + */ +export function setConversationId(conversationId: string | null | undefined): void { + getIsolationScope().setConversationId(conversationId); +} + +/** + * Gets the conversation ID from the current isolation scope. + * + * @returns The conversation ID, or `undefined` if not set. + */ +export function getConversationId(): string | undefined { + return getIsolationScope().getConversationId(); +} + /** * The last error event id of the isolation scope. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0fdd328a42d2..aa15b44e002c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,8 @@ export { setTag, setTags, setUser, + setConversationId, + getConversationId, isInitialized, isEnabled, startSession, diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index b5a64bb8818a..cf5b3eb6d300 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -153,6 +153,9 @@ export class Scope { /** Contains the last event id of a captured event. */ protected _lastEventId?: string; + /** Conversation ID */ + protected _conversationId?: string; + // NOTE: Any field which gets added here should get added not only to the constructor but also to the `clone` method. public constructor() { @@ -202,6 +205,7 @@ export class Scope { newScope._propagationContext = { ...this._propagationContext }; newScope._client = this._client; newScope._lastEventId = this._lastEventId; + newScope._conversationId = this._conversationId; _setSpanForScope(newScope, _getSpanForScope(this)); @@ -284,6 +288,23 @@ export class Scope { return this._user; } + /** + * Set the conversation ID for this scope. + * Set to `null` to unset the conversation ID. + */ + public setConversationId(conversationId: string | null | undefined): this { + this._conversationId = conversationId || undefined; + this._notifyScopeListeners(); + return this; + } + + /** + * Get the conversation ID from this scope. + */ + public getConversationId(): string | undefined { + return this._conversationId; + } + /** * Set an object that will be merged into existing tags on the scope, * and will be sent as tags data with the event. diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 9bd98b9741c6..913501665aee 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -38,6 +38,7 @@ import { TRACE_FLAG_SAMPLED, } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; +import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from './ai/gen-ai-attributes'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; @@ -221,6 +222,22 @@ export class SentrySpan implements Span { * use `spanToJSON(span)` instead. */ public getSpanJSON(): SpanJSON { + // Automatically inject conversation ID from scope if not already set + if (!this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE]) { + const capturedScopes = getCapturedScopesOnSpan(this); + // Try isolation scope first (where setConversationId sets it) + let conversationId = capturedScopes.isolationScope?.getConversationId(); + + // Fallback to regular scope + if (!conversationId) { + conversationId = capturedScopes.scope?.getConversationId(); + } + + if (conversationId) { + this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; + } + } + return { data: this._attributes, description: this._name, diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index d7c261ecd73c..6b31d352a4d7 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -7,6 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; +import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../tracing/ai/gen-ai-attributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; @@ -149,6 +150,28 @@ export function spanToJSON(span: Span): SpanJSON { // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { const { attributes, startTime, name, endTime, status, links } = span; + // Automatically inject conversation ID from scope if not already set + if (!attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE]) { + // First try captured scopes (scopes at span creation time) + const capturedScopes = getCapturedScopesOnSpan(span); + // Try captured isolation scope first (where setConversationId sets it) + let conversationId = capturedScopes.isolationScope?.getConversationId(); + + // Fallback to regular scope + if (!conversationId) { + conversationId = capturedScopes.scope?.getConversationId(); + } + + // If not found in captured scopes, try current scopes (from AsyncLocalStorage) + if (!conversationId) { + const currentScope = getCurrentScope(); + conversationId = currentScope?.getConversationId(); + } + + if (conversationId) { + attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; + } + } // In preparation for the next major of OpenTelemetry, we want to support // looking up the parent span id according to the new API diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 84fdf97539bc..f895c4abca83 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -83,6 +83,8 @@ export { setTag, setTags, setUser, + setConversationId, + getConversationId, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, From b6fa98112fece326978cd882577c9bbb3f96f735 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 21 Jan 2026 16:01:15 +0100 Subject: [PATCH 2/6] refactor --- .../scenario-manual-conversation-id.mjs | 79 ++++++++++ .../openai/scenario-separate-scope-1.mjs | 74 +++++++++ .../openai/scenario-separate-scope-2.mjs | 74 +++++++++ .../suites/tracing/openai/test.ts | 140 ++++++++++++++++++ packages/core/src/exports.ts | 9 -- packages/core/src/index.ts | 1 - packages/core/src/tracing/sentrySpan.ts | 29 ++-- packages/core/src/utils/spanUtils.ts | 16 +- packages/core/test/lib/scope.test.ts | 34 +++++ .../core/test/lib/utils/spanUtils.test.ts | 51 +++++++ packages/node/src/index.ts | 1 - 11 files changed, 472 insertions(+), 36 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-manual-conversation-id.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-1.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-2.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-manual-conversation-id.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-manual-conversation-id.mjs new file mode 100644 index 000000000000..a44b4767bbae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-manual-conversation-id.mjs @@ -0,0 +1,79 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Chat completions endpoint + app.post('/openai/chat/completions', (req, res) => { + const { model } = req.body; + + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Mock response from OpenAI', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + // Test: Multiple chat completions in the same conversation with manual conversation ID + await Sentry.startSpan({ op: 'function', name: 'chat-with-manual-conversation-id' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Set conversation ID manually using Sentry API + Sentry.setConversationId('user_chat_session_abc123'); + + // First message in the conversation + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + // Second message in the same conversation + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Tell me more about it' }], + }); + + // Third message in the same conversation + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'What is its population?' }], + }); + }); + + server.close(); + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-1.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-1.mjs new file mode 100644 index 000000000000..dab303a401d9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-1.mjs @@ -0,0 +1,74 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Chat completions endpoint + app.post('/openai/chat/completions', (req, res) => { + const { model } = req.body; + + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Mock response from OpenAI', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // First request/conversation scope + await Sentry.withScope(async scope => { + // Set conversation ID for this request scope BEFORE starting the span + scope.setConversationId('conv_user1_session_abc'); + + await Sentry.startSpan({ op: 'http.server', name: 'GET /chat/conversation-1' }, async () => { + // First message in conversation 1 + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello from conversation 1' }], + }); + + // Second message in conversation 1 + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Follow-up in conversation 1' }], + }); + }); + }); + + server.close(); + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-2.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-2.mjs new file mode 100644 index 000000000000..09f73afed761 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-separate-scope-2.mjs @@ -0,0 +1,74 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Chat completions endpoint + app.post('/openai/chat/completions', (req, res) => { + const { model } = req.body; + + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Mock response from OpenAI', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Second request/conversation scope (completely separate) + await Sentry.withScope(async scope => { + // Set different conversation ID for this request scope BEFORE starting the span + scope.setConversationId('conv_user2_session_xyz'); + + await Sentry.startSpan({ op: 'http.server', name: 'GET /chat/conversation-2' }, async () => { + // First message in conversation 2 + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello from conversation 2' }], + }); + + // Second message in conversation 2 + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Follow-up in conversation 2' }], + }); + }); + }); + + server.close(); + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 4d41b34b8c31..1cfcdd38298c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -720,4 +720,144 @@ describe('OpenAI integration', () => { .completed(); }); }); + + // Test for manual conversation ID setting using setConversationId() + const EXPECTED_TRANSACTION_MANUAL_CONVERSATION_ID = { + transaction: 'chat-with-manual-conversation-id', + spans: expect.arrayContaining([ + // All three chat completion spans should have the same manually-set conversation ID + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'user_chat_session_abc123', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'user_chat_session_abc123', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'user_chat_session_abc123', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-manual-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('attaches manual conversation ID set via setConversationId() to all chat spans', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_MANUAL_CONVERSATION_ID }) + .start() + .completed(); + }); + }); + + // Test for scope isolation - different scopes have different conversation IDs + const EXPECTED_TRANSACTION_CONVERSATION_1 = { + transaction: 'GET /chat/conversation-1', + spans: expect.arrayContaining([ + // Both chat completion spans in conversation 1 should have conv_user1_session_abc + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv_user1_session_abc', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv_user1_session_abc', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_CONVERSATION_2 = { + transaction: 'GET /chat/conversation-2', + spans: expect.arrayContaining([ + // Both chat completion spans in conversation 2 should have conv_user2_session_xyz + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv_user2_session_xyz', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.conversation.id': 'conv_user2_session_xyz', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'sentry.op': 'gen_ai.chat', + }), + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-separate-scope-1.mjs', 'instrument.mjs', (createRunner, test) => { + test('isolates conversation IDs across separate scopes - conversation 1', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_CONVERSATION_1 }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-separate-scope-2.mjs', 'instrument.mjs', (createRunner, test) => { + test('isolates conversation IDs across separate scopes - conversation 2', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_CONVERSATION_2 }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index eedb36f799ec..d7931565b7ab 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -120,15 +120,6 @@ export function setConversationId(conversationId: string | null | undefined): vo getIsolationScope().setConversationId(conversationId); } -/** - * Gets the conversation ID from the current isolation scope. - * - * @returns The conversation ID, or `undefined` if not set. - */ -export function getConversationId(): string | undefined { - return getIsolationScope().getConversationId(); -} - /** * The last error event id of the isolation scope. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa15b44e002c..bfc89f27c9a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,7 +26,6 @@ export { setTags, setUser, setConversationId, - getConversationId, isInitialized, isEnabled, startSession, diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 913501665aee..663863f20313 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -223,23 +223,14 @@ export class SentrySpan implements Span { */ public getSpanJSON(): SpanJSON { // Automatically inject conversation ID from scope if not already set - if (!this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE]) { - const capturedScopes = getCapturedScopesOnSpan(this); - // Try isolation scope first (where setConversationId sets it) - let conversationId = capturedScopes.isolationScope?.getConversationId(); - - // Fallback to regular scope - if (!conversationId) { - conversationId = capturedScopes.scope?.getConversationId(); - } + const conversationId = this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] || this._getConversationIdFromScope(); - if (conversationId) { - this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; - } - } + const data = conversationId + ? { ...this._attributes, [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: conversationId } + : this._attributes; return { - data: this._attributes, + data, description: this._name, op: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP], parent_span_id: this._parentSpanId, @@ -299,6 +290,16 @@ export class SentrySpan implements Span { return !!this._isStandaloneSpan; } + /** + * Get conversation ID from captured scopes. + * Current scope takes precedence over isolation scope. + */ + private _getConversationIdFromScope(): string | undefined { + const capturedScopes = getCapturedScopesOnSpan(this); + // Check current scope first (higher precedence) + return capturedScopes.scope?.getConversationId() || capturedScopes.isolationScope?.getConversationId(); + } + /** Emit `spanEnd` when the span is ended. */ private _onSpanEnded(): void { const client = getClient(); diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 6b31d352a4d7..8a90060e2f1f 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -152,20 +152,14 @@ export function spanToJSON(span: Span): SpanJSON { const { attributes, startTime, name, endTime, status, links } = span; // Automatically inject conversation ID from scope if not already set if (!attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE]) { - // First try captured scopes (scopes at span creation time) const capturedScopes = getCapturedScopesOnSpan(span); - // Try captured isolation scope first (where setConversationId sets it) - let conversationId = capturedScopes.isolationScope?.getConversationId(); + // Check current scope first (higher precedence), then isolation scope + let conversationId = + capturedScopes.scope?.getConversationId() || capturedScopes.isolationScope?.getConversationId(); - // Fallback to regular scope + // If not found in captured scopes, try currently active scope as fallback (for OTel spans) if (!conversationId) { - conversationId = capturedScopes.scope?.getConversationId(); - } - - // If not found in captured scopes, try current scopes (from AsyncLocalStorage) - if (!conversationId) { - const currentScope = getCurrentScope(); - conversationId = currentScope?.getConversationId(); + conversationId = getCurrentScope()?.getConversationId(); } if (conversationId) { diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index f1e5c58550be..f01a9be27310 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -1011,6 +1011,40 @@ describe('Scope', () => { }); }); + describe('setConversationId() / getConversationId()', () => { + test('sets and gets conversation ID', () => { + const scope = new Scope(); + scope.setConversationId('conv_abc123'); + expect(scope.getConversationId()).toEqual('conv_abc123'); + }); + + test('unsets conversation ID with null or undefined', () => { + const scope = new Scope(); + scope.setConversationId('conv_abc123'); + scope.setConversationId(null); + expect(scope.getConversationId()).toBeUndefined(); + + scope.setConversationId('conv_abc123'); + scope.setConversationId(undefined); + expect(scope.getConversationId()).toBeUndefined(); + }); + + test('clones conversation ID to new scope', () => { + const scope = new Scope(); + scope.setConversationId('conv_clone123'); + const clonedScope = scope.clone(); + expect(clonedScope.getConversationId()).toEqual('conv_clone123'); + }); + + test('notifies scope listeners when conversation ID is set', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setConversationId('conv_listener'); + expect(listener).toHaveBeenCalledWith(scope); + }); + }); + describe('addBreadcrumb()', () => { test('adds a breadcrumb', () => { const scope = new Scope(); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index bca9a406dd50..230e4b16b3ad 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, test } from 'vitest'; import { convertSpanLinksForEnvelope, + getCurrentScope, + getIsolationScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -425,6 +427,55 @@ describe('spanToJSON', () => { data: {}, }); }); + + describe('conversation ID injection', () => { + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + }); + + it('injects conversation ID from current scope', () => { + getCurrentScope().setConversationId('conv_test_123'); + + startSpan({ name: 'test' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_test_123'); + }); + }); + + it('injects conversation ID from isolation scope when current scope is empty', () => { + getCurrentScope().setConversationId(null); + getIsolationScope().setConversationId('conv_isolation_789'); + + startSpan({ name: 'test' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_isolation_789'); + }); + }); + + it('does not inject conversation ID when not set', () => { + const span = new SentrySpan({ name: 'test' }); + const spanJSON = spanToJSON(span); + + expect(spanJSON.data?.['gen_ai.conversation.id']).toBeUndefined(); + }); + + it('prefers existing conversation ID attribute over scope', () => { + getCurrentScope().setConversationId('conv_from_scope'); + + const otelSpan = createMockedOtelSpan({ + spanId: 'SPAN-1', + traceId: 'TRACE-1', + name: 'test span', + attributes: { + 'gen_ai.conversation.id': 'conv_from_attribute', + }, + }); + + const spanJSON = spanToJSON(otelSpan); + expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_from_attribute'); + }); + }); }); describe('spanIsSampled', () => { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index f895c4abca83..e96a28483174 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -84,7 +84,6 @@ export { setTags, setUser, setConversationId, - getConversationId, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, From 6f202e01f7261529cef8309be80dc3d31d88b0d9 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 21 Jan 2026 16:17:28 +0100 Subject: [PATCH 3/6] add exports where missing --- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 28623724db19..7005fcf26b86 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -114,6 +114,7 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setContext, + setConversationId, setCurrentClient, setExtra, setExtras, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd0a0cb83095..34889236032c 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -25,6 +25,7 @@ export { Scope, SDK_VERSION, setContext, + setConversationId, setExtra, setExtras, setTag, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 9de1e55dacb6..5f2d628ce983 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -48,6 +48,7 @@ export { Scope, SDK_VERSION, setContext, + setConversationId, setExtra, setExtras, setTag, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 4fa5c727be59..636852d722d3 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -25,6 +25,7 @@ export { Scope, SDK_VERSION, setContext, + setConversationId, setExtra, setExtras, setTag, From a137f69e575f147ae8d4e62a837dd16a34c132a4 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 22 Jan 2026 13:33:48 +0100 Subject: [PATCH 4/6] improve logic, refactor to an integration --- packages/browser/src/sdk.ts | 2 + packages/cloudflare/src/sdk.ts | 2 + packages/core/src/index.ts | 1 + .../core/src/integrations/conversationId.ts | 35 +++++++ packages/core/src/scope.ts | 16 +-- packages/core/src/semanticAttributes.ts | 15 +++ packages/core/src/tracing/sentrySpan.ts | 20 +--- packages/core/src/utils/spanUtils.ts | 17 ---- .../lib/integrations/conversationId.test.ts | 98 +++++++++++++++++++ packages/core/test/lib/scope.test.ts | 35 +++++-- .../core/test/lib/utils/spanUtils.test.ts | 49 ---------- packages/node-core/src/sdk/index.ts | 2 + packages/vercel-edge/src/sdk.ts | 2 + 13 files changed, 196 insertions(+), 98 deletions(-) create mode 100644 packages/core/src/integrations/conversationId.ts create mode 100644 packages/core/test/lib/integrations/conversationId.test.ts diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 800c1b701352..eeff23fe8f17 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -1,5 +1,6 @@ import type { Client, Integration, Options } from '@sentry/core'; import { + conversationIdIntegration, dedupeIntegration, functionToStringIntegration, getIntegrationsToSetup, @@ -31,6 +32,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration(), functionToStringIntegration(), + conversationIdIntegration(), browserApiErrorsIntegration(), breadcrumbsIntegration(), globalHandlersIntegration(), diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 238cc13253a5..0211fa7f96a9 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -1,6 +1,7 @@ import type { Integration } from '@sentry/core'; import { consoleIntegration, + conversationIdIntegration, dedupeIntegration, functionToStringIntegration, getIntegrationsToSetup, @@ -30,6 +31,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration(), functionToStringIntegration(), + conversationIdIntegration(), linkedErrorsIntegration(), fetchIntegration(), honoIntegration(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bfc89f27c9a2..3239e1923b54 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,7 @@ export { thirdPartyErrorFilterIntegration } from './integrations/third-party-err export { consoleIntegration } from './integrations/console'; export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; +export { conversationIdIntegration } from './integrations/conversationId'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) diff --git a/packages/core/src/integrations/conversationId.ts b/packages/core/src/integrations/conversationId.ts new file mode 100644 index 000000000000..c11b587d3a71 --- /dev/null +++ b/packages/core/src/integrations/conversationId.ts @@ -0,0 +1,35 @@ +import type { Client } from '../client'; +import { getCurrentScope, getIsolationScope } from '../currentScopes'; +import { defineIntegration } from '../integration'; +import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../semanticAttributes'; +import type { IntegrationFn } from '../types-hoist/integration'; +import type { Span } from '../types-hoist/span'; + +const INTEGRATION_NAME = 'ConversationId'; + +const _conversationIdIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client: Client) { + client.on('spanStart', (span: Span) => { + const scopeData = getCurrentScope().getScopeData(); + const isolationScopeData = getIsolationScope().getScopeData(); + + const conversationId = scopeData.conversationId || isolationScopeData.conversationId; + + if (conversationId) { + span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, conversationId); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Automatically applies conversation ID from scope to spans. + * + * This integration reads the conversation ID from the current or isolation scope + * and applies it to spans when they start. This ensures the conversation ID is + * available for all AI-related operations. + */ +export const conversationIdIntegration = defineIntegration(_conversationIdIntegration); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index cf5b3eb6d300..8f05cf78c16f 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -51,6 +51,7 @@ export interface ScopeContext { attributes?: RawAttributes>; fingerprint: string[]; propagationContext: PropagationContext; + conversationId?: string; } export interface SdkProcessingMetadata { @@ -85,6 +86,7 @@ export interface ScopeData { level?: SeverityLevel; transactionName?: string; span?: Span; + conversationId?: string; } /** @@ -298,13 +300,6 @@ export class Scope { return this; } - /** - * Get the conversation ID from this scope. - */ - public getConversationId(): string | undefined { - return this._conversationId; - } - /** * Set an object that will be merged into existing tags on the scope, * and will be sent as tags data with the event. @@ -528,6 +523,7 @@ export class Scope { level, fingerprint = [], propagationContext, + conversationId, } = scopeInstance || {}; this._tags = { ...this._tags, ...tags }; @@ -551,6 +547,10 @@ export class Scope { this._propagationContext = propagationContext; } + if (conversationId) { + this._conversationId = conversationId; + } + return this; } @@ -570,6 +570,7 @@ export class Scope { this._transactionName = undefined; this._fingerprint = undefined; this._session = undefined; + this._conversationId = undefined; _setSpanForScope(this, undefined); this._attachments = []; this.setPropagationContext({ @@ -662,6 +663,7 @@ export class Scope { sdkProcessingMetadata: this._sdkProcessingMetadata, transactionName: this._transactionName, span: _getSpanForScope(this), + conversationId: this._conversationId, }; } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 9b90809c0091..88b0f470dfa3 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -77,3 +77,18 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types */ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; + +/** + * ============================================================================= + * GEN AI ATTRIBUTES + * Based on OpenTelemetry Semantic Conventions for Generative AI + * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/ + * ============================================================================= + */ + +/** + * The conversation ID for linking messages across API calls. + * For OpenAI Assistants API: thread_id + * For LangGraph: configurable.thread_id + */ +export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 663863f20313..9bd98b9741c6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -38,7 +38,6 @@ import { TRACE_FLAG_SAMPLED, } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from './ai/gen-ai-attributes'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; @@ -222,15 +221,8 @@ export class SentrySpan implements Span { * use `spanToJSON(span)` instead. */ public getSpanJSON(): SpanJSON { - // Automatically inject conversation ID from scope if not already set - const conversationId = this._attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] || this._getConversationIdFromScope(); - - const data = conversationId - ? { ...this._attributes, [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: conversationId } - : this._attributes; - return { - data, + data: this._attributes, description: this._name, op: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP], parent_span_id: this._parentSpanId, @@ -290,16 +282,6 @@ export class SentrySpan implements Span { return !!this._isStandaloneSpan; } - /** - * Get conversation ID from captured scopes. - * Current scope takes precedence over isolation scope. - */ - private _getConversationIdFromScope(): string | undefined { - const capturedScopes = getCapturedScopesOnSpan(this); - // Check current scope first (higher precedence) - return capturedScopes.scope?.getConversationId() || capturedScopes.isolationScope?.getConversationId(); - } - /** Emit `spanEnd` when the span is ended. */ private _onSpanEnded(): void { const client = getClient(); diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 8a90060e2f1f..d7c261ecd73c 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -7,7 +7,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../tracing/ai/gen-ai-attributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import { getCapturedScopesOnSpan } from '../tracing/utils'; @@ -150,22 +149,6 @@ export function spanToJSON(span: Span): SpanJSON { // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { const { attributes, startTime, name, endTime, status, links } = span; - // Automatically inject conversation ID from scope if not already set - if (!attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE]) { - const capturedScopes = getCapturedScopesOnSpan(span); - // Check current scope first (higher precedence), then isolation scope - let conversationId = - capturedScopes.scope?.getConversationId() || capturedScopes.isolationScope?.getConversationId(); - - // If not found in captured scopes, try currently active scope as fallback (for OTel spans) - if (!conversationId) { - conversationId = getCurrentScope()?.getConversationId(); - } - - if (conversationId) { - attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; - } - } // In preparation for the next major of OpenTelemetry, we want to support // looking up the parent span id according to the new API diff --git a/packages/core/test/lib/integrations/conversationId.test.ts b/packages/core/test/lib/integrations/conversationId.test.ts new file mode 100644 index 000000000000..e9ea9cc50d45 --- /dev/null +++ b/packages/core/test/lib/integrations/conversationId.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getCurrentScope, getIsolationScope, setCurrentClient, startSpan } from '../../../src'; +import { conversationIdIntegration } from '../../../src/integrations/conversationId'; +import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../../../src/semanticAttributes'; +import { spanToJSON } from '../../../src/utils/spanUtils'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('ConversationId', () => { + beforeEach(() => { + const testClient = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1, + }), + ); + setCurrentClient(testClient); + testClient.init(); + testClient.addIntegration(conversationIdIntegration()); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + getCurrentScope().setConversationId(null); + getIsolationScope().setConversationId(null); + }); + + it('applies conversation ID from current scope to span', () => { + getCurrentScope().setConversationId('conv_test_123'); + + startSpan({ name: 'test-span' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_test_123'); + }); + }); + + it('applies conversation ID from isolation scope when current scope does not have one', () => { + getIsolationScope().setConversationId('conv_isolation_456'); + + startSpan({ name: 'test-span' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_isolation_456'); + }); + }); + + it('prefers current scope over isolation scope', () => { + getCurrentScope().setConversationId('conv_current_789'); + getIsolationScope().setConversationId('conv_isolation_999'); + + startSpan({ name: 'test-span' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_current_789'); + }); + }); + + it('does not apply conversation ID when not set in scope', () => { + startSpan({ name: 'test-span' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + }); + }); + + it('works when conversation ID is unset with null', () => { + getCurrentScope().setConversationId('conv_test_123'); + getCurrentScope().setConversationId(null); + + startSpan({ name: 'test-span' }, span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + }); + }); + + it('applies conversation ID to nested spans', () => { + getCurrentScope().setConversationId('conv_nested_abc'); + + startSpan({ name: 'parent-span' }, () => { + startSpan({ name: 'child-span' }, childSpan => { + const childJSON = spanToJSON(childSpan); + expect(childJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_nested_abc'); + }); + }); + }); + + it('scope conversation ID overrides explicitly set attribute', () => { + getCurrentScope().setConversationId('conv_from_scope'); + + startSpan( + { + name: 'test-span', + attributes: { + [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: 'conv_explicit', + }, + }, + span => { + const spanJSON = spanToJSON(span); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_from_scope'); + }, + ); + }); +}); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index f01a9be27310..11fc4cb62fff 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -1011,29 +1011,29 @@ describe('Scope', () => { }); }); - describe('setConversationId() / getConversationId()', () => { - test('sets and gets conversation ID', () => { + describe('setConversationId() / getScopeData()', () => { + test('sets and gets conversation ID via getScopeData', () => { const scope = new Scope(); scope.setConversationId('conv_abc123'); - expect(scope.getConversationId()).toEqual('conv_abc123'); + expect(scope.getScopeData().conversationId).toEqual('conv_abc123'); }); test('unsets conversation ID with null or undefined', () => { const scope = new Scope(); scope.setConversationId('conv_abc123'); scope.setConversationId(null); - expect(scope.getConversationId()).toBeUndefined(); + expect(scope.getScopeData().conversationId).toBeUndefined(); scope.setConversationId('conv_abc123'); scope.setConversationId(undefined); - expect(scope.getConversationId()).toBeUndefined(); + expect(scope.getScopeData().conversationId).toBeUndefined(); }); test('clones conversation ID to new scope', () => { const scope = new Scope(); scope.setConversationId('conv_clone123'); const clonedScope = scope.clone(); - expect(clonedScope.getConversationId()).toEqual('conv_clone123'); + expect(clonedScope.getScopeData().conversationId).toEqual('conv_clone123'); }); test('notifies scope listeners when conversation ID is set', () => { @@ -1043,6 +1043,29 @@ describe('Scope', () => { scope.setConversationId('conv_listener'); expect(listener).toHaveBeenCalledWith(scope); }); + + test('clears conversation ID when scope is cleared', () => { + const scope = new Scope(); + scope.setConversationId('conv_to_clear'); + expect(scope.getScopeData().conversationId).toEqual('conv_to_clear'); + scope.clear(); + expect(scope.getScopeData().conversationId).toBeUndefined(); + }); + + test('updates conversation ID when scope is updated with ScopeContext', () => { + const scope = new Scope(); + scope.setConversationId('conv_old'); + scope.update({ conversationId: 'conv_updated' }); + expect(scope.getScopeData().conversationId).toEqual('conv_updated'); + }); + + test('updates conversation ID when scope is updated with another Scope', () => { + const scope1 = new Scope(); + const scope2 = new Scope(); + scope2.setConversationId('conv_from_scope2'); + scope1.update(scope2); + expect(scope1.getScopeData().conversationId).toEqual('conv_from_scope2'); + }); }); describe('addBreadcrumb()', () => { diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 230e4b16b3ad..912d8ab418da 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -427,55 +427,6 @@ describe('spanToJSON', () => { data: {}, }); }); - - describe('conversation ID injection', () => { - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - setCurrentClient(client); - }); - - it('injects conversation ID from current scope', () => { - getCurrentScope().setConversationId('conv_test_123'); - - startSpan({ name: 'test' }, span => { - const spanJSON = spanToJSON(span); - expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_test_123'); - }); - }); - - it('injects conversation ID from isolation scope when current scope is empty', () => { - getCurrentScope().setConversationId(null); - getIsolationScope().setConversationId('conv_isolation_789'); - - startSpan({ name: 'test' }, span => { - const spanJSON = spanToJSON(span); - expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_isolation_789'); - }); - }); - - it('does not inject conversation ID when not set', () => { - const span = new SentrySpan({ name: 'test' }); - const spanJSON = spanToJSON(span); - - expect(spanJSON.data?.['gen_ai.conversation.id']).toBeUndefined(); - }); - - it('prefers existing conversation ID attribute over scope', () => { - getCurrentScope().setConversationId('conv_from_scope'); - - const otelSpan = createMockedOtelSpan({ - spanId: 'SPAN-1', - traceId: 'TRACE-1', - name: 'test span', - attributes: { - 'gen_ai.conversation.id': 'conv_from_attribute', - }, - }); - - const spanJSON = spanToJSON(otelSpan); - expect(spanJSON.data?.['gen_ai.conversation.id']).toEqual('conv_from_attribute'); - }); - }); }); describe('spanIsSampled', () => { diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 1f0fd8835340..3d6b4c61619e 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -3,6 +3,7 @@ import { applySdkMetadata, consoleIntegration, consoleSandbox, + conversationIdIntegration, debug, functionToStringIntegration, getCurrentScope, @@ -55,6 +56,7 @@ export function getDefaultIntegrations(): Integration[] { linkedErrorsIntegration(), requestDataIntegration(), systemErrorIntegration(), + conversationIdIntegration(), // Native Wrappers consoleIntegration(), httpIntegration(), diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 5c8387c9bc7a..269d9ada280a 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -9,6 +9,7 @@ import { import type { Client, Integration, Options } from '@sentry/core'; import { consoleIntegration, + conversationIdIntegration, createStackParser, debug, dedupeIntegration, @@ -56,6 +57,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration(), functionToStringIntegration(), + conversationIdIntegration(), linkedErrorsIntegration(), winterCGFetchIntegration(), consoleIntegration(), From 0ea7fdda1ba6f18dedf7abd09eeb77f5359c3702 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 22 Jan 2026 13:49:01 +0100 Subject: [PATCH 5/6] refactor, add changelog unreleased entry --- CHANGELOG.md | 19 +++++++++++++++++++ .../core/test/lib/utils/spanUtils.test.ts | 2 -- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25bc54e75abc..4e5aa0101751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(core): Introduces a new `Sentry.setConversationId()` API to track multi turn AI conversations across API calls. ([#18909](https://github.com/getsentry/sentry-javascript/pull/18909))** + + You can now set a conversation ID that will be automatically applied to spans within that scope. This allows you to link traces from the same conversation together. + + ```javascript + import * as Sentry from '@sentry/node'; + + // Set conversation ID for all subsequent spans + Sentry.setConversationId('conv_abc123'); + + // All AI spans will now include the gen_ai.conversation.id attribute + await openai.chat.completions.create({...}); + ``` + + This is particularly useful for tracking multiple AI API calls that are part of the same conversation, allowing you to analyze entire conversation flows in Sentry. + The conversation ID is stored on the isolation scope and automatically applied to spans via the new `conversationIdIntegration`. + ### Other Changes - feat(deps): Bump OpenTelemetry dependencies diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 912d8ab418da..bca9a406dd50 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, test } from 'vitest'; import { convertSpanLinksForEnvelope, - getCurrentScope, - getIsolationScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, From 84351a6ff70af75838ba0c267b8bf7b556226ce1 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 22 Jan 2026 14:10:10 +0100 Subject: [PATCH 6/6] bump the size --- .size-limit.js | 14 +++++++------- .../suites/public-api/debug/test.ts | 1 + packages/angular/src/sdk.ts | 2 ++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index e8124a622962..39063a460793 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -96,21 +96,21 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '42 KB', + limit: '43 KB', }, { name: '@sentry/browser (incl. sendFeedback)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '30 KB', + limit: '31 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '35 KB', + limit: '36 KB', }, { name: '@sentry/browser (incl. Metrics)', @@ -140,7 +140,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '27 KB', + limit: '28 KB', }, { name: '@sentry/react (incl. Tracing)', @@ -208,7 +208,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, // browser CDN bundles (non-gzipped) { @@ -223,7 +223,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '127 KB', + limit: '128 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -278,7 +278,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '52 KB', + limit: '53 KB', }, // Node SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/public-api/debug/test.ts b/dev-packages/browser-integration-tests/suites/public-api/debug/test.ts index b15c64280544..675f9a776cbf 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/debug/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/debug/test.ts @@ -24,6 +24,7 @@ sentryTest('logs debug messages correctly', async ({ getLocalTestUrl, page }) => ? [ 'Sentry Logger [log]: Integration installed: InboundFilters', 'Sentry Logger [log]: Integration installed: FunctionToString', + 'Sentry Logger [log]: Integration installed: ConversationId', 'Sentry Logger [log]: Integration installed: BrowserApiErrors', 'Sentry Logger [log]: Integration installed: Breadcrumbs', 'Sentry Logger [log]: Global Handler attached: onerror', diff --git a/packages/angular/src/sdk.ts b/packages/angular/src/sdk.ts index c6cf3b17fcd0..45b2b1fc9759 100755 --- a/packages/angular/src/sdk.ts +++ b/packages/angular/src/sdk.ts @@ -12,6 +12,7 @@ import { import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata, + conversationIdIntegration, debug, dedupeIntegration, functionToStringIntegration, @@ -36,6 +37,7 @@ export function getDefaultIntegrations(_options: BrowserOptions = {}): Integrati // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration(), functionToStringIntegration(), + conversationIdIntegration(), breadcrumbsIntegration(), globalHandlersIntegration(), linkedErrorsIntegration(),