Skip to content

Commit c4c57cb

Browse files
andreiborzaclaude
andauthored
feat(server-utils): Add TypeSafe integration (#24703)
## What Calls to TypeSafe's Jev through `@typesafe-ai/sdk` (`TypeSafeClient.systemOne`) now create a `gen_ai.evaluate` span with the same shape as the Vercel AI evaluate span. `instrumentTypeSafeClient()` does the same for runtimes without auto-instrumentation. ## Why We want to instrument Jev. Closes: #24693 --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 8882b8a commit c4c57cb

28 files changed

Lines changed: 662 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
dataCollection: { genAI: { inputs: false, outputs: false } },
10+
// `instrumentTypeSafeClient` is the manual path for runtimes without the orchestrion hook.
11+
// Drop the automatic integration so the scenario exercises it alone.
12+
integrations: integrations => integrations.filter(integration => integration.name !== 'TypeSafe'),
13+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
// `instrumentTypeSafeClient` is the manual path for runtimes without the orchestrion hook.
10+
// Drop the automatic integration so the scenario exercises it alone.
11+
integrations: integrations => integrations.filter(integration => integration.name !== 'TypeSafe'),
12+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
dataCollection: { genAI: { inputs: false, outputs: false } },
10+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as Sentry from '@sentry/node';
2+
import { choice, noul, score, TypeSafeClient } from '@typesafe-ai/sdk';
3+
import express from 'express';
4+
5+
function startMockServer() {
6+
const app = express();
7+
app.use(express.json());
8+
9+
app.post('/v1/systemone', (req, res) => {
10+
if (req.body.model === 'error-model') {
11+
res.status(400).json({ error: 'Unknown model' });
12+
return;
13+
}
14+
15+
res.json({
16+
model: 'jev-1.13.0',
17+
answers: {
18+
authIssue: { type: 'noul', noul: 0.98 },
19+
department: {
20+
type: 'choice',
21+
choice: 'billing',
22+
confidence: 0.28,
23+
probabilities: { billing: 0.64, technical: 0.36 },
24+
},
25+
urgency: {
26+
type: 'score',
27+
score: 1.58,
28+
confidence: 0.9,
29+
legend: { 0: 'low', 1: 'medium', 2: 'high' },
30+
probabilities: { 0: 0, 1: 0.42, 2: 0.58 },
31+
},
32+
},
33+
usage: { input_tokens: 275, output_tokens: 20 },
34+
});
35+
});
36+
37+
return new Promise(resolve => {
38+
const server = app.listen(0, () => {
39+
resolve(server);
40+
});
41+
});
42+
}
43+
44+
async function run() {
45+
const server = await startMockServer();
46+
const client = Sentry.instrumentTypeSafeClient(
47+
new TypeSafeClient({
48+
apiKey: 'mock-api-key',
49+
baseURL: `http://localhost:${server.address().port}`,
50+
retry: { maxRetries: 0 },
51+
}),
52+
);
53+
const state = 'I cannot log in, and I also want a refund for last month.';
54+
const questions = {
55+
authIssue: noul('Is there a login problem?'),
56+
department: choice('Which team should handle this?', {
57+
billing: 'Charges and refunds',
58+
technical: 'Bugs and outages',
59+
}),
60+
urgency: score('How urgent is this ticket?', ['low', 'medium', 'high']),
61+
};
62+
63+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
64+
await client.systemOne({ state, questions });
65+
66+
// The instrumentation must not read the body the caller gets from `asResponse()`.
67+
const response = await client.systemOne({ model: 'jev-1.13', state, questions }).asResponse();
68+
await response.json();
69+
70+
try {
71+
await client.systemOne({ model: 'error-model', state, questions });
72+
} catch {
73+
// expected
74+
}
75+
76+
// The SDK rejects empty questions with a `TypeSafeError` before sending the request.
77+
try {
78+
await client.systemOne({ model: 'validation-error', state, questions: {} });
79+
} catch {
80+
// expected
81+
}
82+
});
83+
84+
server.close();
85+
}
86+
87+
run();
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as Sentry from '@sentry/node';
2+
import { choice, noul, score, TypeSafeClient } from '@typesafe-ai/sdk';
3+
import express from 'express';
4+
5+
function startMockServer() {
6+
const app = express();
7+
app.use(express.json());
8+
9+
app.post('/v1/systemone', (req, res) => {
10+
if (req.body.model === 'error-model') {
11+
res.status(400).json({ error: 'Unknown model' });
12+
return;
13+
}
14+
15+
res.json({
16+
model: 'jev-1.13.0',
17+
answers: {
18+
authIssue: { type: 'noul', noul: 0.98 },
19+
department: {
20+
type: 'choice',
21+
choice: 'billing',
22+
confidence: 0.28,
23+
probabilities: { billing: 0.64, technical: 0.36 },
24+
},
25+
urgency: {
26+
type: 'score',
27+
score: 1.58,
28+
confidence: 0.9,
29+
legend: { 0: 'low', 1: 'medium', 2: 'high' },
30+
probabilities: { 0: 0, 1: 0.42, 2: 0.58 },
31+
},
32+
},
33+
usage: { input_tokens: 275, output_tokens: 20 },
34+
});
35+
});
36+
37+
return new Promise(resolve => {
38+
const server = app.listen(0, () => {
39+
resolve(server);
40+
});
41+
});
42+
}
43+
44+
async function run() {
45+
const server = await startMockServer();
46+
const client = new TypeSafeClient({
47+
apiKey: 'mock-api-key',
48+
baseURL: `http://localhost:${server.address().port}`,
49+
retry: { maxRetries: 0 },
50+
});
51+
const state = 'I cannot log in, and I also want a refund for last month.';
52+
const questions = {
53+
authIssue: noul('Is there a login problem?'),
54+
department: choice('Which team should handle this?', {
55+
billing: 'Charges and refunds',
56+
technical: 'Bugs and outages',
57+
}),
58+
urgency: score('How urgent is this ticket?', ['low', 'medium', 'high']),
59+
};
60+
61+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
62+
await client.systemOne({ state, questions });
63+
64+
// The instrumentation must not read the body the caller gets from `asResponse()`.
65+
const response = await client.systemOne({ model: 'jev-1.13', state, questions }).asResponse();
66+
await response.json();
67+
68+
try {
69+
await client.systemOne({ model: 'error-model', state, questions });
70+
} catch {
71+
// expected
72+
}
73+
74+
// The SDK rejects empty questions with a `TypeSafeError` before sending the request.
75+
try {
76+
await client.systemOne({ model: 'validation-error', state, questions: {} });
77+
} catch {
78+
// expected
79+
}
80+
});
81+
82+
server.close();
83+
}
84+
85+
run();
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {
2+
GEN_AI_INPUT_MESSAGES,
3+
GEN_AI_OPERATION_NAME,
4+
GEN_AI_OUTPUT_MESSAGES,
5+
GEN_AI_PROVIDER_NAME,
6+
GEN_AI_REQUEST_MODEL,
7+
GEN_AI_RESPONSE_MODEL,
8+
GEN_AI_USAGE_INPUT_TOKENS,
9+
GEN_AI_USAGE_OUTPUT_TOKENS,
10+
GEN_AI_USAGE_TOTAL_TOKENS,
11+
SENTRY_OP,
12+
SENTRY_ORIGIN,
13+
} from '@sentry/conventions/attributes';
14+
import { afterAll, describe, expect } from 'vitest';
15+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
16+
17+
describe('TypeSafe integration', () => {
18+
afterAll(() => {
19+
cleanupChildProcesses();
20+
});
21+
22+
describe.each([
23+
['automatic', 'scenario.mjs', 'instrument.mjs', 'instrument-no-recording.mjs'],
24+
['manual', 'scenario-manual.mjs', 'instrument-manual.mjs', 'instrument-manual-no-recording.mjs'],
25+
])('%s instrumentation', (_, scenarioFile, instrumentFile, noRecordingInstrumentFile) => {
26+
createEsmAndCjsTests(
27+
__dirname,
28+
scenarioFile,
29+
instrumentFile,
30+
(createRunner, test) => {
31+
test('creates evaluate spans for systemOne', async () => {
32+
await createRunner()
33+
.unordered()
34+
.expect({
35+
span: container => {
36+
const evaluateSpans = container.items.filter(
37+
span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
38+
);
39+
expect(evaluateSpans).toHaveLength(4);
40+
for (const span of evaluateSpans) {
41+
expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.typesafe');
42+
expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate');
43+
expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('typesafe');
44+
}
45+
46+
const defaultModelSpan = evaluateSpans.find(span => span.name === 'evaluate jev-latest')!;
47+
expect(defaultModelSpan).toBeDefined();
48+
expect(defaultModelSpan.status).toBe('ok');
49+
expect(defaultModelSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('jev-latest');
50+
expect(defaultModelSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
51+
expect(defaultModelSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275);
52+
expect(defaultModelSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
53+
expect(defaultModelSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(295);
54+
expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string)).toEqual([
55+
{
56+
type: 'evaluation',
57+
state: 'I cannot log in, and I also want a refund for last month.',
58+
questions: {
59+
authIssue: { type: 'noul', instructions: 'Is there a login problem?' },
60+
department: {
61+
type: 'choice',
62+
instructions: 'Which team should handle this?',
63+
criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' },
64+
},
65+
urgency: {
66+
type: 'score',
67+
instructions: 'How urgent is this ticket?',
68+
criteria: ['low', 'medium', 'high'],
69+
},
70+
},
71+
},
72+
]);
73+
expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string)).toEqual([
74+
{
75+
type: 'evaluation',
76+
answers: {
77+
authIssue: { type: 'noul', noul: 0.98 },
78+
department: {
79+
type: 'choice',
80+
choice: 'billing',
81+
confidence: 0.28,
82+
probabilities: { billing: 0.64, technical: 0.36 },
83+
},
84+
urgency: {
85+
type: 'score',
86+
score: 1.58,
87+
confidence: 0.9,
88+
legend: { 0: 'low', 1: 'medium', 2: 'high' },
89+
probabilities: { 0: 0, 1: 0.42, 2: 0.58 },
90+
},
91+
},
92+
},
93+
]);
94+
95+
const asResponseSpan = evaluateSpans.find(span => span.name === 'evaluate jev-1.13')!;
96+
expect(asResponseSpan).toBeDefined();
97+
expect(asResponseSpan.status).toBe('ok');
98+
expect(asResponseSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0');
99+
100+
const errorSpan = evaluateSpans.find(span => span.name === 'evaluate error-model')!;
101+
expect(errorSpan).toBeDefined();
102+
expect(errorSpan.status).toBe('error');
103+
expect(errorSpan.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
104+
expect(errorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
105+
106+
const validationErrorSpan = evaluateSpans.find(span => span.name === 'evaluate validation-error')!;
107+
expect(validationErrorSpan).toBeDefined();
108+
expect(validationErrorSpan.status).toBe('error');
109+
expect(validationErrorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
110+
},
111+
})
112+
.start()
113+
.completed();
114+
});
115+
},
116+
{
117+
additionalDependencies: {
118+
'@typesafe-ai/sdk': '^0.6.0',
119+
},
120+
},
121+
);
122+
123+
createEsmAndCjsTests(
124+
__dirname,
125+
scenarioFile,
126+
noRecordingInstrumentFile,
127+
(createRunner, test) => {
128+
test('does not record inputs or outputs when recording is off', async () => {
129+
await createRunner()
130+
.unordered()
131+
.expect({
132+
span: container => {
133+
const evaluateSpans = container.items.filter(
134+
span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
135+
);
136+
expect(evaluateSpans).toHaveLength(4);
137+
expect(evaluateSpans.filter(span => span.status === 'error')).toHaveLength(2);
138+
for (const span of evaluateSpans) {
139+
expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
140+
expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
141+
// State, questions and answers must not come back through another attribute (e.g. an error message).
142+
// Only the attributes are checked (timestamps could match a number), and `probabilities` only
143+
// occurs in answers.
144+
expect(JSON.stringify(span.attributes)).not.toMatch(
145+
/cannot log in|Charges and refunds|probabilities/,
146+
);
147+
}
148+
},
149+
})
150+
.start()
151+
.completed();
152+
});
153+
},
154+
{
155+
additionalDependencies: {
156+
'@typesafe-ai/sdk': '^0.6.0',
157+
},
158+
},
159+
);
160+
});
161+
});

‎packages/astro/src/index.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export {
9595
openAIIntegration,
9696
groqIntegration,
9797
togetherAIIntegration,
98+
typesafeIntegration,
9899
langChainIntegration,
99100
langGraphIntegration,
100101
createFlueInstrumentation,
@@ -161,6 +162,7 @@ export {
161162
supabaseIntegration,
162163
instrumentSupabaseClient,
163164
instrumentMistralAiClient,
165+
instrumentTypeSafeClient,
164166
instrumentOpenAiClient,
165167
instrumentAnthropicAiClient,
166168
instrumentGoogleGenAIClient,

0 commit comments

Comments
 (0)