Skip to content

Commit 4269b46

Browse files
committed
feat(deno): Add OpenTelemetry support and vercelAI integration
1 parent 5ee2597 commit 4269b46

File tree

7 files changed

+268
-1
lines changed

7 files changed

+268
-1
lines changed

packages/deno/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"/build"
2525
],
2626
"dependencies": {
27+
"@opentelemetry/api": "^1.9.0",
2728
"@sentry/core": "10.5.0"
2829
},
2930
"scripts": {

packages/deno/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,4 @@ export { normalizePathsIntegration } from './integrations/normalizepaths';
101101
export { contextLinesIntegration } from './integrations/contextlines';
102102
export { denoCronIntegration } from './integrations/deno-cron';
103103
export { breadcrumbsIntegration } from './integrations/breadcrumbs';
104+
export { vercelAIIntegration } from './integrations/vercelai';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* This is a copy of the Vercel AI integration from the cloudflare SDK.
3+
*
4+
* The only difference is that it does not use `@opentelemetry/instrumentation`
5+
* because Deno Workers do not support it in the same way.
6+
*
7+
* Therefore, we cannot automatically patch setting `experimental_telemetry: { isEnabled: true }`
8+
* and users have to manually set this to get spans.
9+
*/
10+
11+
import type { IntegrationFn } from '@sentry/core';
12+
import { addVercelAiProcessors, defineIntegration } from '@sentry/core';
13+
14+
const INTEGRATION_NAME = 'VercelAI';
15+
16+
const _vercelAIIntegration = (() => {
17+
return {
18+
name: INTEGRATION_NAME,
19+
setup(client) {
20+
addVercelAiProcessors(client);
21+
},
22+
};
23+
}) satisfies IntegrationFn;
24+
25+
/**
26+
* Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library.
27+
* This integration is not enabled by default, you need to manually add it.
28+
*
29+
* For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry).
30+
*
31+
* You need to enable collecting spans for a specific call by setting
32+
* `experimental_telemetry.isEnabled` to `true` in the first argument of the function call.
33+
*
34+
* ```javascript
35+
* const result = await generateText({
36+
* model: openai('gpt-4-turbo'),
37+
* experimental_telemetry: { isEnabled: true },
38+
* });
39+
* ```
40+
*
41+
* If you want to collect inputs and outputs for a specific call, you must specifically opt-in to each
42+
* function call by setting `experimental_telemetry.recordInputs` and `experimental_telemetry.recordOutputs`
43+
* to `true`.
44+
*
45+
* ```javascript
46+
* const result = await generateText({
47+
* model: openai('gpt-4-turbo'),
48+
* experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true },
49+
* });
50+
*/
51+
export const vercelAIIntegration = defineIntegration(_vercelAIIntegration);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api';
2+
import { trace } from '@opentelemetry/api';
3+
import { startInactiveSpan, startSpanManual } from '@sentry/core';
4+
5+
/**
6+
* Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans.
7+
* This is not perfect but handles easy/common use cases.
8+
*/
9+
export function setupOpenTelemetryTracer(): void {
10+
trace.setGlobalTracerProvider(new SentryDenoTraceProvider());
11+
}
12+
13+
class SentryDenoTraceProvider implements TracerProvider {
14+
private readonly _tracers: Map<string, Tracer> = new Map();
15+
16+
public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer {
17+
const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`;
18+
if (!this._tracers.has(key)) {
19+
this._tracers.set(key, new SentryDenoTracer());
20+
}
21+
22+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
23+
return this._tracers.get(key)!;
24+
}
25+
}
26+
27+
class SentryDenoTracer implements Tracer {
28+
public startSpan(name: string, options?: SpanOptions): Span {
29+
return startInactiveSpan({
30+
name,
31+
...options,
32+
attributes: {
33+
...options?.attributes,
34+
'sentry.deno_tracer': true,
35+
},
36+
});
37+
}
38+
39+
/**
40+
* NOTE: This does not handle `context` being passed in. It will always put spans on the current scope.
41+
*/
42+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
43+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
44+
public startActiveSpan<F extends (span: Span) => unknown>(
45+
name: string,
46+
options: SpanOptions,
47+
context: Context,
48+
fn: F,
49+
): ReturnType<F>;
50+
public startActiveSpan<F extends (span: Span) => unknown>(
51+
name: string,
52+
options: unknown,
53+
context?: unknown,
54+
fn?: F,
55+
): ReturnType<F> {
56+
const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions;
57+
58+
const spanOpts = {
59+
name,
60+
...opts,
61+
attributes: {
62+
...opts.attributes,
63+
'sentry.deno_tracer': true,
64+
},
65+
};
66+
67+
const callback = (
68+
typeof options === 'function'
69+
? options
70+
: typeof context === 'function'
71+
? context
72+
: typeof fn === 'function'
73+
? fn
74+
: () => {}
75+
) as F;
76+
77+
// In OTEL the semantic matches `startSpanManual` because spans are not auto-ended
78+
return startSpanManual(spanOpts, callback) as ReturnType<F>;
79+
}
80+
}

packages/deno/src/sdk.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { denoContextIntegration } from './integrations/context';
1616
import { contextLinesIntegration } from './integrations/contextlines';
1717
import { globalHandlersIntegration } from './integrations/globalhandlers';
1818
import { normalizePathsIntegration } from './integrations/normalizepaths';
19+
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
1920
import { makeFetchTransport } from './transports';
2021
import type { DenoOptions } from './types';
2122

@@ -97,5 +98,18 @@ export function init(options: DenoOptions = {}): Client {
9798
transport: options.transport || makeFetchTransport,
9899
};
99100

100-
return initAndBind(DenoClient, clientOptions);
101+
const client = initAndBind(DenoClient, clientOptions);
102+
103+
/**
104+
* The Deno SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
105+
* via a custom trace provider.
106+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
107+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
108+
* This should be good enough for many, but not all integrations.
109+
*/
110+
if (!options.skipOpenTelemetrySetup) {
111+
setupOpenTelemetryTracer();
112+
}
113+
114+
return client;
101115
}

packages/deno/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ export interface BaseDenoOptions {
2323
/** Sets an optional server name (device name) */
2424
serverName?: string;
2525

26+
/**
27+
* The Deno SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
28+
* via a custom trace provider.
29+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
30+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
31+
* This should be good enough for many, but not all integrations.
32+
*
33+
* If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`.
34+
*
35+
* @default false
36+
*/
37+
skipOpenTelemetrySetup?: boolean;
38+
2639
/** Callback that is executed when a fatal global error occurs. */
2740
onFatalError?(this: void, error: Error): void;
2841
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { assertEquals } from 'https://deno.land/[email protected]/assert/mod.ts';
2+
import { context, propagation,trace } from 'npm:@opentelemetry/api@1';
3+
import type {
4+
DenoClient} from '../build/esm/index.js';
5+
import {
6+
flush,
7+
getCurrentScope,
8+
getGlobalScope,
9+
getIsolationScope,
10+
init,
11+
vercelAIIntegration,
12+
} from '../build/esm/index.js';
13+
14+
function delay(time: number): Promise<void> {
15+
return new Promise(resolve => {
16+
setTimeout(resolve, time);
17+
});
18+
}
19+
20+
function resetGlobals(): void {
21+
getCurrentScope().clear();
22+
getCurrentScope().setClient(undefined);
23+
getIsolationScope().clear();
24+
getGlobalScope().clear();
25+
}
26+
27+
function cleanupOtel(): void {
28+
// Disable all globally registered APIs
29+
trace.disable();
30+
context.disable();
31+
propagation.disable();
32+
}
33+
34+
function resetSdk(): void {
35+
resetGlobals();
36+
cleanupOtel();
37+
}
38+
39+
Deno.test('opentelemetry: should capture spans emitted via @opentelemetry/api', async _t => {
40+
resetSdk();
41+
const events: any[] = [];
42+
43+
init({
44+
dsn: 'https://username@domain/123',
45+
debug: true,
46+
tracesSampleRate: 1,
47+
skipOpenTelemetrySetup: false,
48+
beforeSendTransaction(event) {
49+
events.push(event);
50+
return null;
51+
},
52+
});
53+
54+
const tracer = trace.getTracer('test-tracer');
55+
const span = tracer.startSpan('test span');
56+
span.setAttribute('test.attribute', 'test value');
57+
span.end();
58+
59+
await delay(200);
60+
await flush(1000);
61+
62+
assertEquals(events.length, 1);
63+
const transactionEvent = events[0];
64+
65+
assertEquals(transactionEvent?.transaction, 'test span');
66+
assertEquals(transactionEvent?.contexts?.trace?.data?.['sentry.deno_tracer'], true);
67+
assertEquals(transactionEvent?.contexts?.trace?.data?.['test.attribute'], 'test value');
68+
});
69+
70+
Deno.test('opentelemetry: should not capture spans when skipOpenTelemetrySetup is true', async () => {
71+
resetSdk();
72+
const events: any[] = [];
73+
74+
init({
75+
dsn: 'https://username@domain/123',
76+
debug: true,
77+
tracesSampleRate: 1,
78+
skipOpenTelemetrySetup: true,
79+
beforeSendTransaction(event) {
80+
events.push(event);
81+
return null;
82+
},
83+
});
84+
85+
const tracer = trace.getTracer('test-tracer');
86+
const span = tracer.startSpan('test span');
87+
span.end();
88+
89+
await delay(200);
90+
await flush(1000);
91+
92+
assertEquals(events.length, 0);
93+
});
94+
95+
Deno.test('opentelemetry: vercelAI integration can be added', () => {
96+
resetSdk();
97+
const client = init({
98+
dsn: 'https://username@domain/123',
99+
debug: true,
100+
tracesSampleRate: 1,
101+
integrations: [vercelAIIntegration()],
102+
}) as DenoClient;
103+
104+
// Just verify the integration can be added without errors
105+
const integration = client.getIntegrationByName('VercelAI');
106+
assertEquals(integration?.name, 'VercelAI');
107+
});

0 commit comments

Comments
 (0)