Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,7 +146,8 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './tracing/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/tracing/vercel-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import type { Span } from '../../types-hoist/span';
import type { ToolCallSpanContext } from './types';

// Global Map to track tool call IDs to their corresponding spans
// Global map to track tool call IDs to their corresponding span contexts.
// This allows us to capture tool errors and link them to the correct span
export const toolCallSpanMap = new Map<string, Span>();
// without keeping full Span objects (and their potentially large attributes) alive.
export const toolCallSpanContextMap = new Map<string, ToolCallSpanContext>();

// Operation sets for efficient mapping to OpenTelemetry semantic convention values
export const INVOKE_AGENT_OPS = new Set([
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/tracing/vercel-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { Client } from '../../client';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import type { Event } from '../../types-hoist/event';
Expand All@@ -19,7 +20,13 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants';
import {
EMBEDDINGS_OPS,
GENERATE_CONTENT_OPS,
INVOKE_AGENT_OPS,
RERANK_OPS,
toolCallSpanContextMap,
} from './constants';
import type { TokenSummary } from './types';
import {
accumulateTokensForParent,
Expand DownExpand Up@@ -232,12 +239,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE);
renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE);

// Store the span in our global map using the tool call ID
// Store the span context in our global map using the tool call ID.
// This allows us to capture tool errors and link them to the correct span
// without retaining the full Span object in memory.
const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE];

if (typeof toolCallId === 'string') {
toolCallSpanMap.set(toolCallId, span);
toolCallSpanContextMap.set(toolCallId, span.spanContext());
}

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tracing/vercel-ai/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,3 +2,8 @@ export interface TokenSummary {
inputTokens: number;
outputTokens: number;
}

export interface ToolCallSpanContext {
traceId: string;
spanId: string;
}
14 changes: 7 additions & 7 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
import { toolCallSpanMap } from './constants';
import type { TokenSummary } from './types';
import { toolCallSpanContextMap } from './constants';
import type { TokenSummary, ToolCallSpanContext } from './types';
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';

/**
Expand DownExpand Up@@ -75,17 +75,17 @@ export function applyAccumulatedTokens(
}

/**
* Get the span associated with a tool call ID
* Get the span context associated with a tool call ID.
*/
export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined {
return toolCallSpanMap.get(toolCallId);
export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined {
return toolCallSpanContextMap.get(toolCallId);
}

/**
* Clean up the span mapping for a tool call ID
*/
export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void {
toolCallSpanMap.delete(toolCallId);
export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void {
toolCallSpanContextMap.delete(toolCallId);
}

/**
Expand Down
110 changes: 63 additions & 47 deletions packages/node/src/integrations/tracing/vercelai/instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation';
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
import type { Span } from '@sentry/core';
import {
_INTERNAL_cleanupToolCallSpan,
_INTERNAL_getSpanForToolCallId,
_INTERNAL_cleanupToolCallSpanContext,
_INTERNAL_getSpanContextForToolCallId,
addNonEnumerableProperty,
captureException,
getActiveSpan,
Expand DownExpand Up@@ -71,10 +70,12 @@ function isToolError(obj: unknown): obj is ToolError {
}

/**
* Check for tool errors in the result and capture them
* Tool errors are not rejected in Vercel V5, it is added as metadata to the result content
* Process tool call results: capture tool errors and clean up span context mappings.
*
* Error checking runs first (needs span context for linking), then cleanup removes all entries.
* Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content.
*/
function checkResultForToolErrors(result: unknown): void {
export function processToolCallResults(result: unknown): void {
if (typeof result !== 'object' || result === null || !('content' in result)) {
return;
}
Expand All@@ -84,53 +85,68 @@ function checkResultForToolErrors(result: unknown): void {
return;
}

for (const item of resultObj.content) {
if (isToolError(item)) {
// Try to get the span associated with this tool call ID
const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span;
captureToolErrors(resultObj.content);
cleanupToolCallSpanContexts(resultObj.content);
Comment thread
nicohrubec marked this conversation as resolved.
}

if (associatedSpan) {
// We have the span, so link the error using span and trace IDs from the span
const spanContext = associatedSpan.spanContext();
function captureToolErrors(content: Array<object>): void {
for (const item of content) {
if (!isToolError(item)) {
continue;
}

withScope(scope => {
// Set the span and trace context for proper linking
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
// Try to get the span context associated with this tool call ID
const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId);

scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
if (spanContext) {
// We have the span context, so link the error using span and trace IDs
withScope(scope => {
scope.setContext('trace', {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});

scope.setLevel('error');
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});

// Clean up the span mapping since we've processed this tool error
// We won't get multiple { type: 'tool-error' } parts for the same toolCallId.
_INTERNAL_cleanupToolCallSpan(item.toolCallId);
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
});
} else {
// Fallback: capture without span linking
withScope(scope => {
scope.setTag('vercel.ai.tool.name', item.toolName);
scope.setTag('vercel.ai.tool.callId', item.toolCallId);
scope.setLevel('error');

captureException(item.error, {
mechanism: {
type: 'auto.vercelai.otel',
handled: false,
},
});
}
});
}
}
}

/**
* Remove span context entries for all completed tool calls in the content array.
*/
export function cleanupToolCallSpanContexts(content: Array<object>): void {
for (const item of content) {
if (
typeof item === 'object' &&
item !== null &&
'toolCallId' in item &&
typeof (item as Record<string, unknown>).toolCallId === 'string'
) {
_INTERNAL_cleanupToolCallSpanContext((item as Record<string, unknown>).toolCallId as string);
}
}
}
Expand DownExpand Up@@ -252,7 +268,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase {
},
() => {},
result => {
checkResultForToolErrors(result);
processToolCallResults(result);
},
);
},
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation';
import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core';
import { beforeEach, describe, expect, test } from 'vitest';
import {
cleanupToolCallSpanContexts,
determineRecordingSettings,
} from '../../../../src/integrations/tracing/vercelai/instrumentation';

describe('determineRecordingSettings', () => {
test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => {
Expand DownExpand Up@@ -212,3 +216,50 @@ describe('determineRecordingSettings', () => {
});
});
});

describe('cleanupToolCallSpanContexts', () => {
beforeEach(() => {
_INTERNAL_toolCallSpanContextMap.clear();
});

test('cleans up span context for tool-result items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' });
});

test('cleans up span context for tool-error items', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([
{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
});

test('cleans up mixed tool-result and tool-error in same content array', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });
_INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' });

cleanupToolCallSpanContexts([
{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' },
{ type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') },
]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined();
expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined();
});

test('ignores items without toolCallId', () => {
_INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' });

cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]);

expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' });
});
});
Loading