Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Work in this release was contributed by @lazerg, @chiliec, @61465, @jpdefo, and

Add `Sentry.cronJobIntegration()` to `integrations` to send check-ins for every named `CronJob` of the `cron` package (4.3+), with the job name as the monitor slug. It's off by default. Each monitored job creates a monitor in Sentry, and monitors are billed. Use `excludeJobs` to skip jobs by name. `Sentry.cron.instrumentCron` is deprecated; use `cronJobIntegration` instead.

- **fix(server-utils): Instrument @anthropic-ai/sdk 1.x and turn off its own OTel spans ([#25750](https://github.com/getsentry/sentry-javascript/pull/25750))**

The Anthropic integration now instruments `@anthropic-ai/sdk` 1.x. Since 0.134 the SDK also records its own OpenTelemetry spans, such as `anthropic.messages.create`, which duplicated Sentry's `gen_ai` spans. Once Sentry instruments the SDK, it sets `ANTHROPIC_OPEN_TELEMETRY=false` to turn those spans off. To keep them, set `ANTHROPIC_OPEN_TELEMETRY=true` or pass the `openTelemetry` option to the Anthropic client.

- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))**

When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation.
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/bun-integration-tests/node-suites/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/pino/test.ts',
'suites/tracing/amqplib/test.ts',
'suites/tracing/anthropic/test.ts',
'suites/tracing/anthropic/v0/test.ts',
'suites/tracing/anthropic/v0.129/test.ts',
'suites/tracing/anthropic/v1/test.ts',
'suites/tracing/apollo-graphql/**',
'suites/tracing/dataloader/test.ts',
'suites/tracing/fastify/test.ts',
Expand Down
2 changes: 1 addition & 1 deletion dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"test:watch": "pnpm run test --watch"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.133.0",
"@anthropic-ai/sdk": "1.3.0",
"@apollo/server": "^5.5.0",
"@aws-sdk/client-bedrock-runtime": "^3.1161.0",
"@aws-sdk/client-dynamodb": "^3.1161.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,6 @@ function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/anthropic/v1/complete', (req, res) => {
res.send({
id: 'compl_withresponse',
type: 'completion',
model: req.body.model,
completion: 'Testing .asResponse() method!',
stop_reason: 'stop_sequence',
});
});

app.post('/anthropic/v1/messages', (req, res) => {
const model = req.body.model;

Expand Down Expand Up @@ -210,15 +200,6 @@ async function run() {
.asResponse();
await betaResponse.json();

const completionResponse = await client.completions
.create({
model: 'claude-2',
max_tokens_to_sample: 100,
prompt: '\n\nHuman: Test asResponse\n\nAssistant:',
})
.asResponse();
await completionResponse.json();

const rawStreamResponse = await client.messages
.create({
model: 'claude-3-haiku-20240307',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('Anthropic integration', () => {
span: container => {
expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
expect(spans).toHaveLength(6);
expect(spans).toHaveLength(5);
const nonStreamingSpans = spans.filter(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_withresponse',
);
Expand All @@ -60,7 +60,7 @@ describe('Anthropic integration', () => {
expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);

const rawResponseSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
expect(rawResponseSpans).toHaveLength(4);
expect(rawResponseSpans).toHaveLength(3);
for (const span of rawResponseSpans) {
expect(span.name).toBe(`chat ${span.attributes[GEN_AI_REQUEST_MODEL].value}`);
expect(span.status).toBe('ok');
Expand All @@ -70,7 +70,6 @@ describe('Anthropic integration', () => {
expect(rawResponseSpans.map(span => span.attributes[GEN_AI_REQUEST_MODEL].value)).toEqual([
'claude-3-haiku-20240307',
'claude-3-haiku-20240307',
'claude-2',
'claude-3-haiku-20240307',
]);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Anthropic from '@anthropic-ai/sdk';
import * as Sentry from '@sentry/node';
import express from 'express';

function streamEvents(id, model) {
return [
{
type: 'message_start',
message: { id, type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } },
},
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } },
{ type: 'message_stop' },
];
}

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/anthropic/v1/messages', (req, res) => {
// The prompt names the response id, so each call's span can be told apart.
const id = req.body.messages[0].content;

if (!req.body.stream) {
res.json({
id,
type: 'message',
role: 'assistant',
model: req.body.model,
content: [{ type: 'text', text: 'Hello!' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 10, output_tokens: 15 },
});
return;
}

res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const events = streamEvents(id, req.body.model);
events.forEach((event, index) => {
setTimeout(() => {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
if (index === events.length - 1) {
res.end();
}
}, index * 10);
});
});

app.post('/anthropic/v1/complete', (req, res) => {
res.json({
id: req.body.prompt.includes('asResponse') ? 'compl_v0_as_response' : 'compl_v0',
type: 'completion',
model: req.body.model,
completion: 'Hello!',
stop_reason: 'stop_sequence',
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const client = new Anthropic({
apiKey: 'mock-api-key',
baseURL: `http://localhost:${server.address().port}/anthropic`,
});
const model = 'claude-3-haiku-20240307';

await client.messages.create({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v0_create' }] });
await client.beta.messages.create({
model,
max_tokens: 100,
messages: [{ role: 'user', content: 'msg_v0_beta_create' }],
});

const stream = await client.messages.create({
model,
max_tokens: 100,
stream: true,
messages: [{ role: 'user', content: 'msg_v0_stream' }],
});
for await (const _ of stream) {
// consume
}

await client.messages
.stream({ model, max_tokens: 100, messages: [{ role: 'user', content: 'msg_v0_stream_helper' }] })
.finalMessage();

await client.completions.create({
model: 'claude-2',
max_tokens_to_sample: 100,
prompt: '\n\nHuman: Hello\n\nAssistant:',
});
const response = await client.completions
.create({ model: 'claude-2', max_tokens_to_sample: 100, prompt: '\n\nHuman: asResponse\n\nAssistant:' })
.asResponse();
await response.json();
});

await Sentry.flush(2000);

server.close();
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import {
GEN_AI_REQUEST_MODEL,
GEN_AI_RESPONSE_ID,
GEN_AI_RESPONSE_STREAMING,
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

type SerializedSpan = SerializedStreamedSpanContainer['items'][number];

const MESSAGE_CALLS = [
{ id: 'msg_v0_create', streaming: false },
{ id: 'msg_v0_beta_create', streaming: false },
{ id: 'msg_v0_stream', streaming: true },
{ id: 'msg_v0_stream_helper', streaming: true },
];

// The main suite runs on 1.x. This keeps the last 0.x version covered, including `completions`,
// which 1.0 removed.
describe('Anthropic integration (0.x)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(
__dirname,
'scenario.mjs',
'instrument.mjs',
(createRunner, test) => {
test('emits one span per call', async () => {
await createRunner()
.unordered()
.expect({
span: container => {
expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const spans = container.items.filter(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic',
);
expect(spans).toHaveLength(6);
for (const span of spans) {
expect(span.attributes[SENTRY_OP]?.value, span.name).toBe('gen_ai.chat');
expect(span.status, span.name).toBe('ok');
}

const spanForId = (id: string): SerializedSpan[] =>
spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id);

for (const { id, streaming } of MESSAGE_CALLS) {
expect(spanForId(id), id).toHaveLength(1);
expect(spanForId(id)[0]!.attributes[GEN_AI_RESPONSE_STREAMING]?.value ?? false, id).toBe(streaming);
}

// `.asResponse()` hands back the raw response, so its span has no response id.
const completionSpans = spans.filter(span => span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'claude-2');
expect(completionSpans.map(span => span.attributes[GEN_AI_RESPONSE_ID]?.value)).toEqual([
'compl_v0',
undefined,
]);
},
})
.start()
.completed();
});
},
{ additionalDependencies: { '@anthropic-ai/sdk': '0.133.0' } },
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
// Registers Sentry as the global tracer provider, which is what 1.x traces itself through.
enableOpenTelemetrySetup: process.env.OTEL_SETUP === 'true',
integrations:
process.env.WITHOUT_ANTHROPIC === 'true'
? integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI')
: undefined,
});
Loading
Loading