Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions packages/deno/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"/build"
],
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@sentry/core": "10.5.0"
},
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
51 changes: 51 additions & 0 deletions packages/deno/src/integrations/vercelai.ts
Original file line number Diff line number Diff line change
@@ -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);
80 changes: 80 additions & 0 deletions packages/deno/src/opentelemetry/tracer.ts
Original file line number Diff line number Diff line change
@@ -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<string, Tracer> = 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<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: SpanOptions,
context: Context,
fn: F,
): ReturnType<F>;
public startActiveSpan<F extends (span: Span) => unknown>(
name: string,
options: unknown,
context?: unknown,
fn?: F,
): ReturnType<F> {
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<F>;
}
}
11 changes: 10 additions & 1 deletion packages/deno/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions packages/deno/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
145 changes: 145 additions & 0 deletions packages/deno/test/opentelemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { assertEquals } from 'https://deno.land/[email protected]/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');
});