Skip to content

Commit a2522cf

Browse files
committed
Merge remote-tracking branch 'origin/develop' into sig/tanstack-start-delete-source-maps
# Conflicts: # packages/tanstackstart-react/src/vite/sourceMaps.ts
2 parents 2f5889c + f9b8bad commit a2522cf

26 files changed

Lines changed: 985 additions & 56 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,10 @@ jobs:
11971197
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
11981198
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
11991199
E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }}
1200+
# Only this app talks to OpenAI directly (OpenRouter has no Decisions route), so only it gets the key.
1201+
E2E_OPENAI_DECISIONS_API_KEY:
1202+
${{ matrix.test-application == 'node-openai-decisions-send-to-sentry' && secrets.E2E_OPENAI_DECISIONS_API_KEY ||
1203+
'' }}
12001204
# Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry
12011205
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
12021206
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

‎dev-packages/e2e-tests/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ E2E_TEST_SENTRY_PROJECT=
1616
# Only needed to run that test app locally.
1717
E2E_OPENROUTER_API_KEY=
1818

19+
# An OpenAI API key for the Decisions API (OpenRouter has no Decisions route).
20+
# Only needed to run node-openai-decisions-send-to-sentry locally.
21+
E2E_OPENAI_DECISIONS_API_KEY=
22+
1923
# Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry).
2024
# The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a
2125
# warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
pnpm-lock.yaml
3+
dist
4+
test-results
5+
playwright-report
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "node-openai-decisions-send-to-sentry",
3+
"description": "The OpenAI integration for the Decisions API as a user runs it: a plain Sentry.init on an express app, real decisions.create requests against OpenAI, and the gen_ai.decide spans read back from a real Sentry project",
4+
"version": "1.0.0",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"build": "tsc",
9+
"start": "node --import ./dist/instrument.js dist/app.js",
10+
"test": "playwright test",
11+
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:build-latest": "pnpm install && pnpm add openai@latest && pnpm build",
14+
"test:assert": "pnpm test"
15+
},
16+
"dependencies": {
17+
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
18+
"express": "^4.21.2",
19+
"openai": "7.32.0"
20+
},
21+
"devDependencies": {
22+
"@playwright/test": "~1.63.0",
23+
"@sentry-internal/test-utils": "link:../../../test-utils",
24+
"@sentry/conventions": "0.27.0",
25+
"@types/express": "^4.17.21",
26+
"@types/node": "^24.0.0",
27+
"sentry": "~0.44.1",
28+
"typescript": "~5.9.0"
29+
},
30+
"sentryTest": {
31+
"optional": true,
32+
"optionalVariants": [
33+
{
34+
"build-command": "pnpm test:build-latest",
35+
"label": "node-openai-decisions-send-to-sentry (latest)"
36+
}
37+
]
38+
},
39+
"volta": {
40+
"node": "24.15.0",
41+
"extends": "../../package.json"
42+
}
43+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
const port = 3030;
4+
5+
export default defineConfig({
6+
testDir: './tests',
7+
/*
8+
* Each Sentry poll can take up to 180s. The first poll also waits the ~2min until the span can be
9+
* queried, so the later polls usually pass at once. This leaves room for two full polls.
10+
*/
11+
timeout: 400_000,
12+
fullyParallel: true,
13+
forbidOnly: !!process.env.CI,
14+
retries: 0,
15+
// Every test spends most of its time polling Sentry, so run them all at once.
16+
workers: '100%',
17+
reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
18+
use: {
19+
baseURL: `http://localhost:${port}`,
20+
},
21+
webServer: {
22+
command: 'pnpm start',
23+
port,
24+
stdout: 'pipe',
25+
stderr: 'pipe',
26+
},
27+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here.
2+
import * as Sentry from '@sentry/node';
3+
import express from 'express';
4+
import OpenAI from 'openai';
5+
6+
// OpenRouter has no `/decisions` route, so this app calls OpenAI directly. CI only passes this key to
7+
// this app.
8+
const apiKey = process.env.E2E_OPENAI_DECISIONS_API_KEY;
9+
if (!apiKey) {
10+
throw new Error('E2E_OPENAI_DECISIONS_API_KEY is not set');
11+
}
12+
13+
const client = new OpenAI({ apiKey });
14+
15+
const MODEL = 'gpt-6-luna';
16+
17+
// A 1x1 red PNG, so the image request is cheap.
18+
const RED_PIXEL =
19+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
20+
21+
const app = express();
22+
23+
// Each route makes one `decisions.create` call and answers with the trace id, so the test can find
24+
// the request's spans in Sentry.
25+
26+
app.get('/decide', async (_req, res, next) => {
27+
try {
28+
const decision = await client.decisions.create({
29+
model: MODEL,
30+
input: 'I was charged twice this month and want a refund.',
31+
questions: [
32+
{ type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' },
33+
{
34+
type: 'choice',
35+
name: 'department',
36+
instructions: 'Which team should handle this?',
37+
choices: [
38+
{ value: 'billing', description: 'Charges and refunds' },
39+
{ value: 'other', description: 'Anything else' },
40+
],
41+
},
42+
{
43+
type: 'score',
44+
name: 'urgency',
45+
instructions: 'How urgent is this ticket?',
46+
levels: [{ label: 'low' }, { label: 'high' }],
47+
},
48+
],
49+
});
50+
res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers });
51+
} catch (error) {
52+
next(error);
53+
}
54+
});
55+
56+
app.get('/decide-image', async (_req, res, next) => {
57+
try {
58+
const decision = await client.decisions.create({
59+
model: MODEL,
60+
input: [
61+
{
62+
role: 'user',
63+
content: [
64+
{ type: 'input_text', text: 'Is this image mostly red?' },
65+
{ type: 'input_image', image_url: RED_PIXEL },
66+
],
67+
},
68+
],
69+
questions: [{ type: 'predicate', name: 'is_red', instructions: 'Is the image mostly red?' }],
70+
});
71+
res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers });
72+
} catch (error) {
73+
next(error);
74+
}
75+
});
76+
77+
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
78+
res.status(500).send({ message: error.message });
79+
});
80+
81+
const port = Number(process.env.PORT ?? 3030);
82+
app.listen(port, () => {
83+
// eslint-disable-next-line no-console
84+
console.log(`node-openai-decisions-send-to-sentry listening on port ${port}`);
85+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/node';
2+
3+
Sentry.init({
4+
dsn: process.env.E2E_TEST_DSN,
5+
environment: 'qa', // dynamic sampling bias to keep transactions
6+
tracesSampleRate: 1,
7+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { expect, test } from '@playwright/test';
2+
import {
3+
SEARCH_GEN_AI__INPUT__MESSAGES,
4+
SEARCH_GEN_AI__OPERATION__NAME,
5+
SEARCH_GEN_AI__OUTPUT__MESSAGES,
6+
SEARCH_GEN_AI__PROVIDER__NAME,
7+
SEARCH_GEN_AI__REQUEST__MODEL,
8+
SEARCH_GEN_AI__RESPONSE__MODEL,
9+
SEARCH_GEN_AI__USAGE__INPUT_TOKENS,
10+
SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS,
11+
SEARCH_GEN_AI__USAGE__TOTAL_TOKENS,
12+
SEARCH_ORIGIN,
13+
SEARCH_SPAN__OP,
14+
SEARCH_SPAN__STATUS,
15+
} from '@sentry/conventions/attributes/search';
16+
import { GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op';
17+
import {
18+
EVENT_POLLING_OPTIONS,
19+
fetchSpanAttributes,
20+
fetchTrace,
21+
findSpanInTrace,
22+
flattenTrace,
23+
traceTarget,
24+
} from '@sentry-internal/test-utils/cli';
25+
26+
// Mirrors src/app.ts.
27+
const MODEL = 'gpt-6-luna';
28+
29+
/**
30+
* What every decide span carries once Sentry has stored it, whatever the model answers. The keys are the
31+
* search names, e.g. `span.op` and `origin` rather than the `sentry.*` attributes the SDK sends.
32+
* `span.description` stays empty: ingest only derives it for ops it knows, and `gen_ai.decide` is new.
33+
*/
34+
const DECIDE_SPAN_ATTRIBUTES = {
35+
'span.name': `decide ${MODEL}`,
36+
[SEARCH_SPAN__OP]: GEN_AI_DECIDE,
37+
[SEARCH_SPAN__STATUS]: 'ok',
38+
[SEARCH_ORIGIN]: 'auto.ai.openai',
39+
[SEARCH_GEN_AI__OPERATION__NAME]: 'decide',
40+
[SEARCH_GEN_AI__PROVIDER__NAME]: 'openai',
41+
[SEARCH_GEN_AI__REQUEST__MODEL]: MODEL,
42+
[SEARCH_GEN_AI__RESPONSE__MODEL]: MODEL,
43+
};
44+
45+
test('records a text decision with its state, questions and answers', async ({ baseURL }) => {
46+
const response = await fetch(`${baseURL}/decide`);
47+
const body = await response.json();
48+
expect(response.status, JSON.stringify(body)).toBe(200);
49+
const { traceId }: { traceId: string } = body;
50+
51+
console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`);
52+
53+
let spanId: string | undefined;
54+
await expect
55+
.poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS)
56+
.toBeDefined();
57+
58+
let attributes: Record<string, unknown> | undefined;
59+
await expect
60+
.poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS)
61+
.toMatchObject(DECIDE_SPAN_ATTRIBUTES);
62+
63+
// Integers come back as strings. Decisions bills input tokens only, so there are no output tokens.
64+
expect(Number(attributes![SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS])).toBe(0);
65+
expect(Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS])).toBeGreaterThan(0);
66+
expect(Number(attributes![SEARCH_GEN_AI__USAGE__TOTAL_TOKENS])).toBe(
67+
Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS]),
68+
);
69+
70+
// The request span is the segment; it ends last, so it can land after the decide span.
71+
await expect
72+
.poll(() => {
73+
const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === HTTP_SERVER);
74+
return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === spanId);
75+
}, EVENT_POLLING_OPTIONS)
76+
.toBe(true);
77+
78+
expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([
79+
{
80+
type: 'evaluation',
81+
state: 'I was charged twice this month and want a refund.',
82+
questions: {
83+
wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' },
84+
department: {
85+
type: 'choice',
86+
instructions: 'Which team should handle this?',
87+
criteria: { billing: 'Charges and refunds', other: 'Anything else' },
88+
},
89+
urgency: {
90+
type: 'score',
91+
instructions: 'How urgent is this ticket?',
92+
criteria: ['low', 'high'],
93+
},
94+
},
95+
},
96+
]);
97+
98+
// The answers depend on the model, the shape does not.
99+
expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([
100+
{
101+
type: 'evaluation',
102+
answers: {
103+
wants_refund: { type: 'predicate', probability: expect.any(Number) },
104+
department: {
105+
type: 'choice',
106+
choice: expect.stringMatching(/^(billing|other)$/),
107+
probabilities: { billing: expect.any(Number), other: expect.any(Number) },
108+
confidence: expect.any(Number),
109+
},
110+
urgency: {
111+
type: 'score',
112+
score: expect.any(Number),
113+
probabilities: { 0: expect.any(Number), 1: expect.any(Number) },
114+
legend: { 0: 'low', 1: 'high' },
115+
confidence: expect.any(Number),
116+
},
117+
},
118+
},
119+
]);
120+
});
121+
122+
test('records an image decision with its image data', async ({ baseURL }) => {
123+
const response = await fetch(`${baseURL}/decide-image`);
124+
const body = await response.json();
125+
expect(response.status, JSON.stringify(body)).toBe(200);
126+
const { traceId }: { traceId: string } = body;
127+
128+
console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`);
129+
130+
let spanId: string | undefined;
131+
await expect
132+
.poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS)
133+
.toBeDefined();
134+
135+
let attributes: Record<string, unknown> | undefined;
136+
await expect
137+
.poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS)
138+
.toMatchObject(DECIDE_SPAN_ATTRIBUTES);
139+
140+
expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([
141+
{
142+
type: 'evaluation',
143+
state: [
144+
{
145+
role: 'user',
146+
content: [
147+
{ type: 'input_text', text: 'Is this image mostly red?' },
148+
{ type: 'input_image', image_url: expect.stringMatching(/^data:image\/png;base64,/) },
149+
],
150+
},
151+
],
152+
questions: { is_red: { type: 'predicate', instructions: 'Is the image mostly red?' } },
153+
},
154+
]);
155+
156+
expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([
157+
{ type: 'evaluation', answers: { is_red: { type: 'predicate', probability: expect.any(Number) } } },
158+
]);
159+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2022",
4+
"module": "nodenext",
5+
"moduleResolution": "nodenext",
6+
"outDir": "dist",
7+
"strict": true,
8+
"esModuleInterop": true,
9+
"skipLibCheck": true,
10+
"types": ["node"]
11+
},
12+
"include": ["src/**/*.ts"]
13+
}

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ export default defineConfig(async config => ({
1010
reactRouter(),
1111
sentryCloudflareVitePlugin(),
1212
...((await sentryReactRouter(
13-
// Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build.
14-
{ sourcemaps: { disable: true }, buildTimeInstrumentation: false },
13+
{ sourcemaps: { disable: true } },
1514
config,
1615
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1716
)) as any[]),

0 commit comments

Comments
 (0)