Skip to content

Commit 23fe2c2

Browse files
mydeaclaude
andauthored
feat(node): Add Groq and Together AI integrations (#24454)
Adds auto-instrumentation for the `groq-sdk` and `together-ai` clients — chat completions and embeddings, streaming and non-streaming. Both are Stainless-generated SDKs that speak the OpenAI wire format, so rather than duplicating the openai integration twice, a shared `createOpenAiCompatibleIntegration` factory reuses the existing `ai/openai` span-building, streaming and response-parsing logic and only overrides `gen_ai.provider.name` and the span origin. Each provider is then a small orchestrion config + a one-call integration file, so the next OpenAI-compatible provider (Perplexity, Cerebras, …) is ~15 lines on the same rail. Groq is the meaningful one by volume (~3.4M npm downloads/mo); Together is included since it shares the exact layout at no extra cost. `gen_ai.request.max_tokens` is intentionally not asserted — the shared openai request extractor doesn't capture it today (a separate, pre-existing gap), so these providers match its behaviour. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5dc2165 commit 23fe2c2

29 files changed

Lines changed: 839 additions & 0 deletions

File tree

‎dev-packages/node-integration-tests/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"fastify": "^5.12.1",
7272
"generic-pool": "^3.9.0",
7373
"graphql": "^16.11.0",
74+
"groq-sdk": "1.6.0",
7475
"graphql-tag": "^2.12.7",
7576
"hono": "^4.13.5",
7677
"http-terminator": "^3.2.0",
@@ -99,6 +100,7 @@
99100
"redis-5": "npm:redis@~5.11.0",
100101
"redis-5-tracing": "npm:redis@^5.12.0",
101102
"reflect-metadata": "0.2.1",
103+
"together-ai": "0.54.0",
102104
"rxjs": "^7.8.2",
103105
"tedious": "^19.2.1",
104106
"winston": "^3.17.0",
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://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
dataCollection: { genAI: { inputs: true, outputs: true } },
9+
transport: loggingTransport,
10+
});
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://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
dataCollection: { genAI: { inputs: false, outputs: false } },
9+
transport: loggingTransport,
10+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import Groq from 'groq-sdk';
2+
import * as Sentry from '@sentry/node';
3+
import express from 'express';
4+
5+
function startMockServer() {
6+
const app = express();
7+
app.use(express.json());
8+
9+
app.post('/openai/v1/chat/completions', (req, res) => {
10+
const { model, stream } = req.body;
11+
12+
// error-model returns 404 (not retried by the SDK) so the span records an error
13+
if (model === 'error-model') {
14+
res.status(404).set('x-request-id', 'mock-request-123').end('Model not found');
15+
return;
16+
}
17+
18+
if (stream) {
19+
res.setHeader('Content-Type', 'text/event-stream');
20+
res.setHeader('Cache-Control', 'no-cache');
21+
res.setHeader('Connection', 'keep-alive');
22+
23+
const chunks = [
24+
{
25+
id: 'chatcmpl-stream-123',
26+
object: 'chat.completion.chunk',
27+
created: 1677652300,
28+
model,
29+
choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }],
30+
},
31+
{
32+
id: 'chatcmpl-stream-123',
33+
object: 'chat.completion.chunk',
34+
created: 1677652300,
35+
model,
36+
choices: [{ index: 0, delta: { content: 'Hello from Groq streaming!' }, finish_reason: null }],
37+
},
38+
{
39+
id: 'chatcmpl-stream-123',
40+
object: 'chat.completion.chunk',
41+
created: 1677652300,
42+
model,
43+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
44+
usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 },
45+
},
46+
];
47+
48+
chunks.forEach((chunk, index) => {
49+
setTimeout(() => {
50+
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
51+
if (index === chunks.length - 1) {
52+
res.write('data: [DONE]\n\n');
53+
res.end();
54+
}
55+
}, index * 10);
56+
});
57+
} else {
58+
res.send({
59+
id: 'chatcmpl-mock123',
60+
object: 'chat.completion',
61+
created: 1677652288,
62+
model,
63+
choices: [
64+
{ index: 0, message: { role: 'assistant', content: 'Hello from Groq mock!' }, finish_reason: 'stop' },
65+
],
66+
usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 },
67+
});
68+
}
69+
});
70+
71+
app.post('/openai/v1/embeddings', (req, res) => {
72+
const { model } = req.body;
73+
res.send({
74+
id: 'embd-mock123',
75+
object: 'list',
76+
model,
77+
data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }],
78+
usage: { prompt_tokens: 8, total_tokens: 8 },
79+
});
80+
});
81+
82+
return new Promise(resolve => {
83+
const server = app.listen(0, () => {
84+
resolve(server);
85+
});
86+
});
87+
}
88+
89+
async function run() {
90+
const server = await startMockServer();
91+
92+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
93+
const client = new Groq({
94+
apiKey: 'mock-api-key',
95+
// `groq-sdk` appends `/openai/v1/...` to the baseURL, so it stays bare here.
96+
baseURL: `http://localhost:${server.address().port}`,
97+
});
98+
99+
await client.chat.completions.create({
100+
model: 'llama-3.3-70b-versatile',
101+
messages: [
102+
{ role: 'system', content: 'You are a helpful assistant.' },
103+
{ role: 'user', content: 'What is the capital of France?' },
104+
],
105+
temperature: 0.7,
106+
max_tokens: 100,
107+
});
108+
109+
try {
110+
await client.chat.completions.create({
111+
model: 'error-model',
112+
messages: [{ role: 'user', content: 'This will fail' }],
113+
});
114+
} catch {
115+
// expected
116+
}
117+
118+
const stream = await client.chat.completions.create({
119+
model: 'llama-3.1-8b-instant',
120+
messages: [{ role: 'user', content: 'Tell me about streaming' }],
121+
temperature: 0.8,
122+
stream: true,
123+
});
124+
125+
for await (const chunk of stream) {
126+
void chunk;
127+
}
128+
129+
await client.embeddings.create({
130+
model: 'nomic-embed-text-v1_5',
131+
input: 'Embedding test!',
132+
});
133+
});
134+
135+
await Sentry.flush(2000);
136+
server.close();
137+
}
138+
139+
run();
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
2+
import {
3+
GEN_AI_EMBEDDINGS_INPUT,
4+
GEN_AI_INPUT_MESSAGES,
5+
GEN_AI_OPERATION_NAME,
6+
GEN_AI_PROVIDER_NAME,
7+
GEN_AI_REQUEST_MODEL,
8+
GEN_AI_REQUEST_TEMPERATURE,
9+
GEN_AI_RESPONSE_FINISH_REASONS,
10+
GEN_AI_RESPONSE_ID,
11+
GEN_AI_RESPONSE_MODEL,
12+
GEN_AI_RESPONSE_STREAMING,
13+
GEN_AI_RESPONSE_TEXT,
14+
GEN_AI_SYSTEM_INSTRUCTIONS,
15+
GEN_AI_USAGE_INPUT_TOKENS,
16+
GEN_AI_USAGE_OUTPUT_TOKENS,
17+
GEN_AI_USAGE_TOTAL_TOKENS,
18+
} from '@sentry/conventions/attributes';
19+
import { afterAll, describe, expect } from 'vitest';
20+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
21+
22+
const PROVIDER = 'groq';
23+
const ORIGIN = 'auto.ai.groq';
24+
25+
describe('Groq integration', () => {
26+
afterAll(() => {
27+
cleanupChildProcesses();
28+
});
29+
30+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
31+
test('creates chat and embeddings spans with genAI recording disabled', async () => {
32+
await createRunner()
33+
.expect({
34+
span: container => {
35+
const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123');
36+
expect(chatSpan).toBeDefined();
37+
expect(chatSpan!.name).toBe('chat llama-3.3-70b-versatile');
38+
expect(chatSpan!.status).toBe('ok');
39+
expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
40+
expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat');
41+
expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN);
42+
expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER);
43+
expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('llama-3.3-70b-versatile');
44+
expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7);
45+
expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('llama-3.3-70b-versatile');
46+
expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]');
47+
expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
48+
expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15);
49+
expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25);
50+
// recording disabled → no prompt/response content
51+
expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
52+
expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
53+
54+
const streamSpan = container.items.find(
55+
s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
56+
);
57+
expect(streamSpan).toBeDefined();
58+
expect(streamSpan!.name).toBe('chat llama-3.1-8b-instant');
59+
expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
60+
expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
61+
expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30);
62+
63+
const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model');
64+
expect(errorSpan).toBeDefined();
65+
expect(errorSpan!.status).toBe('error');
66+
67+
const embeddingsSpan = container.items.find(
68+
s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123',
69+
);
70+
expect(embeddingsSpan).toBeDefined();
71+
expect(embeddingsSpan!.name).toBe('embeddings nomic-embed-text-v1_5');
72+
expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings');
73+
expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings');
74+
expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER);
75+
expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8);
76+
expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
77+
},
78+
})
79+
.start()
80+
.completed();
81+
});
82+
});
83+
84+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
85+
test('records chat and embeddings inputs/outputs with PII enabled', async () => {
86+
await createRunner()
87+
.expect({
88+
span: container => {
89+
const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123');
90+
expect(chatSpan).toBeDefined();
91+
// The system message is split out into gen_ai.system_instructions.
92+
expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.');
93+
expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe(
94+
'[{"role":"user","content":"What is the capital of France?"}]',
95+
);
96+
expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Groq mock!"]');
97+
98+
const embeddingsSpan = container.items.find(
99+
s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123',
100+
);
101+
expect(embeddingsSpan).toBeDefined();
102+
expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!');
103+
},
104+
})
105+
.start()
106+
.completed();
107+
});
108+
});
109+
});
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://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
dataCollection: { genAI: { inputs: true, outputs: true } },
9+
transport: loggingTransport,
10+
});
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://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
dataCollection: { genAI: { inputs: false, outputs: false } },
9+
transport: loggingTransport,
10+
});

0 commit comments

Comments
 (0)