diff --git a/packages/deno/package.json b/packages/deno/package.json index b17f360b8a03..27d4b66e65c0 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.9.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..801badefa19f --- /dev/null +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -0,0 +1,110 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +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. + * 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 { + // 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, + }, + }); + } + + /** + * 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; + + // 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, + }, + }; + + 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; + } + + 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'; + } + } +} diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 955417842f94..e670a86f50d6 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,13 @@ export function init(options: DenoOptions = {}): Client { transport: options.transport || makeFetchTransport, }; - return initAndBind(DenoClient, clientOptions); + const client = initAndBind(DenoClient, clientOptions); + + // 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(); + } + + 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..57fc020a9219 --- /dev/null +++ b/packages/deno/test/opentelemetry.test.ts @@ -0,0 +1,145 @@ +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 { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js'; + +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('should not capture spans emitted via @opentelemetry/api when skipOpenTelemetrySetup is true', async () => { + resetSdk(); + const transactionEvents: any[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + 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(); + }); + + await client.flush(); + + assertEquals(transactionEvents.length, 0); +}); + +Deno.test('should capture spans emitted via @opentelemetry/api', async () => { + resetSdk(); + const transactionEvents: any[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + 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(); + }); + + 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 spans should interop with Sentry spans', async () => { + resetSdk(); + const transactionEvents: any[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + 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'); +});