Skip to content

Commit 5e04fe2

Browse files
committed
ramp up deno tests
1 parent 66b48de commit 5e04fe2

11 files changed

Lines changed: 500 additions & 3 deletions

File tree

dev-packages/e2e-tests/test-applications/deno/deno.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"imports": {
33
"@sentry/deno": "npm:@sentry/deno",
44
"@sentry/core": "npm:@sentry/core",
5-
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0"
5+
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
6+
"ai": "npm:ai@^3.0.0",
7+
"ai/test": "npm:ai@^3.0.0/test",
8+
"zod": "npm:zod@^3.22.4"
69
},
710
"nodeModulesDir": "manual"
811
}

dev-packages/e2e-tests/test-applications/deno/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
},
1212
"dependencies": {
1313
"@sentry/deno": "latest || *",
14-
"@opentelemetry/api": "^1.9.0"
14+
"@opentelemetry/api": "^1.9.0",
15+
"ai": "^3.0.0",
16+
"zod": "^3.22.4"
1517
},
1618
"devDependencies": {
1719
"@playwright/test": "~1.56.0",

dev-packages/e2e-tests/test-applications/deno/src/app.ts

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,23 @@ trace.setGlobalTracerProvider(fakeProvider as any);
1313

1414
// Sentry.init() must call trace.disable() to clear the fake provider above
1515
import * as Sentry from '@sentry/deno';
16+
import { generateText } from 'ai';
17+
import { MockLanguageModelV1 } from 'ai/test';
18+
import { z } from 'zod';
1619

1720
Sentry.init({
1821
environment: 'qa',
1922
dsn: Deno.env.get('E2E_TEST_DSN'),
2023
debug: !!Deno.env.get('DEBUG'),
2124
tunnel: 'http://localhost:3031/',
2225
tracesSampleRate: 1,
26+
sendDefaultPii: true,
27+
enableLogs: true,
2328
});
2429

2530
const port = 3030;
2631

27-
Deno.serve({ port }, (req: Request) => {
32+
Deno.serve({ port }, async (req: Request) => {
2833
const url = new URL(req.url);
2934

3035
if (url.pathname === '/test-success') {
@@ -84,6 +89,219 @@ Deno.serve({ port }, (req: Request) => {
8489
});
8590
}
8691

92+
// Test breadcrumbs: add a breadcrumb then capture an error
93+
if (url.pathname === '/test-breadcrumb') {
94+
Sentry.addBreadcrumb({
95+
message: 'test-breadcrumb',
96+
category: 'custom',
97+
level: 'info',
98+
});
99+
const exceptionId = Sentry.captureException(new Error('breadcrumb-test'));
100+
return new Response(JSON.stringify({ exceptionId }), {
101+
headers: { 'Content-Type': 'application/json' },
102+
});
103+
}
104+
105+
// Test context: set user, tag, extra then capture an error
106+
if (url.pathname === '/test-context') {
107+
Sentry.setUser({ id: '123', email: 'test@sentry.io' });
108+
Sentry.setTag('deno-runtime', 'true');
109+
Sentry.setExtra('detail', { key: 'value' });
110+
const exceptionId = Sentry.captureException(new Error('context-test'));
111+
return new Response(JSON.stringify({ exceptionId }), {
112+
headers: { 'Content-Type': 'application/json' },
113+
});
114+
}
115+
116+
// Test scope isolation: tags inside withScope do not leak
117+
if (url.pathname === '/test-scope-isolation') {
118+
let insideId: string | undefined;
119+
let outsideId: string | undefined;
120+
121+
Sentry.withScope(scope => {
122+
scope.setTag('isolated', 'yes');
123+
insideId = Sentry.captureException(new Error('inside-scope'));
124+
});
125+
126+
outsideId = Sentry.captureException(new Error('outside-scope'));
127+
128+
return new Response(JSON.stringify({ insideId, outsideId }), {
129+
headers: { 'Content-Type': 'application/json' },
130+
});
131+
}
132+
133+
// Test outbound fetch instrumentation
134+
if (url.pathname === '/test-outgoing-fetch') {
135+
const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => {
136+
const res = await fetch('http://localhost:3030/test-success');
137+
return res.json();
138+
});
139+
return new Response(JSON.stringify(response), {
140+
headers: { 'Content-Type': 'application/json' },
141+
});
142+
}
143+
144+
// Test AI: Vercel AI SDK generateText with mock model
145+
if (url.pathname === '/test-ai') {
146+
const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => {
147+
// First call - telemetry enabled by default
148+
const result1 = await generateText({
149+
model: new MockLanguageModelV1({
150+
doGenerate: async () => ({
151+
rawCall: { rawPrompt: null, rawSettings: {} },
152+
finishReason: 'stop',
153+
usage: { promptTokens: 10, completionTokens: 20 },
154+
text: 'First span here!',
155+
}),
156+
}),
157+
prompt: 'Where is the first span?',
158+
});
159+
160+
// Second call - explicitly enabled telemetry
161+
const result2 = await generateText({
162+
experimental_telemetry: { isEnabled: true },
163+
model: new MockLanguageModelV1({
164+
doGenerate: async () => ({
165+
rawCall: { rawPrompt: null, rawSettings: {} },
166+
finishReason: 'stop',
167+
usage: { promptTokens: 10, completionTokens: 20 },
168+
text: 'Second span here!',
169+
}),
170+
}),
171+
prompt: 'Where is the second span?',
172+
});
173+
174+
// Third call - with tool calls
175+
const result3 = await generateText({
176+
model: new MockLanguageModelV1({
177+
doGenerate: async () => ({
178+
rawCall: { rawPrompt: null, rawSettings: {} },
179+
finishReason: 'tool-calls',
180+
usage: { promptTokens: 15, completionTokens: 25 },
181+
text: 'Tool call completed!',
182+
toolCalls: [
183+
{
184+
toolCallType: 'function',
185+
toolCallId: 'call-1',
186+
toolName: 'getWeather',
187+
args: '{ "location": "San Francisco" }',
188+
},
189+
],
190+
}),
191+
}),
192+
tools: {
193+
getWeather: {
194+
parameters: z.object({ location: z.string() }),
195+
execute: async (args: { location: string }) => {
196+
return `Weather in ${args.location}: Sunny, 72°F`;
197+
},
198+
},
199+
},
200+
prompt: 'What is the weather in San Francisco?',
201+
});
202+
203+
// Fourth call - explicitly disabled telemetry, should not be captured
204+
const result4 = await generateText({
205+
experimental_telemetry: { isEnabled: false },
206+
model: new MockLanguageModelV1({
207+
doGenerate: async () => ({
208+
rawCall: { rawPrompt: null, rawSettings: {} },
209+
finishReason: 'stop',
210+
usage: { promptTokens: 10, completionTokens: 20 },
211+
text: 'Should not be captured!',
212+
}),
213+
}),
214+
prompt: 'Where is the disabled span?',
215+
});
216+
217+
return {
218+
result1: result1.text,
219+
result2: result2.text,
220+
result3: result3.text,
221+
result4: result4.text,
222+
};
223+
});
224+
225+
return new Response(JSON.stringify(results), {
226+
headers: { 'Content-Type': 'application/json' },
227+
});
228+
}
229+
230+
// Test AI error: tool call that throws
231+
if (url.pathname === '/test-ai-error') {
232+
try {
233+
await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => {
234+
await generateText({
235+
experimental_telemetry: { isEnabled: true },
236+
model: new MockLanguageModelV1({
237+
doGenerate: async () => ({
238+
rawCall: { rawPrompt: null, rawSettings: {} },
239+
finishReason: 'tool-calls',
240+
usage: { promptTokens: 15, completionTokens: 25 },
241+
text: 'Tool call completed!',
242+
toolCalls: [
243+
{
244+
toolCallType: 'function',
245+
toolCallId: 'call-1',
246+
toolName: 'getWeather',
247+
args: '{ "location": "San Francisco" }',
248+
},
249+
],
250+
}),
251+
}),
252+
tools: {
253+
getWeather: {
254+
parameters: z.object({ location: z.string() }),
255+
execute: async (_args: { location: string }) => {
256+
throw new Error('Tool call failed');
257+
},
258+
},
259+
},
260+
prompt: 'What is the weather in San Francisco?',
261+
});
262+
});
263+
} catch (e) {
264+
Sentry.captureException(e);
265+
}
266+
267+
return new Response(JSON.stringify({ status: 'error-handled' }), {
268+
headers: { 'Content-Type': 'application/json' },
269+
});
270+
}
271+
272+
// Test metrics: emit counter, distribution, and gauge
273+
if (url.pathname === '/test-metrics') {
274+
Sentry.metrics.count('test.deno.count', 1, {
275+
attributes: {
276+
endpoint: '/test-metrics',
277+
'random.attribute': 'Apples',
278+
},
279+
});
280+
Sentry.metrics.distribution('test.deno.distribution', 100, {
281+
attributes: {
282+
endpoint: '/test-metrics',
283+
'random.attribute': 'Bananas',
284+
},
285+
});
286+
Sentry.metrics.gauge('test.deno.gauge', 200, {
287+
attributes: {
288+
endpoint: '/test-metrics',
289+
'random.attribute': 'Cherries',
290+
},
291+
});
292+
return new Response(JSON.stringify({ status: 'ok' }), {
293+
headers: { 'Content-Type': 'application/json' },
294+
});
295+
}
296+
297+
// Test logs: emit a debug log via Sentry.logger
298+
if (url.pathname === '/test-log') {
299+
Sentry.logger.debug('Accessed /test-log route');
300+
return new Response(JSON.stringify({ message: 'Log sent' }), {
301+
headers: { 'Content-Type': 'application/json' },
302+
});
303+
}
304+
87305
return new Response('Not found', { status: 404 });
88306
});
89307

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction, waitForError } from '@sentry-internal/test-utils';
3+
4+
test('should link AI errors to the correct trace', async ({ baseURL }) => {
5+
const aiTransactionPromise = waitForTransaction('deno', event => {
6+
return event?.spans?.some(span => span.description === 'ai-error-test') ?? false;
7+
});
8+
9+
const errorEventPromise = waitForError('deno', event => {
10+
return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false;
11+
});
12+
13+
await fetch(`${baseURL}/test-ai-error`);
14+
15+
const aiTransaction = await aiTransactionPromise;
16+
const errorEvent = await errorEventPromise;
17+
18+
expect(aiTransaction).toBeDefined();
19+
20+
const spans = aiTransaction.spans || [];
21+
22+
// The parent span wrapping the AI call should exist
23+
expect(spans).toEqual(
24+
expect.arrayContaining([
25+
expect.objectContaining({
26+
description: 'ai-error-test',
27+
op: 'function',
28+
}),
29+
]),
30+
);
31+
32+
expect(errorEvent).toBeDefined();
33+
34+
// Verify error is linked to the same trace as the transaction
35+
expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id);
36+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => {
5+
const aiTransactionPromise = waitForTransaction('deno', event => {
6+
return event?.spans?.some(span => span.description === 'ai-test') ?? false;
7+
});
8+
9+
await fetch(`${baseURL}/test-ai`);
10+
11+
const aiTransaction = await aiTransactionPromise;
12+
13+
expect(aiTransaction).toBeDefined();
14+
15+
const spans = aiTransaction.spans || [];
16+
17+
// The parent span wrapping all AI calls should exist
18+
expect(spans).toEqual(
19+
expect.arrayContaining([
20+
expect.objectContaining({
21+
description: 'ai-test',
22+
op: 'function',
23+
}),
24+
]),
25+
);
26+
27+
// Vercel AI SDK emits OTel spans for generateText calls.
28+
// Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716),
29+
// only explicitly opted-in calls produce telemetry spans.
30+
// The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans.
31+
const aiSpans = spans.filter(
32+
(span: any) =>
33+
span.op === 'gen_ai.invoke_agent' ||
34+
span.op === 'gen_ai.generate_text' ||
35+
span.op === 'otel.span' ||
36+
span.description?.includes('ai.generateText'),
37+
);
38+
39+
// We expect at least one AI-related span from the explicitly enabled call
40+
expect(aiSpans.length).toBeGreaterThanOrEqual(1);
41+
42+
// Verify the disabled call was not captured
43+
const promptsInSpans = spans
44+
.map((span: any) => span.data?.['vercel.ai.prompt'])
45+
.filter((prompt: unknown): prompt is string => prompt !== undefined);
46+
const hasDisabledPrompt = promptsInSpans.some((prompt: string) => prompt.includes('Where is the disabled span?'));
47+
expect(hasDisabledPrompt).toBe(false);
48+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('Sends error event with breadcrumbs', async ({ baseURL }) => {
5+
const errorEventPromise = waitForError('deno', event => {
6+
return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test';
7+
});
8+
9+
await fetch(`${baseURL}/test-breadcrumb`);
10+
11+
const errorEvent = await errorEventPromise;
12+
13+
expect(errorEvent.exception?.values).toHaveLength(1);
14+
expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test');
15+
16+
expect(errorEvent.breadcrumbs).toEqual(
17+
expect.arrayContaining([
18+
expect.objectContaining({
19+
message: 'test-breadcrumb',
20+
category: 'custom',
21+
level: 'info',
22+
}),
23+
]),
24+
);
25+
});

0 commit comments

Comments
 (0)