diff --git a/.cursor/commands/publish_release.md b/.cursor/commands/publish_release.md new file mode 100644 index 000000000000..5d1477992dfb --- /dev/null +++ b/.cursor/commands/publish_release.md @@ -0,0 +1,5 @@ +# Release Command + +Execute the standard Sentry JavaScript SDK release process. + +Find the "publishing_release" rule in `.cursor/rules/publishing_release` and follow those complete instructions step by step. diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5dd9918a40..1989cd3a1f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.16.0 + +- feat(logs): Add internal `replay_is_buffering` flag ([#17752](https://github.com/getsentry/sentry-javascript/pull/17752)) +- feat(react-router): Update loadContext type to be compatible with middleware ([#17758](https://github.com/getsentry/sentry-javascript/pull/17758)) +- feat(replay/logs): Only attach sampled replay Ids to logs ([#17750](https://github.com/getsentry/sentry-javascript/pull/17750)) +- fix(browser): Use current start timestamp for CLS span when CLS is 0 ([#17800](https://github.com/getsentry/sentry-javascript/pull/17800)) +- fix(core): Prevent `instrumentAnthropicAiClient` breaking MessageStream api ([#17754](https://github.com/getsentry/sentry-javascript/pull/17754)) +- fix(nextjs): Don't use chalk in turbopack config file ([#17806](https://github.com/getsentry/sentry-javascript/pull/17806)) +- fix(react): Do not send additional navigation span on pageload ([#17799](https://github.com/getsentry/sentry-javascript/pull/17799)) + +
+ Internal Changes + +- build(aws): Ensure AWS build cache does not keep old files ([#17776](https://github.com/getsentry/sentry-javascript/pull/17776)) +- chore: Add `publish_release` command ([#17797](https://github.com/getsentry/sentry-javascript/pull/17797)) +- ref(aws-serverless): Add resolution for `import-in-the-middle` when building the Lambda layer ([#17780](https://github.com/getsentry/sentry-javascript/pull/17780)) +- ref(aws-serverless): Improve README with better examples ([#17787](https://github.com/getsentry/sentry-javascript/pull/17787)) +- ref(core): Improve promise buffer ([#17788](https://github.com/getsentry/sentry-javascript/pull/17788)) +- Revert "test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility ([#17777](https://github.com/getsentry/sentry-javascript/pull/17777))" (#17784) +- test(e2e): Pin `import-in-the-middle@1.14.2` due to `@vercel/nft` incompatibility ([#17777](https://github.com/getsentry/sentry-javascript/pull/17777)) +- test(nextjs): Add route handler tests for turbopack ([#17515](https://github.com/getsentry/sentry-javascript/pull/17515)) +- test(react-router): Test v8 middleware ([#17783](https://github.com/getsentry/sentry-javascript/pull/17783)) + +
+ ## 10.15.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts new file mode 100644 index 000000000000..dbc0c6193131 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/error/route.ts @@ -0,0 +1,3 @@ +export async function GET(request: Request) { + throw new Error('Dynamic route handler error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts new file mode 100644 index 000000000000..581a4d68b640 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/[param]/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ name: 'Beep' }); +} + +export async function POST() { + return NextResponse.json({ name: 'Boop' }, { status: 400 }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts new file mode 100644 index 000000000000..c2407f908b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/route-handlers/static/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + return NextResponse.json({ name: 'Static' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 1cfbd8eb6628..e28db5352884 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "^19", "@types/react-dom": "^19", - "next": "^15.3.5", + "next": "^15.5.4", "react": "^19", "react-dom": "^19", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts new file mode 100644 index 000000000000..544ba0084167 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for dynamic route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/[param]'; + }); + + const response = await request.get('/route-handlers/foo'); + expect(await response.json()).toStrictEqual({ name: 'Beep' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should create a transaction for static route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/static'; + }); + + const response = await request.get('/route-handlers/static'); + expect(await response.json()).toStrictEqual({ name: 'Static' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ + request, +}) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'POST /route-handlers/[param]'; + }); + + const response = await request.post('/route-handlers/bar'); + expect(await response.json()).toStrictEqual({ name: 'Boop' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('invalid_argument'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); +}); + +test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-turbo', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Dynamic route handler error'; + }); + + const routehandlerTransactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handlers/[param]/error'; + }); + + await request.get('/route-handlers/boop/error').catch(() => {}); + + const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerError = await errorEventPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.origin).toContain('auto'); + + expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error'); + + expect(routehandlerError.request?.method).toBe('GET'); + // todo: make sure url is attached to request object + // expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); + + expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts new file mode 100644 index 000000000000..a15189e5bed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react-router'; + +export type User = { + id: string; + name: string; +}; + +export const userContext = createContext(null); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index b412893def52..731081b54f52 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -17,5 +17,6 @@ export default [ route('static', 'routes/performance/static.tsx'), route('server-loader', 'routes/performance/server-loader.tsx'), route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..c86f78e17164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/with-middleware.tsx @@ -0,0 +1,38 @@ +import type { Route } from './+types/with-middleware'; +import type { User } from '../../context'; +import { userContext } from '../../context'; +import * as Sentry from '@sentry/react-router'; + +async function getUser() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + id: '1', + name: 'Carlos Gomez', + }; +} + +const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { + Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { + const user: User = await getUser(); + context.set(userContext, user); + await next(); + }); +}; + +export const middleware: Route.MiddlewareFunction[] = [authMiddleware]; + +export const loader = async ({ context }: Route.LoaderArgs) => { + const user = context.get(userContext); + return { user }; +}; + +export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + + return ( +
+

With Middleware Page

+

User: {user?.name}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json index a6bd5459ae2c..1ec3da8da47a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json @@ -24,6 +24,7 @@ }, "scripts": { "build": "react-router build", + "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build", "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", "proxy": "node start-event-proxy.mjs", @@ -54,5 +55,13 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-latest", + "label": "react-router-7-framework (latest)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts index bb1f96469dd2..72f2eef3b0f5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts @@ -3,4 +3,7 @@ import type { Config } from '@react-router/dev/config'; export default { ssr: true, prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, } satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..dbce05350ad9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - middleware', () => { + test('should send middleware transaction on pageload', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with-middleware'; + }); + + const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'authMiddleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const serverTx = await serverTxPromise; + const pageloadTx = await pageloadTxPromise; + const customMiddlewareTx = await customMiddlewareTxPromise; + + const traceIds = { + server: serverTx?.contexts?.trace?.trace_id, + pageload: pageloadTx?.contexts?.trace?.trace_id, + customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, + }; + + expect(pageloadTx).toBeDefined(); + expect(customMiddlewareTx).toBeDefined(); + + // Assert that all transactions belong to the same trace + expect(traceIds.server).toBe(traceIds.pageload); + expect(traceIds.server).toBe(traceIds.customMiddleware); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 2cfa2935b5bf..34e5105f8f9d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -23,6 +23,37 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) expect(event.contexts?.trace?.op).toBe('pageload'); }); +test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + await page.goto('/lazy/inner/1/2/3'); + + const pageloadEvent = await pageloadPromise; + + expect(pageloadEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // "Race" between navigation transaction and a timeout to ensure no navigation transaction is created within the timeout period + const result = await Promise.race([ + navigationPromise.then(() => 'navigation'), + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 1500)), + ]); + + expect(result).toBe('timeout'); +}); + test('Creates a navigation transaction inside a lazy route', async ({ page }) => { const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { return ( diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs index 577c63dc3d08..7e8e2ecd52f8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -29,6 +29,55 @@ function startMockAnthropicServer() { return; } + // Check if streaming is requested + if (req.body.stream === true) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + // Send streaming events + const events = [ + { + type: 'message_start', + message: { + id: 'msg_stream123', + 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_delta', index: 0, delta: { type: 'text_delta', text: 'from ' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'stream!' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 15 }, + }, + { type: 'message_stop' }, + ]; + + 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); // Small delay between events + }); + + return; + } + + // Non-streaming response res.send({ id: 'msg_mock123', type: 'message', @@ -92,8 +141,32 @@ async function run() { // Fourth test: models.retrieve await client.models.retrieve('claude-3-haiku-20240307'); + + // Fifth test: streaming via messages.create + const stream = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + stream: true, + }); + + for await (const _ of stream) { + void _; + } + + // Sixth test: streaming via messages.stream + await client.messages + .stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }) + .on('streamEvent', () => { + Sentry.captureMessage('stream event from user-added event listener captured'); + }); }); + // Wait for the stream event handler to finish + await Sentry.flush(2000); + server.close(); } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 9c14f698bc18..c05db16fc251 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -152,6 +152,30 @@ describe('Anthropic integration', () => { origin: 'auto.ai.anthropic', status: 'ok', }), + // Fifth span - messages.create with stream: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + }), + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Sixth span - messages.stream + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + }), + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), ]), }; @@ -189,6 +213,21 @@ describe('Anthropic integration', () => { ]), }; + const EXPECTED_MODEL_ERROR = { + exception: { + values: [ + { + type: 'Error', + value: '404 Model not found', + }, + ], + }, + }; + + const EXPECTED_STREAM_EVENT_HANDLER_MESSAGE = { + message: 'stream event from user-added event listener captured', + }; + createEsmAndCjsTests(__dirname, 'scenario-manual-client.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans when manually insturmenting client', async () => { await createRunner() @@ -202,8 +241,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: false', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); @@ -212,8 +252,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates anthropic related spans with sendDefaultPii: true', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); @@ -222,8 +263,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates anthropic related spans with custom options', async () => { await createRunner() - .ignore('event') + .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE }) .start() .completed(); }); diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 810b180c3ba9..79053f4ef7ee 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -4,6 +4,8 @@ _These steps are only relevant to Sentry employees when preparing and publishing These have also been documented via [Cursor Rules](../.cursor/rules/publishing-release.mdc). +You can run a pre-configured command in cursor by just typing `/publish_release` into the chat window to automate the steps below. + **If you want to release a new SDK for the first time, be sure to follow the [New SDK Release Checklist](./new-sdk-release-checklist.md)** diff --git a/packages/aws-serverless/README.md b/packages/aws-serverless/README.md index 353b702fbcb9..81372f2178d2 100644 --- a/packages/aws-serverless/README.md +++ b/packages/aws-serverless/README.md @@ -15,16 +15,53 @@ This package is a wrapper around `@sentry/node`, with added functionality related to AWS Lambda. All methods available in `@sentry/node` can be imported from `@sentry/aws-serverless`. -To use this SDK, call `Sentry.init(options)` at the very beginning of your JavaScript file. +### Automatic Setup + +To use this SDK with an automatic setup, set the following environment variables in your Lambda function configuration: + +```bash +NODE_OPTIONS="--import @sentry/aws-serverless/awslambda-auto" +SENTRY_DSN="__DSN__" +# Add Tracing by setting tracesSampleRate and adding integration +# Set tracesSampleRate to 1.0 to capture 100% of transactions +# We recommend adjusting this value in production +# Learn more at +# https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate +SENTRY_TRACES_SAMPLE_RATE="1.0" +``` + +### Manual Setup + +Alternatively, to further customize the SDK setup, you can also manually initialize the SDK in your lambda function. The benefit of this installation method is that you can fully customize your Sentry SDK setup in a Sentry.init call. -```javascript +Create a new file, for example `instrument.js` to initialize the SDK: + +```js import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ dsn: '__DSN__', - // ... + // Adds request headers and IP for users, for more info visit: + // https://docs.sentry.io/platforms/javascript/guides/aws-lambda/configuration/options/#sendDefaultPii + sendDefaultPii: true, + // Add Tracing by setting tracesSampleRate and adding integration + // Set tracesSampleRate to 1.0 to capture 100% of transactions + // We recommend adjusting this value in production + // Learn more at + // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate + tracesSampleRate: 1.0, }); +``` + +And then load the SDK before your function starts by importing the instrument.js file via a NODE_OPTIONS environment variable: + +```bash +NODE_OPTIONS="--import ./instrument.js" +``` + +## Verify +```js // async (recommended) export const handler = async (event, context) => { throw new Error('oh, hello there!'); @@ -36,19 +73,7 @@ export const handler = (event, context, callback) => { }; ``` -If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the -`tracesSampleRate` option. - -```javascript -import * as Sentry from '@sentry/aws-serverless'; - -Sentry.init({ - dsn: '__DSN__', - tracesSampleRate: 1.0, -}); -``` - -#### Integrate Sentry using the Sentry Lambda layer +## Integrate Sentry using the Sentry Lambda layer Another much simpler way to integrate Sentry to your AWS Lambda function is to add the official layer. diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index f5361a8428a3..b00fe8929303 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -79,7 +79,7 @@ }, "scripts": { "build": "run-p build:transpile build:types", - "build:layer": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", + "build:layer": "rimraf build/aws && rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer", "build:types": "run-s build:types:core build:types:downlevel", diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index c12d8bd70d77..241bc864816a 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -51,9 +51,11 @@ async function buildLambdaLayer(): Promise { fs.chmodSync('./build/aws/dist-serverless/sentry-extension/index.mjs', 0o755); const zipFilename = `sentry-node-serverless-${version}.zip`; + // Only include these directories in the zip file + const dirsToZip = ['nodejs', 'extensions', 'sentry-extension']; console.log(`Creating final layer zip file ${zipFilename}.`); // need to preserve the symlink above with -y - run(`zip -r -y ${zipFilename} .`, { cwd: 'build/aws/dist-serverless' }); + run(`zip -r -y ${zipFilename} ${dirsToZip.join(' ')}`, { cwd: 'build/aws/dist-serverless' }); } // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -79,7 +81,11 @@ async function pruneNodeModules(): Promise { './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/awslambda-auto.js', ]; - const { fileList } = await nodeFileTrace(entrypoints); + const { fileList } = await nodeFileTrace(entrypoints, { + // import-in-the-middle uses mixed require and import syntax in their `hook.mjs` file. + // So we need to set `mixedModules` to `true` to ensure that all modules are tracked. + mixedModules: true, + }); const allFiles = getAllFiles('./build/aws/dist-serverless/nodejs/node_modules'); diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 99f76c610226..d836ff315c06 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; @@ -42,12 +43,15 @@ export function trackClsAsStandaloneSpan(client: Client): void { }, true); listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); + _sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); cleanupClsHandler(); }); } -function sendStandaloneClsSpan( +/** + * Exported only for testing! + */ +export function _sendStandaloneClsSpan( clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string, @@ -55,7 +59,7 @@ function sendStandaloneClsSpan( ) { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); const routeName = getCurrentScope().getScopeData().transactionName; const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; @@ -63,7 +67,7 @@ function sendStandaloneClsSpan( const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.cls', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.cls', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // attach the pageload span id to the CLS span so that we can link them in the UI 'sentry.pageload.span_id': pageloadSpanId, // describes what triggered the web vital to be reported diff --git a/packages/browser-utils/test/metrics/cls.test.ts b/packages/browser-utils/test/metrics/cls.test.ts new file mode 100644 index 000000000000..55550d02f546 --- /dev/null +++ b/packages/browser-utils/test/metrics/cls.test.ts @@ -0,0 +1,231 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _sendStandaloneClsSpan } from '../../src/metrics/cls'; +import * as WebVitalUtils from '../../src/metrics/utils'; + +// Mock all Sentry core dependencies +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + }; +}); + +describe('_sendStandaloneClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); + }); + + it('sends a standalone CLS span with entry data', () => { + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + const reportEvent = 'navigation'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, reportEvent); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '123', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + }, + startTime: 1.1, // (1000 + 100) / 1000 + }); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.1); + }); + + it('sends a standalone CLS span without entry data', () => { + const clsValue = 0; + const pageloadSpanId = '456'; + const reportEvent = 'pagehide'; + + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, reportEvent); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.browserPerformanceTimeOrigin).not.toHaveBeenCalled(); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: 'Layout shift', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': pageloadSpanId, + 'sentry.report_event': 'pagehide', + }, + startTime: 1.5, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0, + }); + }); + + it('handles entry with multiple sources', () => { + const clsValue = 0.15; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'span' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '789'; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3); + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '789', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + 'cls.source.2': '', + }, + startTime: 1.2, // (1000 + 200) / 1000 + }); + }); + + it('handles entry without sources', () => { + const clsValue = 0.05; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 50, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '101'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '101', + 'sentry.report_event': 'navigation', + }, + startTime: 1.05, // (1000 + 50) / 1000 + }); + }); + + it('handles when startStandaloneWebVitalSpan returns undefined', () => { + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(undefined); + + const clsValue = 0.1; + const pageloadSpanId = '123'; + + expect(() => { + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, 'navigation'); + }).not.toThrow(); + + expect(mockSpan.addEvent).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + + it('handles when browserPerformanceTimeOrigin returns null', () => { + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); + + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: 0.2, + }), + ); + }); +}); diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index adcbf0dfb737..b3bda05d97f7 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -151,8 +151,20 @@ export function _INTERNAL_captureLog( setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name); setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version); - const replay = client.getIntegrationByName string }>('Replay'); - setLogAttribute(processedLogAttributes, 'sentry.replay_id', replay?.getReplayId()); + const replay = client.getIntegrationByName< + Integration & { + getReplayId: (onlyIfSampled?: boolean) => string; + getRecordingMode: () => 'session' | 'buffer' | undefined; + } + >('Replay'); + + const replayId = replay?.getReplayId(true); + setLogAttribute(processedLogAttributes, 'sentry.replay_id', replayId); + + if (replayId && replay?.getRecordingMode() === 'buffer') { + // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry + setLogAttribute(processedLogAttributes, 'sentry._internal.replay_is_buffering', true); + } const beforeLogMessage = beforeLog.message; if (isParameterizedString(beforeLogMessage)) { diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8cc44c188671..8e77dd76b34e 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -25,7 +25,7 @@ import { } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; -import { instrumentStream } from './streaming'; +import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, AnthropicAiOptions, @@ -194,6 +194,74 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record addMetadataAttributes(span, response); } +/** + * Handle common error catching and reporting for streaming requests + */ +function handleStreamingError(error: unknown, span: Span, methodPath: string): never { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.anthropic', data: { function: methodPath } }, + }); + + if (span.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + } + throw error; +} + +/** + * Handle streaming cases with common logic + */ +function handleStreamingRequest( + originalMethod: (...args: T) => Promise, + target: (...args: T) => Promise, + context: unknown, + args: T, + requestAttributes: Record, + operationName: string, + methodPath: string, + params: Record | undefined, + options: AnthropicAiOptions, + isStreamRequested: boolean, +): Promise { + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const spanConfig = { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }; + + if (isStreamRequested) { + return startSpanManual(spanConfig, async span => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const result = await originalMethod.apply(context, args); + return instrumentAsyncIterableStream( + result as AsyncIterable, + span, + options.recordOutputs ?? false, + ) as unknown as R; + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + }); + } else { + return startSpanManual(spanConfig, span => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const messageStream = target.apply(context, args); + return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); + } catch (error) { + return handleStreamingError(error, span, methodPath); + } + }); + } +} + /** * Instrument a method with Sentry spans * Following Sentry AI Agents Manual Instrumentation conventions @@ -205,82 +273,62 @@ function instrumentMethod( context: unknown, options: AnthropicAiOptions, ): (...args: T) => Promise { - return async function instrumentedMethod(...args: T): Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); - const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); + return new Proxy(originalMethod, { + apply(target, thisArg, args: T): Promise { + const requestAttributes = extractRequestAttributes(args, methodPath); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); - const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; - const isStreamRequested = Boolean(params?.stream); - const isStreamingMethod = methodPath === 'messages.stream'; + const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; + const isStreamRequested = Boolean(params?.stream); + const isStreamingMethod = methodPath === 'messages.stream'; - if (isStreamRequested || isStreamingMethod) { - return startSpanManual( + if (isStreamRequested || isStreamingMethod) { + return handleStreamingRequest( + originalMethod, + target, + context, + args, + requestAttributes, + operationName, + methodPath, + params, + options, + isStreamRequested, + ); + } + + return startSpan( { - name: `${operationName} ${model} stream-response`, + name: `${operationName} ${model}`, op: getSpanOperation(methodPath), attributes: requestAttributes as Record, }, - async span => { - try { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - - const result = await originalMethod.apply(context, args); - return instrumentStream( - result as AsyncIterable, - span, - options.recordOutputs ?? false, - ) as unknown as R; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, - }, - }, - }); - span.end(); - throw error; + span => { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); } - }, - ); - } - return startSpan( - { - name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), - attributes: requestAttributes as Record, - }, - span => { - if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); - } - - return handleCallbackErrors( - () => originalMethod.apply(context, args), - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, + return handleCallbackErrors( + () => target.apply(context, args), + error => { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: methodPath, + }, }, - }, - }); - }, - () => {}, - result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), - ); - }, - ); - }; + }); + }, + () => {}, + result => addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs), + ); + }, + ); + }, + }) as (...args: T) => Promise; } /** diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts index cd30d99ad09e..b542cbfda75a 100644 --- a/packages/core/src/utils/anthropic-ai/streaming.ts +++ b/packages/core/src/utils/anthropic-ai/streaming.ts @@ -15,7 +15,6 @@ import type { AnthropicAiStreamingEvent } from './types'; /** * State object used to accumulate information from a stream of Anthropic AI events. */ - interface StreamingState { /** Collected response text fragments (for output recording). */ responseTexts: string[]; @@ -183,7 +182,6 @@ function handleContentBlockStop(event: AnthropicAiStreamingEvent, state: Streami * @param recordOutputs - Whether to record outputs * @param span - The span to update */ - function processEvent( event: AnthropicAiStreamingEvent, state: StreamingState, @@ -209,12 +207,66 @@ function processEvent( handleContentBlockStop(event, state); } +/** + * Finalizes span attributes when stream processing completes + */ +function finalizeStreamSpan(state: StreamingState, span: Span, recordOutputs: boolean): void { + if (!span.isRecording()) { + return; + } + + // Set common response attributes if available + if (state.responseId) { + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: state.responseId, + }); + } + if (state.responseModel) { + span.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: state.responseModel, + }); + } + + setTokenUsageAttributes( + span, + state.promptTokens, + state.completionTokens, + state.cacheCreationInputTokens, + state.cacheReadInputTokens, + ); + + span.setAttributes({ + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + }); + + if (state.finishReasons.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(state.finishReasons), + }); + } + + if (recordOutputs && state.responseTexts.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: state.responseTexts.join(''), + }); + } + + // Set tool calls if any were captured + if (recordOutputs && state.toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(state.toolCalls), + }); + } + + span.end(); +} + /** * Instruments an async iterable stream of Anthropic events, updates the span with * streaming attributes and (optionally) the aggregated output text, and yields * each event from the input stream unchanged. */ -export async function* instrumentStream( +export async function* instrumentAsyncIterableStream( stream: AsyncIterable, span: Span, recordOutputs: boolean, @@ -284,3 +336,51 @@ export async function* instrumentStream( span.end(); } } + +/** + * Instruments a MessageStream by registering event handlers and preserving the original stream API. + */ +export function instrumentMessageStream void }>( + stream: R, + span: Span, + recordOutputs: boolean, +): R { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + promptTokens: undefined, + completionTokens: undefined, + cacheCreationInputTokens: undefined, + cacheReadInputTokens: undefined, + toolCalls: [], + activeToolBlocks: {}, + }; + + stream.on('streamEvent', (event: unknown) => { + processEvent(event as AnthropicAiStreamingEvent, state, recordOutputs, span); + }); + + // The event fired when a message is done being streamed by the API. Corresponds to the message_stop SSE event. + // @see https://github.com/anthropics/anthropic-sdk-typescript/blob/d3be31f5a4e6ebb4c0a2f65dbb8f381ae73a9166/helpers.md?plain=1#L42-L44 + stream.on('message', () => { + finalizeStreamSpan(state, span, recordOutputs); + }); + + stream.on('error', (error: unknown) => { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.stream_error', + }, + }); + + if (span.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'stream_error' }); + span.end(); + } + }); + + return stream; +} diff --git a/packages/core/src/utils/promisebuffer.ts b/packages/core/src/utils/promisebuffer.ts index 2830e8897129..f66077a76fd5 100644 --- a/packages/core/src/utils/promisebuffer.ts +++ b/packages/core/src/utils/promisebuffer.ts @@ -1,9 +1,9 @@ -import { rejectedSyncPromise, resolvedSyncPromise, SyncPromise } from './syncpromise'; +import { rejectedSyncPromise, resolvedSyncPromise } from './syncpromise'; export interface PromiseBuffer { // exposes the internal array so tests can assert on the state of it. // XXX: this really should not be public api. - $: Array>; + $: PromiseLike[]; add(taskProducer: () => PromiseLike): PromiseLike; drain(timeout?: number): PromiseLike; } @@ -14,11 +14,11 @@ export const SENTRY_BUFFER_FULL_ERROR = Symbol.for('SentryBufferFullError'); * Creates an new PromiseBuffer object with the specified limit * @param limit max number of promises that can be stored in the buffer */ -export function makePromiseBuffer(limit?: number): PromiseBuffer { - const buffer: Array> = []; +export function makePromiseBuffer(limit: number = 100): PromiseBuffer { + const buffer: Set> = new Set(); function isReady(): boolean { - return limit === undefined || buffer.length < limit; + return buffer.size < limit; } /** @@ -27,8 +27,8 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { * @param task Can be any PromiseLike * @returns Removed promise. */ - function remove(task: PromiseLike): PromiseLike { - return buffer.splice(buffer.indexOf(task), 1)[0] || Promise.resolve(undefined); + function remove(task: PromiseLike): void { + buffer.delete(task); } /** @@ -48,19 +48,11 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { // start the task and add its promise to the queue const task = taskProducer(); - if (buffer.indexOf(task) === -1) { - buffer.push(task); - } - void task - .then(() => remove(task)) - // Use `then(null, rejectionHandler)` rather than `catch(rejectionHandler)` so that we can use `PromiseLike` - // rather than `Promise`. `PromiseLike` doesn't have a `.catch` method, making its polyfill smaller. (ES5 didn't - // have promises, so TS has to polyfill when down-compiling.) - .then(null, () => - remove(task).then(null, () => { - // We have to add another catch here because `remove()` starts a new promise chain. - }), - ); + buffer.add(task); + void task.then( + () => remove(task), + () => remove(task), + ); return task; } @@ -74,34 +66,28 @@ export function makePromiseBuffer(limit?: number): PromiseBuffer { * `false` otherwise */ function drain(timeout?: number): PromiseLike { - return new SyncPromise((resolve, reject) => { - let counter = buffer.length; + if (!buffer.size) { + return resolvedSyncPromise(true); + } - if (!counter) { - return resolve(true); - } + // We want to resolve even if one of the promises rejects + const drainPromise = Promise.allSettled(Array.from(buffer)).then(() => true); + + if (!timeout) { + return drainPromise; + } - // wait for `timeout` ms and then resolve to `false` (if not cancelled first) - const capturedSetTimeout = setTimeout(() => { - if (timeout && timeout > 0) { - resolve(false); - } - }, timeout); + const promises = [drainPromise, new Promise(resolve => setTimeout(() => resolve(false), timeout))]; - // if all promises resolve in time, cancel the timer and resolve to `true` - buffer.forEach(item => { - void resolvedSyncPromise(item).then(() => { - if (!--counter) { - clearTimeout(capturedSetTimeout); - resolve(true); - } - }, reject); - }); - }); + // Promise.race will resolve to the first promise that resolves or rejects + // So if the drainPromise resolves, the timeout promise will be ignored + return Promise.race(promises); } return { - $: buffer, + get $(): PromiseLike[] { + return Array.from(buffer); + }, add, drain, }; diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 49339e72b6b1..dbb2966dc076 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -411,6 +411,384 @@ describe('_INTERNAL_captureLog', () => { beforeCaptureLogSpy.mockRestore(); }); + describe('replay integration with onlyIfSampled', () => { + it('includes replay ID for sampled sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with sampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + // Simulate behavior: return ID for sampled sessions + return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id'; + }), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with sampled replay' }, scope); + + // Verify getReplayId was called with onlyIfSampled=true + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'sampled-replay-id', + type: 'string', + }, + }); + }); + + it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with unsampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + // Simulate behavior: return undefined for unsampled when onlyIfSampled=true + return onlyIfSampled ? undefined : 'unsampled-replay-id'; + }), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with unsampled replay' }, scope); + + // Verify getReplayId was called with onlyIfSampled=true + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + // Should not include sentry.replay_id attribute + expect(logAttributes).toEqual({}); + }); + + it('includes replay ID for buffer mode sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode session + const mockReplayIntegration = { + getReplayId: vi.fn((_onlyIfSampled?: boolean) => { + // Buffer mode should still return ID even with onlyIfSampled=true + return 'buffer-replay-id'; + }), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, + }); + }); + + it('handles missing replay integration gracefully', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureLog({ level: 'info', message: 'test log without replay' }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + // Should not include sentry.replay_id attribute + expect(logAttributes).toEqual({}); + }); + + it('combines replay ID with other log attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'test-replay-id'), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with replay and other attributes', + attributes: { component: 'auth', action: 'login' }, + }, + scope, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + component: { + value: 'auth', + type: 'string', + }, + action: { + value: 'login', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'test-replay-id', + type: 'string', + }, + }); + }); + + it('does not set replay ID attribute when getReplayId returns null or undefined', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const testCases = [null, undefined]; + + testCases.forEach(returnValue => { + // Clear buffer for each test + _INTERNAL_getLogBuffer(client)?.splice(0); + + const mockReplayIntegration = { + getReplayId: vi.fn(() => returnValue), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: `test log with replay returning ${returnValue}` }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + }); + }); + + it('sets replay_is_buffering attribute when replay is in buffer mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffered replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, + }); + }); + + it('does not set replay_is_buffering attribute when replay is in session mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with session mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'session-replay-id'), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with session replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'session-replay-id', + type: 'string', + }, + }); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay is undefined mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with undefined mode (replay stopped/disabled) + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'stopped-replay-id'), + getRecordingMode: vi.fn(() => undefined), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with stopped replay' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + 'sentry.replay_id': { + value: 'stopped-replay-id', + type: 'string', + }, + }); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when no replay ID is available', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration that returns no replay ID but has buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => undefined), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog({ level: 'info', message: 'test log with buffer mode but no replay ID' }, scope); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + // getRecordingMode should not be called if there's no replay ID + expect(mockReplayIntegration.getRecordingMode).not.toHaveBeenCalled(); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + expect(logAttributes).not.toHaveProperty('sentry.internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay integration is missing', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureLog({ level: 'info', message: 'test log without replay integration' }, scope); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({}); + expect(logAttributes).not.toHaveProperty('sentry.replay_id'); + expect(logAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('combines replay_is_buffering with other replay attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with buffer replay and other attributes', + attributes: { component: 'auth', action: 'login' }, + }, + scope, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + component: { + value: 'auth', + type: 'string', + }, + action: { + value: 'login', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: true, + type: 'boolean', + }, + }); + }); + }); + describe('user functionality', () => { it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ diff --git a/packages/core/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts index 618de06322a0..b1316302e6f6 100644 --- a/packages/core/test/lib/utils/promisebuffer.test.ts +++ b/packages/core/test/lib/utils/promisebuffer.test.ts @@ -1,52 +1,163 @@ import { describe, expect, test, vi } from 'vitest'; import { makePromiseBuffer } from '../../../src/utils/promisebuffer'; -import { SyncPromise } from '../../../src/utils/syncpromise'; +import { rejectedSyncPromise, resolvedSyncPromise } from '../../../src/utils/syncpromise'; describe('PromiseBuffer', () => { describe('add()', () => { - test('no limit', () => { - const buffer = makePromiseBuffer(); - const p = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve))); - void buffer.add(p); - expect(buffer.$.length).toEqual(1); + test('enforces limit of promises', async () => { + const buffer = makePromiseBuffer(5); + + const producer1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const producer6 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + + void buffer.add(producer1); + void buffer.add(producer2); + void buffer.add(producer3); + void buffer.add(producer4); + void buffer.add(producer5); + await expect(buffer.add(producer6)).rejects.toThrowError(); + + expect(producer1).toHaveBeenCalledTimes(1); + expect(producer2).toHaveBeenCalledTimes(1); + expect(producer3).toHaveBeenCalledTimes(1); + expect(producer4).toHaveBeenCalledTimes(1); + expect(producer5).toHaveBeenCalledTimes(1); + expect(producer6).not.toHaveBeenCalled(); + + expect(buffer.$.length).toEqual(5); + + await buffer.drain(); + + expect(buffer.$.length).toEqual(0); + + expect(producer1).toHaveBeenCalledTimes(1); + expect(producer2).toHaveBeenCalledTimes(1); + expect(producer3).toHaveBeenCalledTimes(1); + expect(producer4).toHaveBeenCalledTimes(1); + expect(producer5).toHaveBeenCalledTimes(1); + expect(producer6).not.toHaveBeenCalled(); + }); + + test('sync promises', async () => { + const buffer = makePromiseBuffer(1); + let task1; + const producer1 = vi.fn(() => { + task1 = resolvedSyncPromise(); + return task1; + }); + const producer2 = vi.fn(() => resolvedSyncPromise()); + expect(buffer.add(producer1)).toEqual(task1); + const add2 = buffer.add(producer2); + + // This is immediately executed and removed again from the buffer + expect(buffer.$.length).toEqual(0); + + await expect(add2).resolves.toBeUndefined(); + + expect(producer1).toHaveBeenCalled(); + expect(producer2).toHaveBeenCalled(); }); - test('with limit', () => { + test('async promises', async () => { const buffer = makePromiseBuffer(1); let task1; const producer1 = vi.fn(() => { - task1 = new SyncPromise(resolve => setTimeout(resolve)); + task1 = new Promise(resolve => setTimeout(resolve, 1)); return task1; }); - const producer2 = vi.fn(() => new SyncPromise(resolve => setTimeout(resolve))); + const producer2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); expect(buffer.add(producer1)).toEqual(task1); - void expect(buffer.add(producer2)).rejects.toThrowError(); + const add2 = buffer.add(producer2); + expect(buffer.$.length).toEqual(1); + + await expect(add2).rejects.toThrowError(); + expect(producer1).toHaveBeenCalled(); expect(producer2).not.toHaveBeenCalled(); }); + + test('handles multiple equivalent promises', async () => { + const buffer = makePromiseBuffer(10); + + const promise = new Promise(resolve => setTimeout(resolve, 1)); + + const producer = vi.fn(() => promise); + const producer2 = vi.fn(() => promise); + + expect(buffer.add(producer)).toEqual(promise); + expect(buffer.add(producer2)).toEqual(promise); + + expect(buffer.$.length).toEqual(1); + + expect(producer).toHaveBeenCalled(); + expect(producer2).toHaveBeenCalled(); + + await buffer.drain(); + + expect(buffer.$.length).toEqual(0); + }); }); describe('drain()', () => { - test('without timeout', async () => { + test('drains all promises without timeout', async () => { const buffer = makePromiseBuffer(); - for (let i = 0; i < 5; i++) { - void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve))); - } + + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + + [p1, p2, p3, p4, p5].forEach(p => { + void buffer.add(p); + }); + expect(buffer.$.length).toEqual(5); const result = await buffer.drain(); expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + expect(p3).toHaveBeenCalled(); + expect(p4).toHaveBeenCalled(); + expect(p5).toHaveBeenCalled(); }); - test('with timeout', async () => { + test('drains all promises with timeout', async () => { const buffer = makePromiseBuffer(); - for (let i = 0; i < 5; i++) { - void buffer.add(() => new SyncPromise(resolve => setTimeout(resolve, 100))); - } + + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 2))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 4))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 6))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 8))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + + [p1, p2, p3, p4, p5].forEach(p => { + void buffer.add(p); + }); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + expect(p3).toHaveBeenCalled(); + expect(p4).toHaveBeenCalled(); + expect(p5).toHaveBeenCalled(); + expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(50); + const result = await buffer.drain(8); expect(result).toEqual(false); + // p5 is still in the buffer + expect(buffer.$.length).toEqual(1); + + // Now drain final item + const result2 = await buffer.drain(); + expect(result2).toEqual(true); + expect(buffer.$.length).toEqual(0); }); test('on empty buffer', async () => { @@ -56,11 +167,26 @@ describe('PromiseBuffer', () => { expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); }); + + test('resolves even if one of the promises rejects', async () => { + const buffer = makePromiseBuffer(); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p2 = vi.fn(() => new Promise((_, reject) => setTimeout(() => reject(new Error('whoops')), 1))); + void buffer.add(p1); + void buffer.add(p2); + + const result = await buffer.drain(); + expect(result).toEqual(true); + expect(buffer.$.length).toEqual(0); + + expect(p1).toHaveBeenCalled(); + expect(p2).toHaveBeenCalled(); + }); }); test('resolved promises should not show up in buffer length', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise(resolve => setTimeout(resolve)); + const producer = () => new Promise(resolve => setTimeout(resolve, 1)); const task = buffer.add(producer); expect(buffer.$.length).toEqual(1); await task; @@ -69,20 +195,18 @@ describe('PromiseBuffer', () => { test('rejected promises should not show up in buffer length', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise((_, reject) => setTimeout(reject)); + const error = new Error('whoops'); + const producer = () => new Promise((_, reject) => setTimeout(() => reject(error), 1)); const task = buffer.add(producer); expect(buffer.$.length).toEqual(1); - try { - await task; - } catch { - // no-empty - } + + await expect(task).rejects.toThrow(error); expect(buffer.$.length).toEqual(0); }); test('resolved task should give an access to the return value', async () => { const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise(resolve => setTimeout(() => resolve('test'))); + const producer = () => resolvedSyncPromise('test'); const task = buffer.add(producer); const result = await task; expect(result).toEqual('test'); @@ -91,7 +215,7 @@ describe('PromiseBuffer', () => { test('rejected task should give an access to the return value', async () => { expect.assertions(1); const buffer = makePromiseBuffer(); - const producer = () => new SyncPromise((_, reject) => setTimeout(() => reject(new Error('whoops')))); + const producer = () => rejectedSyncPromise(new Error('whoops')); const task = buffer.add(producer); try { await task; diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 76d98fda25e8..5c6372d6dec1 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,5 +1,4 @@ import { debug } from '@sentry/core'; -import * as chalk from 'chalk'; import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; import { generateValueInjectionRules } from './generateValueInjectionRules'; @@ -57,9 +56,7 @@ export function safelyAddTurbopackRule( // If the rule already exists, we don't want to mess with it. if (existingRules[matcher]) { debug.log( - `${chalk.cyan( - 'info', - )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, + `[@sentry/nextjs] - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, ); return existingRules; } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index b9d339c865db..c56faf7f1662 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -61,7 +61,7 @@ "@react-router/dev": "^7.5.2", "@react-router/node": "^7.5.2", "react": "^18.3.1", - "react-router": "^7.5.2", + "react-router": "^7.9.2", "vite": "^6.1.0" }, "peerDependencies": { diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx index d7db59be616f..eaf13eb16779 100644 --- a/packages/react-router/src/server/createSentryHandleRequest.tsx +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -1,7 +1,7 @@ import type { createReadableStreamFromReadable } from '@react-router/node'; import type { ReactNode } from 'react'; import React from 'react'; -import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router'; +import type { AppLoadContext, EntryContext, RouterContextProvider, ServerRouter } from 'react-router'; import { PassThrough } from 'stream'; import { getMetaTagTransformer } from './getMetaTagTransformer'; import { wrapSentryHandleRequest } from './wrapSentryHandleRequest'; @@ -67,7 +67,7 @@ export function createSentryHandleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) => Promise { const { streamTimeout = 10000, @@ -82,7 +82,7 @@ export function createSentryHandleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - _loadContext: AppLoadContext, + _loadContext: AppLoadContext | RouterContextProvider, ): Promise { return new Promise((resolve, reject) => { let shellRendered = false; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 161b40f9e241..5651ad208a9d 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,14 +8,14 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; -import type { AppLoadContext, EntryContext } from 'react-router'; +import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; type OriginalHandleRequest = ( request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) => Promise; /** @@ -30,7 +30,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppLoadContext | RouterContextProvider, ) { const parameterizedPath = routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a464875a8575..10db32231195 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -567,6 +567,13 @@ export function handleNavigation(opts: { return; } + // Avoid starting a navigation span on initial load when a pageload root span is active. + // This commonly happens when lazy routes resolve during the first render and React Router emits a POP. + const activeRootSpan = getActiveRootSpan(); + if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload' && navigationType === 'POP') { + return; + } + if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { const [name, source] = resolveRouteNameAndSource( location, diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 795562c7f6ce..41c5966b88c5 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -280,13 +280,16 @@ export class Replay implements Integration { /** * Get the current session ID. + * + * @param onlyIfSampled - If true, will only return the session ID if the session is sampled. + * */ - public getReplayId(): string | undefined { + public getReplayId(onlyIfSampled?: boolean): string | undefined { if (!this._replay?.isEnabled()) { return; } - return this._replay.getSessionId(); + return this._replay.getSessionId(onlyIfSampled); } /** diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index ae3aa9589cab..61676f790b4d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -719,8 +719,15 @@ export class ReplayContainer implements ReplayContainerInterface { this._debouncedFlush.cancel(); } - /** Get the current session (=replay) ID */ - public getSessionId(): string | undefined { + /** Get the current session (=replay) ID + * + * @param onlyIfSampled - If true, will only return the session ID if the session is sampled. + */ + public getSessionId(onlyIfSampled?: boolean): string | undefined { + if (onlyIfSampled && this.session?.sampled === false) { + return undefined; + } + return this.session?.id; } diff --git a/packages/replay-internal/test/integration/getReplayId.test.ts b/packages/replay-internal/test/integration/getReplayId.test.ts index c2f4e765520a..28b8f56ccaab 100644 --- a/packages/replay-internal/test/integration/getReplayId.test.ts +++ b/packages/replay-internal/test/integration/getReplayId.test.ts @@ -30,4 +30,113 @@ describe('Integration | getReplayId', () => { expect(integration.getReplayId()).toBeUndefined(); }); + + describe('onlyIfSampled parameter', () => { + it('returns replay ID for session mode when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + }); + + // Should be in session mode by default with sessionSampleRate: 1.0 + expect(replay.recordingMode).toBe('session'); + expect(replay.session?.sampled).toBe('session'); + + expect(integration.getReplayId(true)).toBeDefined(); + expect(integration.getReplayId(true)).toEqual(replay.session?.id); + }); + + it('returns replay ID for buffer mode when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + }, + }); + + // Force buffer mode by manually setting session + if (replay.session) { + replay.session.sampled = 'buffer'; + replay.recordingMode = 'buffer'; + } + + expect(integration.getReplayId(true)).toBeDefined(); + expect(integration.getReplayId(true)).toEqual(replay.session?.id); + }); + + it('returns undefined for unsampled sessions when onlyIfSampled=true', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, // Start enabled to create session + replaysOnErrorSampleRate: 0.0, + }, + }); + + // Manually create an unsampled session by overriding the existing one + replay.session = { + id: 'test-unsampled-session', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + + expect(integration.getReplayId(true)).toBeUndefined(); + // But default behavior should still return the ID + expect(integration.getReplayId()).toBe('test-unsampled-session'); + expect(integration.getReplayId(false)).toBe('test-unsampled-session'); + }); + + it('maintains backward compatibility when onlyIfSampled is not provided', async () => { + const { integration, replay } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, // Start with a session to ensure initialization + replaysOnErrorSampleRate: 0.0, + }, + }); + + const testCases: Array<{ sampled: 'session' | 'buffer' | false; sessionId: string }> = [ + { sampled: 'session', sessionId: 'session-test-id' }, + { sampled: 'buffer', sessionId: 'buffer-test-id' }, + { sampled: false, sessionId: 'unsampled-test-id' }, + ]; + + for (const { sampled, sessionId } of testCases) { + replay.session = { + id: sessionId, + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled, + }; + + // Default behavior should always return the ID + expect(integration.getReplayId()).toBe(sessionId); + } + }); + + it('returns undefined when replay is disabled regardless of onlyIfSampled', async () => { + const { integration } = await mockSdk({ + replayOptions: { + stickySession: true, + }, + }); + + integration.stop(); + + expect(integration.getReplayId()).toBeUndefined(); + expect(integration.getReplayId(true)).toBeUndefined(); + expect(integration.getReplayId(false)).toBeUndefined(); + }); + }); }); diff --git a/packages/replay-internal/test/unit/getSessionId.test.ts b/packages/replay-internal/test/unit/getSessionId.test.ts new file mode 100644 index 000000000000..c9ccde7d07d0 --- /dev/null +++ b/packages/replay-internal/test/unit/getSessionId.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import type { Session } from '../../src/types'; +import { setupReplayContainer } from '../utils/setupReplayContainer'; + +describe('Unit | ReplayContainer | getSessionId', () => { + it('returns session ID when session exists', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'session', + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe('test-session-id'); + }); + + it('returns undefined when no session exists', () => { + const replay = setupReplayContainer(); + replay.session = undefined; + + expect(replay.getSessionId()).toBeUndefined(); + }); + + describe('onlyIfSampled parameter', () => { + it('returns session ID for sampled=session when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'session', + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBe('test-session-id'); + }); + + it('returns session ID for sampled=buffer when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: 'buffer', + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBe('test-session-id'); + }); + + it('returns undefined for sampled=false when onlyIfSampled=true', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + replay.session = mockSession; + + expect(replay.getSessionId(true)).toBeUndefined(); + }); + + it('returns session ID for sampled=false when onlyIfSampled=false (default)', () => { + const replay = setupReplayContainer(); + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled: false, + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe('test-session-id'); + expect(replay.getSessionId(false)).toBe('test-session-id'); + }); + + it('returns undefined when no session exists regardless of onlyIfSampled', () => { + const replay = setupReplayContainer(); + replay.session = undefined; + + expect(replay.getSessionId(true)).toBeUndefined(); + expect(replay.getSessionId(false)).toBeUndefined(); + }); + }); + + describe('backward compatibility', () => { + it('maintains existing behavior when onlyIfSampled is not provided', () => { + const replay = setupReplayContainer(); + + // Test with different sampling states + const testCases: Array<{ sampled: Session['sampled']; expected: string | undefined }> = [ + { sampled: 'session', expected: 'test-session-id' }, + { sampled: 'buffer', expected: 'test-session-id' }, + { sampled: false, expected: 'test-session-id' }, + ]; + + testCases.forEach(({ sampled, expected }) => { + const mockSession: Session = { + id: 'test-session-id', + started: Date.now(), + lastActivity: Date.now(), + segmentId: 0, + sampled, + }; + replay.session = mockSession; + + expect(replay.getSessionId()).toBe(expected); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 299b1415d89a..6e477bf0a40b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9448,7 +9448,7 @@ postcss "^8.4.47" source-map-js "^1.2.0" -"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4": +"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4": version "3.5.21" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz#e48189ef3ffe334c864c2625389ebe3bb4fa41eb" integrity sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ== @@ -14248,9 +14248,6 @@ detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^7.0.1" detective-stylus@^4.0.0: version "4.0.0" @@ -14285,14 +14282,6 @@ detective-vue2@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a" integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA== - dependencies: - "@dependents/detective-less" "^5.0.1" - "@vue/compiler-sfc" "^3.5.13" - detective-es6 "^5.0.1" - detective-sass "^6.0.1" - detective-scss "^5.0.1" - detective-stylus "^5.0.1" - detective-typescript "^14.0.0" deterministic-object-hash@^1.3.1: version "1.3.1" @@ -16844,9 +16833,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" fflate@0.8.2, fflate@^0.8.2: version "0.8.2" @@ -23099,11 +23085,6 @@ node-cron@^3.0.3: dependencies: uuid "8.3.2" -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37" @@ -26164,10 +26145,10 @@ react-router@6.28.1: dependencies: "@remix-run/router" "1.21.0" -react-router@^7.5.2: - version "7.6.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.6.2.tgz#9f48b343bead7d0a94e28342fc4f9ae29131520e" - integrity sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w== +react-router@^7.9.2: + version "7.9.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6" + integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" @@ -31227,7 +31208,7 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1: +web-streams-polyfill@^3.1.1: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==