From 4269b46c3d233cc28ff73d73735b3c7c41fe6f93 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 22 Aug 2025 16:02:21 -0400 Subject: [PATCH 1/4] feat(deno): Add OpenTelemetry support and vercelAI integration --- packages/deno/package.json | 1 + packages/deno/src/index.ts | 1 + packages/deno/src/integrations/vercelai.ts | 51 ++++++++++ packages/deno/src/opentelemetry/tracer.ts | 80 +++++++++++++++ packages/deno/src/sdk.ts | 16 ++- packages/deno/src/types.ts | 13 +++ packages/deno/test/opentelemetry.test.ts | 107 +++++++++++++++++++++ 7 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 packages/deno/src/integrations/vercelai.ts create mode 100644 packages/deno/src/opentelemetry/tracer.ts create mode 100644 packages/deno/test/opentelemetry.test.ts diff --git a/packages/deno/package.json b/packages/deno/package.json index b641fdabdd34..c432909eca16 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -24,6 +24,7 @@ "/build" ], "dependencies": { + "@opentelemetry/api": "^1.9.0", "@sentry/core": "10.5.0" }, "scripts": { diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 12bcedc35270..3b22fc8b1135 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -101,3 +101,4 @@ export { normalizePathsIntegration } from './integrations/normalizepaths'; export { contextLinesIntegration } from './integrations/contextlines'; export { denoCronIntegration } from './integrations/deno-cron'; export { breadcrumbsIntegration } from './integrations/breadcrumbs'; +export { vercelAIIntegration } from './integrations/vercelai'; diff --git a/packages/deno/src/integrations/vercelai.ts b/packages/deno/src/integrations/vercelai.ts new file mode 100644 index 000000000000..442818060124 --- /dev/null +++ b/packages/deno/src/integrations/vercelai.ts @@ -0,0 +1,51 @@ +/** + * This is a copy of the Vercel AI integration from the cloudflare SDK. + * + * The only difference is that it does not use `@opentelemetry/instrumentation` + * because Deno Workers do not support it in the same way. + * + * Therefore, we cannot automatically patch setting `experimental_telemetry: { isEnabled: true }` + * and users have to manually set this to get spans. + */ + +import type { IntegrationFn } from '@sentry/core'; +import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; + +const INTEGRATION_NAME = 'VercelAI'; + +const _vercelAIIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + addVercelAiProcessors(client); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library. + * This integration is not enabled by default, you need to manually add it. + * + * For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry). + * + * You need to enable collecting spans for a specific call by setting + * `experimental_telemetry.isEnabled` to `true` in the first argument of the function call. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true }, + * }); + * ``` + * + * If you want to collect inputs and outputs for a specific call, you must specifically opt-in to each + * function call by setting `experimental_telemetry.recordInputs` and `experimental_telemetry.recordOutputs` + * to `true`. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true }, + * }); + */ +export const vercelAIIntegration = defineIntegration(_vercelAIIntegration); diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts new file mode 100644 index 000000000000..cd65db4ba7c5 --- /dev/null +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -0,0 +1,80 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import { startInactiveSpan, startSpanManual } from '@sentry/core'; + +/** + * Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans. + * This is not perfect but handles easy/common use cases. + */ +export function setupOpenTelemetryTracer(): void { + trace.setGlobalTracerProvider(new SentryDenoTraceProvider()); +} + +class SentryDenoTraceProvider implements TracerProvider { + private readonly _tracers: Map = new Map(); + + public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer { + const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`; + if (!this._tracers.has(key)) { + this._tracers.set(key, new SentryDenoTracer()); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._tracers.get(key)!; + } +} + +class SentryDenoTracer implements Tracer { + public startSpan(name: string, options?: SpanOptions): Span { + return startInactiveSpan({ + name, + ...options, + attributes: { + ...options?.attributes, + 'sentry.deno_tracer': true, + }, + }); + } + + /** + * NOTE: This does not handle `context` being passed in. It will always put spans on the current scope. + */ + public startActiveSpan unknown>(name: string, fn: F): ReturnType; + public startActiveSpan unknown>(name: string, options: SpanOptions, fn: F): ReturnType; + public startActiveSpan unknown>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType; + public startActiveSpan unknown>( + name: string, + options: unknown, + context?: unknown, + fn?: F, + ): ReturnType { + const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions; + + const spanOpts = { + name, + ...opts, + attributes: { + ...opts.attributes, + 'sentry.deno_tracer': true, + }, + }; + + const callback = ( + typeof options === 'function' + ? options + : typeof context === 'function' + ? context + : typeof fn === 'function' + ? fn + : () => {} + ) as F; + + // In OTEL the semantic matches `startSpanManual` because spans are not auto-ended + return startSpanManual(spanOpts, callback) as ReturnType; + } +} diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 955417842f94..5bd7790d4978 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -16,6 +16,7 @@ import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; import { globalHandlersIntegration } from './integrations/globalhandlers'; import { normalizePathsIntegration } from './integrations/normalizepaths'; +import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeFetchTransport } from './transports'; import type { DenoOptions } from './types'; @@ -97,5 +98,18 @@ export function init(options: DenoOptions = {}): Client { transport: options.transport || makeFetchTransport, }; - return initAndBind(DenoClient, clientOptions); + const client = initAndBind(DenoClient, clientOptions); + + /** + * The Deno SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + */ + if (!options.skipOpenTelemetrySetup) { + setupOpenTelemetryTracer(); + } + + return client; } diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 422e561bb644..1659e7a635e1 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -23,6 +23,19 @@ export interface BaseDenoOptions { /** Sets an optional server name (device name) */ serverName?: string; + /** + * The Deno SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + * + * If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`. + * + * @default false + */ + skipOpenTelemetrySetup?: boolean; + /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts new file mode 100644 index 000000000000..50e974d7896a --- /dev/null +++ b/packages/deno/test/opentelemetry.test.ts @@ -0,0 +1,107 @@ +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { context, propagation,trace } from 'npm:@opentelemetry/api@1'; +import type { + DenoClient} from '../build/esm/index.js'; +import { + flush, + getCurrentScope, + getGlobalScope, + getIsolationScope, + init, + vercelAIIntegration, +} from '../build/esm/index.js'; + +function delay(time: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, time); + }); +} + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +function cleanupOtel(): void { + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} + +function resetSdk(): void { + resetGlobals(); + cleanupOtel(); +} + +Deno.test('opentelemetry: should capture spans emitted via @opentelemetry/api', async _t => { + resetSdk(); + const events: any[] = []; + + init({ + dsn: 'https://username@domain/123', + debug: true, + tracesSampleRate: 1, + skipOpenTelemetrySetup: false, + beforeSendTransaction(event) { + events.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('test span'); + span.setAttribute('test.attribute', 'test value'); + span.end(); + + await delay(200); + await flush(1000); + + assertEquals(events.length, 1); + const transactionEvent = events[0]; + + assertEquals(transactionEvent?.transaction, 'test span'); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.deno_tracer'], true); + assertEquals(transactionEvent?.contexts?.trace?.data?.['test.attribute'], 'test value'); +}); + +Deno.test('opentelemetry: should not capture spans when skipOpenTelemetrySetup is true', async () => { + resetSdk(); + const events: any[] = []; + + init({ + dsn: 'https://username@domain/123', + debug: true, + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + beforeSendTransaction(event) { + events.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('test span'); + span.end(); + + await delay(200); + await flush(1000); + + assertEquals(events.length, 0); +}); + +Deno.test('opentelemetry: vercelAI integration can be added', () => { + resetSdk(); + const client = init({ + dsn: 'https://username@domain/123', + debug: true, + tracesSampleRate: 1, + integrations: [vercelAIIntegration()], + }) as DenoClient; + + // Just verify the integration can be added without errors + const integration = client.getIntegrationByName('VercelAI'); + assertEquals(integration?.name, 'VercelAI'); +}); From 1427b8be16832d4ce44866d91e2f18e8f2a7b5b4 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 22 Aug 2025 16:58:17 -0400 Subject: [PATCH 2/4] lint --- packages/deno/test/opentelemetry.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts index 50e974d7896a..0155b73bcc04 100644 --- a/packages/deno/test/opentelemetry.test.ts +++ b/packages/deno/test/opentelemetry.test.ts @@ -1,7 +1,6 @@ import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; -import { context, propagation,trace } from 'npm:@opentelemetry/api@1'; -import type { - DenoClient} from '../build/esm/index.js'; +import { context, propagation, trace } from 'npm:@opentelemetry/api@1'; +import type { DenoClient } from '../build/esm/index.js'; import { flush, getCurrentScope, From 814a1e32b51c57678ced90de2a0d1f4fe8ad7786 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 22 Aug 2025 17:55:09 -0400 Subject: [PATCH 3/4] better tests --- packages/deno/src/sdk.ts | 9 +- packages/deno/test/opentelemetry.test.ts | 141 +++++++++++++++-------- 2 files changed, 92 insertions(+), 58 deletions(-) diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 5bd7790d4978..e670a86f50d6 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -100,13 +100,8 @@ export function init(options: DenoOptions = {}): Client { const client = initAndBind(DenoClient, clientOptions); - /** - * The Deno SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility - * via a custom trace provider. - * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. - * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. - * This should be good enough for many, but not all integrations. - */ + // Set up OpenTelemetry compatibility to capture spans from libraries using @opentelemetry/api + // Note: This is separate from Deno's native OTEL support and doesn't capture auto-instrumented spans if (!options.skipOpenTelemetrySetup) { setupOpenTelemetryTracer(); } diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts index 0155b73bcc04..57fc020a9219 100644 --- a/packages/deno/test/opentelemetry.test.ts +++ b/packages/deno/test/opentelemetry.test.ts @@ -1,20 +1,7 @@ import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; import { context, propagation, trace } from 'npm:@opentelemetry/api@1'; import type { DenoClient } from '../build/esm/index.js'; -import { - flush, - getCurrentScope, - getGlobalScope, - getIsolationScope, - init, - vercelAIIntegration, -} from '../build/esm/index.js'; - -function delay(time: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, time); - }); -} +import { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js'; function resetGlobals(): void { getCurrentScope().clear(); @@ -35,72 +22,124 @@ function resetSdk(): void { cleanupOtel(); } -Deno.test('opentelemetry: should capture spans emitted via @opentelemetry/api', async _t => { +Deno.test('should not capture spans emitted via @opentelemetry/api when skipOpenTelemetrySetup is true', async () => { resetSdk(); - const events: any[] = []; + const transactionEvents: any[] = []; - init({ + const client = init({ dsn: 'https://username@domain/123', - debug: true, tracesSampleRate: 1, - skipOpenTelemetrySetup: false, - beforeSendTransaction(event) { - events.push(event); + skipOpenTelemetrySetup: true, + beforeSendTransaction: event => { + transactionEvents.push(event); return null; }, - }); + }) as DenoClient; - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('test span'); - span.setAttribute('test.attribute', 'test value'); + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); span.end(); - await delay(200); - await flush(1000); + await client.flush(); + + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); - assertEquals(events.length, 1); - const transactionEvent = events[0]; + await client.flush(); - assertEquals(transactionEvent?.transaction, 'test span'); - assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.deno_tracer'], true); - assertEquals(transactionEvent?.contexts?.trace?.data?.['test.attribute'], 'test value'); + assertEquals(transactionEvents.length, 0); }); -Deno.test('opentelemetry: should not capture spans when skipOpenTelemetrySetup is true', async () => { +Deno.test('should capture spans emitted via @opentelemetry/api', async () => { resetSdk(); - const events: any[] = []; + const transactionEvents: any[] = []; - init({ + const client = init({ dsn: 'https://username@domain/123', - debug: true, tracesSampleRate: 1, - skipOpenTelemetrySetup: true, - beforeSendTransaction(event) { - events.push(event); + beforeSendTransaction: event => { + transactionEvents.push(event); return null; }, - }); + }) as DenoClient; - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('test span'); + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); span.end(); - await delay(200); - await flush(1000); + await client.flush(); - assertEquals(events.length, 0); + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); + + await client.flush(); + + assertEquals(transactionEvents.length, 2); + const [transactionEvent, transactionEvent2] = transactionEvents; + + assertEquals(transactionEvent?.spans?.length, 0); + assertEquals(transactionEvent?.transaction, 'test'); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.deno_tracer'], true); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.origin'], 'manual'); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.sample_rate'], 1); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.source'], 'custom'); + + assertEquals(transactionEvent2?.spans?.length, 1); + assertEquals(transactionEvent2?.transaction, 'test 2'); + assertEquals(transactionEvent2?.contexts?.trace?.data?.['sentry.deno_tracer'], true); + assertEquals(transactionEvent2?.contexts?.trace?.data?.['sentry.origin'], 'manual'); + assertEquals(transactionEvent2?.contexts?.trace?.data?.['sentry.sample_rate'], 1); + assertEquals(transactionEvent2?.contexts?.trace?.data?.['sentry.source'], 'custom'); + assertEquals(transactionEvent2?.contexts?.trace?.data?.['test.attribute'], 'test'); + + const childSpan = transactionEvent2?.spans?.[0]; + assertEquals(childSpan?.description, 'test 3'); + assertEquals(childSpan?.data?.['sentry.deno_tracer'], true); + assertEquals(childSpan?.data?.['sentry.origin'], 'manual'); + assertEquals(childSpan?.data?.['test.attribute'], 'test2'); }); -Deno.test('opentelemetry: vercelAI integration can be added', () => { +Deno.test('opentelemetry spans should interop with Sentry spans', async () => { resetSdk(); + const transactionEvents: any[] = []; + const client = init({ dsn: 'https://username@domain/123', - debug: true, tracesSampleRate: 1, - integrations: [vercelAIIntegration()], + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, }) as DenoClient; - // Just verify the integration can be added without errors - const integration = client.getIntegrationByName('VercelAI'); - assertEquals(integration?.name, 'VercelAI'); + const tracer = trace.getTracer('test'); + + startSpan({ name: 'sentry span' }, () => { + const span = tracer.startSpan('otel span'); + span.end(); + }); + + await client.flush(); + + assertEquals(transactionEvents.length, 1); + const [transactionEvent] = transactionEvents; + + assertEquals(transactionEvent?.spans?.length, 1); + assertEquals(transactionEvent?.transaction, 'sentry span'); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.origin'], 'manual'); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.sample_rate'], 1); + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.source'], 'custom'); + // Note: Sentry-created spans don't have the deno_tracer marker + assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.deno_tracer'], undefined); + + const otelSpan = transactionEvent?.spans?.[0]; + assertEquals(otelSpan?.description, 'otel span'); + assertEquals(otelSpan?.data?.['sentry.deno_tracer'], true); + assertEquals(otelSpan?.data?.['sentry.origin'], 'manual'); }); From 170d30089f2179e1b603c111ed8a371b678f8522 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 25 Aug 2025 15:55:44 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fixes=20for=20=F0=9F=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/deno/src/opentelemetry/tracer.ts | 34 +++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts index cd65db4ba7c5..801badefa19f 100644 --- a/packages/deno/src/opentelemetry/tracer.ts +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -1,6 +1,11 @@ import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import { startInactiveSpan, startSpanManual } from '@sentry/core'; +import { SpanKind, trace } from '@opentelemetry/api'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + startSpanManual, +} from '@sentry/core'; /** * Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans. @@ -26,11 +31,16 @@ class SentryDenoTraceProvider implements TracerProvider { class SentryDenoTracer implements Tracer { public startSpan(name: string, options?: SpanOptions): Span { + // Map OpenTelemetry SpanKind to Sentry operation + const op = this._mapSpanKindToOp(options?.kind); + return startInactiveSpan({ name, ...options, attributes: { ...options?.attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, 'sentry.deno_tracer': true, }, }); @@ -55,11 +65,16 @@ class SentryDenoTracer implements Tracer { ): ReturnType { const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions; + // Map OpenTelemetry SpanKind to Sentry operation + const op = this._mapSpanKindToOp(opts.kind); + const spanOpts = { name, ...opts, attributes: { ...opts.attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, 'sentry.deno_tracer': true, }, }; @@ -77,4 +92,19 @@ class SentryDenoTracer implements Tracer { // In OTEL the semantic matches `startSpanManual` because spans are not auto-ended return startSpanManual(spanOpts, callback) as ReturnType; } + + private _mapSpanKindToOp(kind?: SpanKind): string { + switch (kind) { + case SpanKind.CLIENT: + return 'http.client'; + case SpanKind.SERVER: + return 'http.server'; + case SpanKind.PRODUCER: + return 'message.produce'; + case SpanKind.CONSUMER: + return 'message.consume'; + default: + return 'otel.span'; + } + } }