diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 44057f39da11..41e4e5c2a3b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -954,7 +954,7 @@ jobs: - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 - run: pnpm ${{ matrix.build-command || 'test:build' }} + run: ${{ matrix.build-command || 'pnpm test:build' }} - name: Install Playwright uses: ./.github/actions/install-playwright @@ -965,7 +965,7 @@ jobs: - name: Run E2E test working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 - run: pnpm test:assert + run: ${{ matrix.assert-command || 'pnpm test:assert' }} - name: Upload Playwright Traces uses: actions/upload-artifact@v4 @@ -1075,7 +1075,7 @@ jobs: - name: Build E2E app working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 - run: pnpm ${{ matrix.build-command || 'test:build' }} + run: ${{ matrix.build-command || 'pnpm test:build' }} - name: Install Playwright uses: ./.github/actions/install-playwright @@ -1086,7 +1086,7 @@ jobs: - name: Run E2E test working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 - run: pnpm ${{ matrix.assert-command || 'test:assert' }} + run: ${{ matrix.assert-command || 'pnpm test:assert' }} - name: Pre-process E2E Test Dumps if: failure() diff --git a/.size-limit.js b/.size-limit.js index 490195900900..3ea2bdf80703 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '95 KB', + limit: '96 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '149 KB', + limit: '150 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 6529fcfe59cb..2a88fc83987f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.7.0 + +### Important Changes + +- **feat(cloudflare): Add `instrumentPrototypeMethods` option to instrument RPC methods for DurableObjects ([#17424](https://github.com/getsentry/sentry-javascript/pull/17424))** + +By default, `Sentry.instrumentDurableObjectWithSentry` will not wrap any RPC methods on the prototype. To enable wrapping for RPC methods, set `instrumentPrototypeMethods` to `true` or, if performance is a concern, a list of only the methods you want to instrument: + +```js +class MyDurableObjectBase extends DurableObject { + method1() { + // ... + } + + method2() { + // ... + } + + method3() { + // ... + } +} +// Export your named class as defined in your wrangler config +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: "https://ac49b7af3017c458bd12dab9b3328bfc@o4508482761982032.ingest.de.sentry.io/4508482780987481", + tracesSampleRate: 1.0, + instrumentPrototypeMethods: ['method1', 'method3'], + }), + MyDurableObjectBase, +); +``` + +## Other Changes + +- feat(aws): Add support for streaming handlers ([#17463](https://github.com/getsentry/sentry-javascript/pull/17463)) +- feat(core): Stream responses Anthropic AI ([#17460](https://github.com/getsentry/sentry-javascript/pull/17460)) +- feat(deps): bump @opentelemetry/instrumentation-aws-sdk from 0.56.0 to 0.57.0 ([#17455](https://github.com/getsentry/sentry-javascript/pull/17455)) +- feat(deps): bump @opentelemetry/instrumentation-dataloader from 0.21.0 to 0.21.1 ([#17457](https://github.com/getsentry/sentry-javascript/pull/17457)) +- feat(deps): bump @opentelemetry/instrumentation-kafkajs from 0.12.0 to 0.13.0 ([#17469](https://github.com/getsentry/sentry-javascript/pull/17469)) +- feat(deps): bump @opentelemetry/instrumentation-mysql2 from 0.49.0 to 0.50.0 ([#17459](https://github.com/getsentry/sentry-javascript/pull/17459)) +- feat(deps): bump @prisma/instrumentation from 6.13.0 to 6.14.0 ([#17466](https://github.com/getsentry/sentry-javascript/pull/17466)) +- feat(deps): bump @sentry/cli from 2.51.1 to 2.52.0 ([#17458](https://github.com/getsentry/sentry-javascript/pull/17458)) +- feat(deps): bump @sentry/rollup-plugin from 4.1.0 to 4.1.1 ([#17456](https://github.com/getsentry/sentry-javascript/pull/17456)) +- feat(deps): bump @sentry/webpack-plugin from 4.1.0 to 4.1.1 ([#17467](https://github.com/getsentry/sentry-javascript/pull/17467)) +- feat(replay): Add option to skip `requestAnimationFrame` for canvas snapshots ([#17380](https://github.com/getsentry/sentry-javascript/pull/17380)) + +
+ Internal Changes + +- test(aws): Run E2E tests in all supported Node versions ([#17446](https://github.com/getsentry/sentry-javascript/pull/17446)) + +
+ ## 10.6.0 ### Important Changes @@ -41,8 +95,6 @@ The Sentry Nuxt SDK is now considered stable and no longer in beta! -Work in this release was contributed by @Karibash. Thank you for your contribution! - ## 10.5.0 - feat(core): better cause data extraction ([#17375](https://github.com/getsentry/sentry-javascript/pull/17375)) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts new file mode 100644 index 000000000000..74ce2cbbdac4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class TestDurableObjectBase extends DurableObject { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + async sayHello(name: string): Promise { + return `Hello, ${name}`; + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + instrumentPrototypeMethods: true, + }), + TestDurableObjectBase, +); + +export default { + async fetch(request: Request, env: Env): Promise { + const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test'); + const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase; + + if (request.url.includes('hello')) { + const greeting = await stub.sayHello('world'); + return new Response(greeting); + } + + return new Response('Usual response'); + }, +}; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts new file mode 100644 index 000000000000..cfb6841004a9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('traces a durable object method', async () => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare_durableobjects', + }), + origin: 'auto.faas.cloudflare_durableobjects', + }), + }), + transaction: 'sayHello', + }), + ); + }) + .start(); + await runner.makeRequest('get', '/hello'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc new file mode 100644 index 000000000000..8f27c3af7a22 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + "name": "worker-name", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1" + } + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT" + } + ] + }, + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552" + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json index 83437b2f9fbf..bf8085c4e892 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json @@ -23,5 +23,19 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "NODE_VERSION=20 ./pull-sam-image.sh && pnpm test:build", + "assert-command": "NODE_VERSION=20 pnpm test:assert", + "label": "aws-serverless (Node 20)" + }, + { + "build-command": "NODE_VERSION=18 ./pull-sam-image.sh && pnpm test:build", + "assert-command": "NODE_VERSION=18 pnpm test:assert", + "label": "aws-serverless (Node 18)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts index 174593c307df..e47333c66e76 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts @@ -1,3 +1,5 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -export default getPlaywrightConfig(); +export default getPlaywrightConfig(undefined, { + timeout: 60 * 1000 * 3, // 3 minutes +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh b/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh new file mode 100755 index 000000000000..0c27c1eac24d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/pull-sam-image.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Script to pull the correct SAM docker image based on the NODE_VERSION environment variable. + +set -e + +if [[ -z "$NODE_VERSION" ]]; then + echo "Error: NODE_VERSION not set" + exit 1 +fi + +echo "Pulling SAM Node $NODE_VERSION docker image..." +docker pull "public.ecr.aws/sam/build-nodejs${NODE_VERSION}.x:latest" + +echo "Successfully pulled SAM Node $NODE_VERSION docker image" diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Streaming/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Streaming/index.mjs new file mode 100644 index 000000000000..d46b9df502b9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Streaming/index.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/aws-serverless'; + +export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => { + Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + responseStream.write('Hello, world!'); + responseStream.end(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index 825c9648ee66..d23feae60811 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -12,8 +12,8 @@ const LAMBDA_FUNCTIONS_WITH_LAYER_DIR = './src/lambda-functions-layer'; const LAMBDA_FUNCTIONS_WITH_NPM_DIR = './src/lambda-functions-npm'; const LAMBDA_FUNCTION_TIMEOUT = 10; const LAYER_DIR = './node_modules/@sentry/aws-serverless/'; +const DEFAULT_NODE_VERSION = '22'; export const SAM_PORT = 3001; -const NODE_RUNTIME = `nodejs${process.version.split('.').at(0)?.replace('v', '')}.x`; export class LocalLambdaStack extends Stack { sentryLayer: CfnResource; @@ -73,14 +73,12 @@ export class LocalLambdaStack extends Stack { execFileSync('npm', ['install', '--prefix', path.join(functionsDir, lambdaDir)], { stdio: 'inherit' }); } - const isEsm = fs.existsSync(path.join(functionsDir, lambdaDir, 'index.mjs')); - new CfnResource(this, functionName, { type: 'AWS::Serverless::Function', properties: { CodeUri: path.join(functionsDir, lambdaDir), Handler: 'index.handler', - Runtime: NODE_RUNTIME, + Runtime: `nodejs${process.env.NODE_VERSION ?? DEFAULT_NODE_VERSION}.x`, Timeout: LAMBDA_FUNCTION_TIMEOUT, Layers: addLayer ? [{ Ref: this.sentryLayer.logicalId }] : undefined, Environment: { @@ -88,7 +86,7 @@ export class LocalLambdaStack extends Stack { SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, - NODE_OPTIONS: `--${isEsm ? 'import' : 'require'}=@sentry/aws-serverless/awslambda-auto`, + NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index 707f808218fb..561086fcdb9d 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -29,23 +29,27 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien const debugLog = tmp.fileSync({ prefix: 'sentry_aws_lambda_tests_sam_debug', postfix: '.log' }); console.log(`[test_environment fixture] Writing SAM debug log to: ${debugLog.name}`); - const process = spawn( - 'sam', - [ - 'local', - 'start-lambda', - '--debug', - '--template', - SAM_TEMPLATE_FILE, - '--warm-containers', - 'EAGER', - '--docker-network', - DOCKER_NETWORK_NAME, - ], - { - stdio: ['ignore', debugLog.fd, debugLog.fd], - }, - ); + const args = [ + 'local', + 'start-lambda', + '--debug', + '--template', + SAM_TEMPLATE_FILE, + '--warm-containers', + 'EAGER', + '--docker-network', + DOCKER_NETWORK_NAME, + ]; + + if (process.env.NODE_VERSION) { + args.push('--invoke-image', `public.ecr.aws/sam/build-nodejs${process.env.NODE_VERSION}.x:latest`); + } + + console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`); + + const samProcess = spawn('sam', args, { + stdio: ['ignore', debugLog.fd, debugLog.fd], + }); try { await LocalLambdaStack.waitForStack(); @@ -54,12 +58,12 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien } finally { console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); - process.kill('SIGTERM'); + samProcess.kill('SIGTERM'); await new Promise(resolve => { - process.once('exit', resolve); + samProcess.once('exit', resolve); setTimeout(() => { - if (!process.killed) { - process.kill('SIGKILL'); + if (!samProcess.killed) { + samProcess.kill('SIGKILL'); } resolve(void 0); }, 5000); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index c20659835ee8..4d68efb66b08 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -194,4 +194,52 @@ test.describe('Lambda layer', () => { }), ); }); + + test('streaming handlers work', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'LayerStreaming'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerStreaming', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toEqual('LayerStreaming'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerStreaming', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(1); + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index f329cea238e8..54786fd221cb 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -11,7 +11,7 @@ * Learn more at https://developers.cloudflare.com/workers/ */ import * as Sentry from '@sentry/cloudflare'; -import { DurableObject } from "cloudflare:workers"; +import { DurableObject } from 'cloudflare:workers'; class MyDurableObjectBase extends DurableObject { private throwOnExit = new WeakMap(); @@ -44,7 +44,7 @@ class MyDurableObjectBase extends DurableObject { } webSocketClose(ws: WebSocket): void | Promise { - if (this.throwOnExit.has(ws)) { + if (this.throwOnExit.has(ws)) { const error = this.throwOnExit.get(ws)!; this.throwOnExit.delete(ws); throw error; @@ -53,36 +53,37 @@ class MyDurableObjectBase extends DurableObject { } export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', // dynamic sampling bias to keep transactions - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, - transportOptions: { - // We are doing a lot of events at once in this test - bufferSize: 1000, - }, - }), - MyDurableObjectBase, + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + instrumentPrototypeMethods: true, + }), + MyDurableObjectBase, ); export default Sentry.withSentry( - (env: Env) => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', // dynamic sampling bias to keep transactions - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, - transportOptions: { - // We are doing a lot of events at once in this test - bufferSize: 1000, - }, - }), - { - async fetch(request, env) { - const url = new URL(request.url); - switch (url.pathname) { + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + switch (url.pathname) { case '/rpc/throwException': { const id = env.MY_DURABLE_OBJECT.idFromName('foo'); @@ -105,7 +106,7 @@ export default Sentry.withSentry( return stub.fetch(new Request(url, request)); } } - return new Response('Hello World!'); - }, - } satisfies ExportedHandler, + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/package.json b/dev-packages/e2e-tests/test-applications/create-next-app/package.json index 5eeac69d422e..d87aed0de03e 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-next-app/package.json @@ -31,7 +31,7 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-13", + "build-command": "pnpm test:build-13", "label": "create-next-app (next@13)" } ] diff --git a/dev-packages/e2e-tests/test-applications/create-react-app/package.json b/dev-packages/e2e-tests/test-applications/create-react-app/package.json index 981123625b96..0c2bc337d396 100644 --- a/dev-packages/e2e-tests/test-applications/create-react-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-react-app/package.json @@ -47,7 +47,7 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-ts3.8", + "build-command": "pnpm test:build-ts3.8", "label": "create-react-app (TS 3.8)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json index ebb4d632127d..32430917ddc0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json @@ -32,11 +32,11 @@ "sentryTest": { "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-13 (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-13 (latest)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json index acffda8eeed5..822d321b2028 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/package.json @@ -33,11 +33,11 @@ "sentryTest": { "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-14 (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-14 (latest)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 063f36d3b164..052dd62697a1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -37,15 +37,15 @@ "sentryTest": { "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-15 (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-15 (latest)" }, { - "build-command": "test:build-turbo", + "build-command": "pnpm test:build-turbo", "assert-command": "pnpm test:prod && pnpm test:dev-turbo", "label": "nextjs-15 (turbo)" } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index e25c4ec84053..e37e4a0c8ca3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -36,17 +36,17 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-13", + "build-command": "pnpm test:build-13", "label": "nextjs-app-dir (next@13)" } ], "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-app-dir (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-app-dir (latest)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 03a7efd1d521..233ceb802536 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -36,17 +36,17 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-13", + "build-command": "pnpm test:build-13", "label": "nextjs-pages-dir (next@13)" } ], "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-pages-dir (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-pages-dir (latest)" } ] 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 46aba39d865c..1cfbd8eb6628 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -34,11 +34,11 @@ "optional": true, "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nextjs-turbo (canary)" }, { - "build-command": "test:build-latest", + "build-command": "pnpm test:build-latest", "label": "nextjs-turbo (latest)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index 536043eec631..b38943d6e3eb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -27,7 +27,7 @@ "sentryTest": { "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nuxt-3 (canary)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index f73e7ff99200..a68c4c823738 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -30,7 +30,7 @@ "sentryTest": { "optionalVariants": [ { - "build-command": "test:build-canary", + "build-command": "pnpm test:build-canary", "label": "nuxt-4 (canary)" } ] diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/package.json b/dev-packages/e2e-tests/test-applications/react-router-6/package.json index 6de1a0f9b76a..bcab38dad727 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-6/package.json @@ -55,7 +55,7 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-ts3.8", + "build-command": "pnpm test:build-ts3.8", "label": "react-router-6 (TS 3.8)" } ] diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json index 41ff42c14f09..2c6bf1654cae 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json @@ -52,7 +52,7 @@ "sentryTest": { "variants": [ { - "build-command": "test:build-ts3.8", + "build-command": "pnpm test:build-ts3.8", "label": "react-router-7-spa (TS 3.8)" } ] diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs new file mode 100644 index 000000000000..da70a2b12467 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs @@ -0,0 +1,105 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +function createMockStreamEvents(model = 'claude-3-haiku-20240307') { + async function* generator() { + // Provide message metadata early so the span can capture id/model/usage input tokens + yield { + type: 'content_block_start', + message: { + id: 'msg_stream_1', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + }, + }, + }; + + // Streamed text chunks + yield { type: 'content_block_delta', delta: { text: 'Hello ' } }; + yield { type: 'content_block_delta', delta: { text: 'from ' } }; + yield { type: 'content_block_delta', delta: { text: 'stream!' } }; + + // Final usage totals for output tokens + yield { type: 'message_delta', usage: { output_tokens: 15 } }; + } + + return generator(); +} + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + this.messages = { + create: this._messagesCreate.bind(this), + stream: this._messagesStream.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + if (params?.stream === true) { + return createMockStreamEvents(params.model); + } + // Fallback non-streaming behavior (not used in this scenario) + return { + id: 'msg_mock123', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } + + async _messagesStream(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + return createMockStreamEvents(params?.model); + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient); + + // 1) Streaming via stream: true param on messages.create + const stream1 = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream this please' }], + stream: true, + }); + for await (const _ of stream1) { + void _; + } + + // 2) Streaming via messages.stream API + const stream2 = await client.messages.stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream this too' }], + }); + for await (const _ of stream2) { + void _; + } + }); +} + +run(); + + 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 4b7d19b7cc58..9b8c7219000d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -218,4 +218,79 @@ describe('Anthropic integration', () => { .completed(); }); }); + + const EXPECTED_STREAM_SPANS_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // messages.create with stream: true + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.system': 'anthropic', + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + 'gen_ai.response.streaming': true, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_stream_1', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.finish_reasons': '["end_turn"]', + }), + }), + // messages.stream + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.system': 'anthropic', + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.streaming': true, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_stream_1', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + }), + ]), + }; + + const EXPECTED_STREAM_SPANS_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.response.streaming': true, + // streamed text concatenated + 'gen_ai.response.text': 'Hello from stream!', + }), + }), + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'Hello from stream!', + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument.mjs', (createRunner, test) => { + test('streams produce spans with token usage and metadata (PII false)', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_STREAM_SPANS_PII_FALSE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('streams record response text when PII true', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_STREAM_SPANS_PII_TRUE }).start().completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts index 1a653dc6496a..5bfb6ff72a39 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts @@ -32,17 +32,11 @@ describe('dataloader auto-instrumentation', () => { ]), }; - createEsmAndCjsTests( - __dirname, - 'scenario.mjs', - 'instrument.mjs', - (createRunner, test) => { - test('should auto-instrument `dataloader` package.', async () => { - const runner = createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); - }, - { failsOnEsm: true }, - ); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should auto-instrument `dataloader` package.', async () => { + const runner = createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + }); }); diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 3c1d7cf4d3f3..5589a412c637 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -67,7 +67,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.203.0", - "@opentelemetry/instrumentation-aws-sdk": "0.56.0", + "@opentelemetry/instrumentation-aws-sdk": "0.57.0", "@opentelemetry/semantic-conventions": "^1.36.0", "@sentry/core": "10.6.0", "@sentry/node": "10.6.0", diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts index 39b63551b2aa..1e51605c2afa 100644 --- a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts +++ b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts @@ -3,6 +3,7 @@ // - Added Sentry `wrapHandler` around the OTel patch handler. // - Cancel init when handler string is invalid (TS) // - Hardcoded package version and name +// - Added support for streaming handlers /* eslint-disable */ /* * Copyright The OpenTelemetry Authors @@ -50,7 +51,7 @@ import { SEMRESATTRS_CLOUD_ACCOUNT_ID, SEMRESATTRS_FAAS_ID, } from '@opentelemetry/semantic-conventions'; -import type { APIGatewayProxyEventHeaders, Callback, Context, Handler } from 'aws-lambda'; +import type { APIGatewayProxyEventHeaders, Callback, Context, Handler, StreamifyHandler } from 'aws-lambda'; import * as fs from 'fs'; import * as path from 'path'; import type { LambdaModule } from './internal-types'; @@ -73,6 +74,9 @@ const headerGetter: TextMapGetter = { }; export const lambdaMaxInitInMilliseconds = 10_000; +const AWS_HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); +const AWS_HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); +const AWS_HANDLER_STREAMING_RESPONSE = 'response'; /** * @@ -101,6 +105,21 @@ export class AwsLambdaInstrumentation extends InstrumentationBase { + handler[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + if (typeof options?.highWaterMark === 'number') { + handler[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = parseInt(options.highWaterMark); + } + return handler; + }, + }; + } + const handler = path.basename(handlerDef); const moduleRoot = handlerDef.substring(0, handlerDef.length - handler.length); @@ -187,16 +206,33 @@ export class AwsLambdaInstrumentation extends InstrumentationBase { - return wrapHandler(this._getPatchHandler(original, handlerLoadStartTime)); + private _getHandler(handlerLoadStartTime: number) { + return (original: T): T => { + if (this._isStreamingHandler(original)) { + const patchedHandler = this._getPatchHandler(original, handlerLoadStartTime); + + // Streaming handlers have special symbols that we need to copy over to the patched handler. + (patchedHandler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] = ( + original as unknown as Record + )[AWS_HANDLER_STREAMING_SYMBOL]; + (patchedHandler as unknown as Record)[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = ( + original as unknown as Record + )[AWS_HANDLER_HIGHWATERMARK_SYMBOL]; + + return wrapHandler(patchedHandler) as T; + } + + return wrapHandler(this._getPatchHandler(original, handlerLoadStartTime)) as T; }; } + private _getPatchHandler(original: Handler, lambdaStartTime: number): Handler; + private _getPatchHandler(original: StreamifyHandler, lambdaStartTime: number): StreamifyHandler; + /** * */ - private _getPatchHandler(original: Handler, lambdaStartTime: number) { + private _getPatchHandler(original: Handler | StreamifyHandler, lambdaStartTime: number): Handler | StreamifyHandler { diag.debug('patch handler function'); const plugin = this; @@ -229,6 +265,36 @@ export class AwsLambdaInstrumentation extends InstrumentationBase[1], + context: Context, + ) { + _onRequest(); + const parent = plugin._determineParent(event, context); + const span = plugin._createSpanForRequest(event, context, requestIsColdStart, parent); + plugin._applyRequestHook(span, event, context); + + return otelContext.with(trace.setSpan(parent, span), () => { + const maybePromise = safeExecuteInTheMiddle( + () => original.apply(this, [event, responseStream, context]), + error => { + if (error != null) { + // Exception thrown synchronously before resolving promise. + plugin._applyResponseHook(span, error); + plugin._endSpan(span, error, () => {}); + } + }, + ) as Promise<{}> | undefined; + + return plugin._handlePromiseResult(span, maybePromise); + }); + }; + } + return function patchedHandler( this: never, // The event can be a user type, it truly is any. @@ -239,39 +305,10 @@ export class AwsLambdaInstrumentation extends InstrumentationBase requestHook(span, { event, context }), - e => { - if (e) diag.error('aws-lambda instrumentation: requestHook error', e); - }, - true, - ); - } + const span = plugin._createSpanForRequest(event, context, requestIsColdStart, parent); + plugin._applyRequestHook(span, event, context); return otelContext.with(trace.setSpan(parent, span), () => { // Lambda seems to pass a callback even if handler is of Promise form, so we wrap all the time before calling @@ -289,23 +326,80 @@ export class AwsLambdaInstrumentation extends InstrumentationBase | undefined; - if (typeof maybePromise?.then === 'function') { - return maybePromise.then( - value => { - plugin._applyResponseHook(span, null, value); - return new Promise(resolve => plugin._endSpan(span, undefined, () => resolve(value))); - }, - (err: Error | string) => { - plugin._applyResponseHook(span, err); - return new Promise((resolve, reject) => plugin._endSpan(span, err, () => reject(err))); - }, - ); - } - return maybePromise; + + return plugin._handlePromiseResult(span, maybePromise); }); }; } + private _createSpanForRequest(event: any, context: Context, requestIsColdStart: boolean, parent: OtelContext): Span { + const name = context.functionName; + return this.tracer.startSpan( + name, + { + kind: SpanKind.SERVER, + attributes: { + [SEMATTRS_FAAS_EXECUTION]: context.awsRequestId, + [SEMRESATTRS_FAAS_ID]: context.invokedFunctionArn, + [SEMRESATTRS_CLOUD_ACCOUNT_ID]: AwsLambdaInstrumentation._extractAccountId(context.invokedFunctionArn), + [ATTR_FAAS_COLDSTART]: requestIsColdStart, + ...AwsLambdaInstrumentation._extractOtherEventFields(event), + }, + }, + parent, + ); + } + + private _applyRequestHook(span: Span, event: any, context: Context): void { + const { requestHook } = this.getConfig(); + if (requestHook) { + safeExecuteInTheMiddle( + () => requestHook(span, { event, context }), + e => { + if (e) diag.error('aws-lambda instrumentation: requestHook error', e); + }, + true, + ); + } + } + + private _handlePromiseResult(span: Span, maybePromise: Promise<{}> | undefined): Promise<{}> | undefined { + if (typeof maybePromise?.then === 'function') { + return maybePromise.then( + value => { + this._applyResponseHook(span, null, value); + return new Promise(resolve => this._endSpan(span, undefined, () => resolve(value))); + }, + (err: Error | string) => { + this._applyResponseHook(span, err); + return new Promise((resolve, reject) => this._endSpan(span, err, () => reject(err))); + }, + ); + } + + // Handle synchronous return values by ending the span and applying response hook + this._applyResponseHook(span, null, maybePromise); + this._endSpan(span, undefined, () => {}); + return maybePromise; + } + + private _determineParent(event: any, context: Context): OtelContext { + const config = this.getConfig(); + return AwsLambdaInstrumentation._determineParent( + event, + context, + config.eventContextExtractor || AwsLambdaInstrumentation._defaultEventContextExtractor, + ); + } + + private _isStreamingHandler( + handler: Handler | StreamifyHandler, + ): handler is StreamifyHandler { + return ( + (handler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] === AWS_HANDLER_STREAMING_RESPONSE + ); + } + /** * */ diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index e6f7d5f3a4f0..fd647d3a3376 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,7 +1,7 @@ import type { Scope } from '@sentry/core'; import { consoleSandbox, debug } from '@sentry/core'; import { captureException, captureMessage, flush, getCurrentScope, withScope } from '@sentry/node'; -import type { Context, Handler } from 'aws-lambda'; +import type { Context, Handler, StreamifyHandler } from 'aws-lambda'; import { performance } from 'perf_hooks'; import { types } from 'util'; import { DEBUG_BUILD } from './debug-build'; @@ -108,6 +108,51 @@ function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTi }); } +function setupTimeoutWatning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined { + // In seconds. You cannot go any more granular than this in AWS Lambda. + const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000); + const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60); + const configuredTimeoutSeconds = configuredTimeout % 60; + + const humanReadableTimeout = + configuredTimeoutMinutes > 0 + ? `${configuredTimeoutMinutes}m${configuredTimeoutSeconds}s` + : `${configuredTimeoutSeconds}s`; + + if (options.captureTimeoutWarning) { + const timeoutWarningDelay = tryGetRemainingTimeInMillis(context) - options.timeoutWarningLimit; + + return setTimeout(() => { + withScope(scope => { + scope.setTag('timeout', humanReadableTimeout); + captureMessage(`Possible function timeout: ${context.functionName}`, 'warning'); + }); + }, timeoutWarningDelay) as unknown as NodeJS.Timeout; + } + + return undefined; +} + +export const AWS_HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); +export const AWS_HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); +export const AWS_HANDLER_STREAMING_RESPONSE = 'response'; + +function isStreamingHandler(handler: Handler | StreamifyHandler): handler is StreamifyHandler { + return ( + (handler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] === AWS_HANDLER_STREAMING_RESPONSE + ); +} + +export function wrapHandler( + handler: Handler, + wrapOptions?: Partial, +): Handler; + +export function wrapHandler( + handler: StreamifyHandler, + wrapOptions?: Partial, +): StreamifyHandler; + /** * Wraps a lambda handler adding it error capture and tracing capabilities. * @@ -116,9 +161,9 @@ function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTi * @returns Handler */ export function wrapHandler( - handler: Handler, + handler: Handler | StreamifyHandler, wrapOptions: Partial = {}, -): Handler { +): Handler | StreamifyHandler { const START_TIME = performance.now(); // eslint-disable-next-line deprecation/deprecation @@ -141,18 +186,22 @@ export function wrapHandler( ...wrapOptions, }; - let timeoutWarningTimer: NodeJS.Timeout; + if (isStreamingHandler(handler)) { + return wrapStreamingHandler(handler, options, START_TIME); + } + + let timeoutWarningTimer: NodeJS.Timeout | undefined; // AWSLambda is like Express. It makes a distinction about handlers based on its last argument // async (event) => async handler // async (event, context) => async handler // (event, context, callback) => sync handler // Nevertheless whatever option is chosen by user, we convert it to async handler. - const asyncHandler: AsyncHandler = + const asyncHandler: AsyncHandler> = handler.length > 2 ? (event, context) => new Promise((resolve, reject) => { - const rv = (handler as SyncHandler)(event, context, (error, result) => { + const rv = (handler as SyncHandler>)(event, context, (error, result) => { if (error === null || error === undefined) { resolve(result!); // eslint-disable-line @typescript-eslint/no-non-null-assertion } else { @@ -166,33 +215,12 @@ export function wrapHandler( void (rv as Promise>).then(resolve, reject); } }) - : (handler as AsyncHandler); + : (handler as AsyncHandler>); - return async (event, context) => { + return async (event: TEvent, context: Context) => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; - // In seconds. You cannot go any more granular than this in AWS Lambda. - const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000); - const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60); - const configuredTimeoutSeconds = configuredTimeout % 60; - - const humanReadableTimeout = - configuredTimeoutMinutes > 0 - ? `${configuredTimeoutMinutes}m${configuredTimeoutSeconds}s` - : `${configuredTimeoutSeconds}s`; - - // When `callbackWaitsForEmptyEventLoop` is set to false, which it should when using `captureTimeoutWarning`, - // we don't have a guarantee that this message will be delivered. Because of that, we don't flush it. - if (options.captureTimeoutWarning) { - const timeoutWarningDelay = tryGetRemainingTimeInMillis(context) - options.timeoutWarningLimit; - - timeoutWarningTimer = setTimeout(() => { - withScope(scope => { - scope.setTag('timeout', humanReadableTimeout); - captureMessage(`Possible function timeout: ${context.functionName}`, 'warning'); - }); - }, timeoutWarningDelay) as unknown as NodeJS.Timeout; - } + timeoutWarningTimer = setupTimeoutWatning(context, options); async function processResult(): Promise { const scope = getCurrentScope(); @@ -229,3 +257,57 @@ export function wrapHandler( }); }; } + +function wrapStreamingHandler( + handler: StreamifyHandler, + options: WrapperOptions, + startTime: number, +): StreamifyHandler { + let timeoutWarningTimer: NodeJS.Timeout | undefined; + + const wrappedHandler = async ( + event: TEvent, + responseStream: Parameters>[1], + context: Context, + ): Promise => { + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; + + timeoutWarningTimer = setupTimeoutWatning(context, options); + + async function processStreamingResult(): Promise { + const scope = getCurrentScope(); + + try { + enhanceScopeWithEnvironmentData(scope, context, startTime); + + responseStream.on('error', error => { + captureException(error, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.stream')); + }); + + return await handler(event, responseStream, context); + } catch (e) { + // Errors should already captured in the instrumentation's `responseHook`, + // we capture them here just to be safe. Double captures are deduplicated by the SDK. + captureException(e, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.handler')); + throw e; + } finally { + if (timeoutWarningTimer) { + clearTimeout(timeoutWarningTimer); + } + await flush(options.flushTimeout).catch(e => { + DEBUG_BUILD && debug.error(e); + }); + } + } + + return withScope(() => processStreamingResult()); + }; + + const handlerWithSymbols = handler as unknown as Record; + (wrappedHandler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] = + handlerWithSymbols[AWS_HANDLER_STREAMING_SYMBOL]; + (wrappedHandler as unknown as Record)[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = + handlerWithSymbols[AWS_HANDLER_HIGHWATERMARK_SYMBOL]; + + return wrappedHandler; +} diff --git a/packages/aws-serverless/test/sdk.test.ts b/packages/aws-serverless/test/sdk.test.ts index 58bb04a234b9..3bf2c42b8fd2 100644 --- a/packages/aws-serverless/test/sdk.test.ts +++ b/packages/aws-serverless/test/sdk.test.ts @@ -2,7 +2,7 @@ import type { Event } from '@sentry/core'; import type { Callback, Handler } from 'aws-lambda'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { init } from '../src/init'; -import { wrapHandler } from '../src/sdk'; +import { AWS_HANDLER_STREAMING_RESPONSE, AWS_HANDLER_STREAMING_SYMBOL, wrapHandler } from '../src/sdk'; const mockFlush = vi.fn((...args) => Promise.resolve(args)); const mockWithScope = vi.fn(); @@ -368,6 +368,191 @@ describe('AWSLambda', () => { }); }); + describe('wrapHandler() on streaming handlers', () => { + // Mock response stream with common stream interface + const mockResponseStream = { + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + on: vi.fn(), + setContentType: vi.fn(), + writable: true, + writableEnded: false, + writableFinished: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockResponseStream.write.mockClear(); + mockResponseStream.end.mockClear(); + mockResponseStream.destroy.mockClear(); + mockResponseStream.on.mockClear(); + }); + + test('successful execution', async () => { + expect.assertions(5); + + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + return 42; + }); + // Add the streaming symbol to mark it as a streaming handler + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + const rv = await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + + expect(rv).toStrictEqual(42); + expectScopeSettings(); + expect(streamingHandler).toHaveBeenCalledWith(fakeEvent, mockResponseStream, fakeContext); + expect(mockFlush).toBeCalledWith(2000); + }); + + test('preserves streaming symbol on wrapped handler', () => { + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + return 42; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + + expect((wrappedHandler as any)[AWS_HANDLER_STREAMING_SYMBOL]).toBe(AWS_HANDLER_STREAMING_RESPONSE); + }); + + test('event, responseStream and context are correctly passed along', async () => { + expect.assertions(3); + + const streamingHandler = vi.fn(async (event, responseStream, context) => { + expect(event).toHaveProperty('fortySix'); + expect(responseStream).toBe(mockResponseStream); + expect(context).toHaveProperty('ytho'); + return 'success'; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + }); + + test('capture error from handler execution', async () => { + expect.assertions(4); + + const error = new Error('streaming handler error'); + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + throw error; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + + try { + await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + } catch { + expectScopeSettings(); + expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); + expect(mockFlush).toBeCalled(); + } + }); + + test('capture stream errors', async () => { + expect.assertions(3); + + const streamError = new Error('stream error'); + const streamingHandler = vi.fn(async (_event, responseStream, _context) => { + // Simulate stream error by calling the error listener + const errorListener = (responseStream.on as any).mock.calls.find((call: any[]) => call[0] === 'error')?.[1]; + if (errorListener) { + errorListener(streamError); + } + return 'success'; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + + expect(mockResponseStream.on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mockCaptureException).toHaveBeenCalledWith(streamError, expect.any(Function)); + expect(streamingHandler).toHaveBeenCalledWith(fakeEvent, mockResponseStream, fakeContext); + }); + + test('streaming handler with flushTimeout option', async () => { + expect.assertions(2); + + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + return 'flushed'; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler, { flushTimeout: 5000 }); + const result = await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + + expect(result).toBe('flushed'); + expect(mockFlush).toBeCalledWith(5000); + }); + + test('streaming handler with captureTimeoutWarning enabled', async () => { + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + // Simulate some delay to trigger timeout warning + await new Promise(resolve => setTimeout(resolve, DEFAULT_EXECUTION_TIME)); + return 'completed'; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + + expect(mockWithScope).toBeCalledTimes(2); + expect(mockCaptureMessage).toBeCalled(); + expect(mockScope.setTag).toBeCalledWith('timeout', '1s'); + }); + + test('marks streaming handler captured errors as unhandled', async () => { + expect.assertions(3); + + const error = new Error('streaming error'); + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + throw error; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + + try { + await (wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext); + } catch { + expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); + + const scopeFunction = mockCaptureException.mock.calls[0]?.[1]; + const event: Event = { exception: { values: [{}] } }; + let evtProcessor: ((e: Event) => Event) | undefined = undefined; + if (scopeFunction) { + scopeFunction({ addEventProcessor: vi.fn().mockImplementation(proc => (evtProcessor = proc)) }); + } + + expect(evtProcessor).toBeInstanceOf(Function); + // @ts-expect-error just mocking around... + expect(evtProcessor!(event).exception.values[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.aws-serverless.handler', + }); + } + }); + + test('should not throw when flush rejects with streaming handler', async () => { + const streamingHandler = vi.fn(async (_event, _responseStream, _context) => { + return 'flush-error-test'; + }); + (streamingHandler as any)[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; + + const wrappedHandler = wrapHandler(streamingHandler); + mockFlush.mockImplementationOnce(() => Promise.reject(new Error('flush failed'))); + + await expect((wrappedHandler as any)(fakeEvent, mockResponseStream, fakeContext)).resolves.toBe( + 'flush-error-test', + ); + }); + }); + test('marks the captured error as unhandled', async () => { expect.assertions(3); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b6b4695835ba..2de5147d3d5a 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -68,6 +68,29 @@ interface BaseCloudflareOptions { * @default false */ skipOpenTelemetrySetup?: boolean; + + /** + * Enable instrumentation of prototype methods for DurableObjects. + * + * When `true`, the SDK will wrap all methods on the DurableObject prototype chain + * to automatically create spans and capture errors for RPC method calls. + * + * When an array of strings is provided, only the specified method names will be instrumented. + * + * This feature adds runtime overhead as it wraps methods at the prototype level. + * Only enable this if you need automatic instrumentation of prototype methods. + * + * @default false + * @example + * ```ts + * // Instrument all prototype methods + * instrumentPrototypeMethods: true + * + * // Instrument only specific methods + * instrumentPrototypeMethods: ['myMethod', 'anotherMethod'] + * ``` + */ + instrumentPrototypeMethods?: boolean | string[]; } /** diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 4efaf33c9b1c..bda7a9aa3538 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -110,8 +110,7 @@ function wrapMethodWithSentry( } : {}; - // Only create these spans if they have a parent span. - return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, () => { + return startSpan({ name: wrapperOptions.spanName, attributes }, () => { try { const result = Reflect.apply(target, thisArg, args); @@ -273,46 +272,101 @@ export function instrumentDurableObjectWithSentry< ); } } - const instrumentedPrototype = instrumentPrototype(target, options, context); - Object.setPrototypeOf(obj, instrumentedPrototype); + + // Store context and options on the instance for prototype methods to access + Object.defineProperty(obj, '__SENTRY_CONTEXT__', { + value: context, + enumerable: false, + writable: false, + configurable: false, + }); + + Object.defineProperty(obj, '__SENTRY_OPTIONS__', { + value: options, + enumerable: false, + writable: false, + configurable: false, + }); + + if (options?.instrumentPrototypeMethods) { + instrumentPrototype(target, options.instrumentPrototypeMethods); + } return obj; }, }); } -function instrumentPrototype( - target: T, - options: CloudflareOptions, - context: MethodWrapperOptions['context'], -): T { - return new Proxy(target.prototype, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (prop === 'constructor' || typeof value !== 'function') { - return value; +function instrumentPrototype(target: T, methodsToInstrument: boolean | string[]): void { + const proto = target.prototype; + + // Get all methods from the prototype chain + const methodNames = new Set(); + let current = proto; + + while (current && current !== Object.prototype) { + Object.getOwnPropertyNames(current).forEach(name => { + if (name !== 'constructor' && typeof (current as Record)[name] === 'function') { + methodNames.add(name); + } + }); + current = Object.getPrototypeOf(current); + } + + // Create a set for efficient lookups when methodsToInstrument is an array + const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null; + + // Instrument each method on the prototype + methodNames.forEach(methodName => { + const originalMethod = (proto as Record)[methodName]; + + if (!originalMethod || isInstrumented(originalMethod)) { + return; + } + + // If methodsToInstrument is an array, only instrument methods in that set + if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) { + return; + } + + // Create a wrapper that gets context/options from the instance at runtime + const wrappedMethod = function (this: any, ...args: any[]): unknown { + const thisWithSentry = this as { + __SENTRY_CONTEXT__: DurableObjectState; + __SENTRY_OPTIONS__: CloudflareOptions; + }; + const instanceContext = thisWithSentry.__SENTRY_CONTEXT__; + const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__; + + if (!instanceOptions) { + // Fallback to original method if no Sentry data found + return (originalMethod as (...args: any[]) => any).apply(this, args); } - const wrapped = wrapMethodWithSentry( - { options, context, spanName: prop.toString(), spanOp: 'rpc' }, - value, + + // Use the existing wrapper but with instance-specific context/options + const wrapper = wrapMethodWithSentry( + { + options: instanceOptions, + context: instanceContext, + spanName: methodName, + spanOp: 'rpc', + }, + originalMethod as (...args: any[]) => any, undefined, - true, + true, // noMark = true since we'll mark the prototype method ); - const instrumented = new Proxy(wrapped, { - get(target, p, receiver) { - if ('__SENTRY_INSTRUMENTED__' === p) { - return true; - } - return Reflect.get(target, p, receiver); - }, - }); - Object.defineProperty(receiver, prop, { - value: instrumented, - enumerable: true, - writable: true, - configurable: true, - }); - return instrumented; - }, + + return (wrapper as (...args: any[]) => any).apply(this, args); + }; + + markAsInstrumented(wrappedMethod); + + // Replace the prototype method + Object.defineProperty(proto, methodName, { + value: wrappedMethod, + enumerable: false, + writable: true, + configurable: true, + }); }); } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 2add5dde9343..ce794dc7fb69 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -10,7 +10,7 @@ describe('instrumentDurableObjectWithSentry', () => { }); it('Generic functionality', () => { - const options = vi.fn(); + const options = vi.fn().mockReturnValue({}); const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); expect(instrumented).toBeTypeOf('function'); expect(() => Reflect.construct(instrumented, [])).not.toThrow(); @@ -23,7 +23,10 @@ describe('instrumentDurableObjectWithSentry', () => { return 'sync-result'; } }; - const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any), + [], + ) as any; expect(obj.method).toBe(obj.method); const result = obj.method(); @@ -37,7 +40,10 @@ describe('instrumentDurableObjectWithSentry', () => { return 'async-result'; } }; - const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any), + [], + ) as any; expect(obj.asyncMethod).toBe(obj.asyncMethod); const result = obj.asyncMethod(); @@ -46,26 +52,42 @@ describe('instrumentDurableObjectWithSentry', () => { }); it('Instruments prototype methods without "sticking" to the options', () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; // Environment mock const initCore = vi.spyOn(SentryCore, 'initAndBind'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); const options = vi .fn() .mockReturnValueOnce({ orgId: 1, + instrumentPrototypeMethods: true, }) .mockReturnValueOnce({ orgId: 2, + instrumentPrototypeMethods: true, }); const testClass = class { method() {} }; - (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); - (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); + const instance1 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [ + mockContext, + mockEnv, + ]) as any; + instance1.method(); + + const instance2 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [ + mockContext, + mockEnv, + ]) as any; + instance2.method(); + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('All available durable object methods are instrumented', () => { + it('All available durable object methods are instrumented when instrumentPrototypeMethods is enabled', () => { const testClass = class { propertyFunction = vi.fn(); @@ -81,9 +103,11 @@ describe('instrumentDurableObjectWithSentry', () => { webSocketError() {} }; - const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), + testClass as any, + ); const obj = Reflect.construct(instrumented, []); - expect(Object.getPrototypeOf(obj), 'Prototype is instrumented').not.toBe(testClass.prototype); for (const method_name of [ 'propertyFunction', 'fetch', @@ -122,4 +146,93 @@ describe('instrumentDurableObjectWithSentry', () => { await Promise.all(waitUntil.mock.calls.map(([p]) => p)); expect(flush).toBeCalled(); }); + + describe('instrumentPrototypeMethods option', () => { + it('does not instrument prototype methods when option is not set', () => { + const testClass = class { + prototypeMethod() { + return 'prototype-result'; + } + }; + const options = vi.fn().mockReturnValue({}); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.prototypeMethod)).toBeFalsy(); + }); + + it('does not instrument prototype methods when option is false', () => { + const testClass = class { + prototypeMethod() { + return 'prototype-result'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.prototypeMethod)).toBeFalsy(); + }); + + it('instruments all prototype methods when option is true', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.methodOne)).toBeTruthy(); + expect(isInstrumented(obj.methodTwo)).toBeTruthy(); + }); + + it('instruments only specified methods when option is array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + methodThree() { + return 'three'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.methodOne)).toBeTruthy(); + expect(isInstrumented(obj.methodTwo)).toBeFalsy(); + expect(isInstrumented(obj.methodThree)).toBeTruthy(); + }); + + it('still instruments instance methods regardless of prototype option', () => { + const testClass = class { + propertyFunction = vi.fn(); + + fetch() {} + alarm() {} + webSocketMessage() {} + webSocketClose() {} + webSocketError() {} + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + // Instance methods should still be instrumented + expect(isInstrumented(obj.propertyFunction)).toBeTruthy(); + expect(isInstrumented(obj.fetch)).toBeTruthy(); + expect(isInstrumented(obj.alarm)).toBeTruthy(); + expect(isInstrumented(obj.webSocketMessage)).toBeTruthy(); + expect(isInstrumented(obj.webSocketClose)).toBeTruthy(); + expect(isInstrumented(obj.webSocketError)).toBeTruthy(); + }); + }); }); diff --git a/packages/core/src/utils/anthropic-ai/constants.ts b/packages/core/src/utils/anthropic-ai/constants.ts index 41a227f171e0..1e20745e0f1f 100644 --- a/packages/core/src/utils/anthropic-ai/constants.ts +++ b/packages/core/src/utils/anthropic-ai/constants.ts @@ -4,6 +4,7 @@ export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI'; // https://docs.anthropic.com/en/api/models-list export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ 'messages.create', + 'messages.stream', 'messages.countTokens', 'models.get', 'completions.create', diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8d56b2a56c04..2ed95be76843 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -1,7 +1,8 @@ import { getCurrentScope } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; -import { startSpan } from '../../tracing/trace'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, @@ -22,14 +23,17 @@ import { } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; +import { instrumentStream } from './streaming'; import type { AnthropicAiClient, AnthropicAiInstrumentedMethod, AnthropicAiIntegration, AnthropicAiOptions, AnthropicAiResponse, + AnthropicAiStreamingEvent, } from './types'; import { shouldInstrument } from './utils'; + /** * Extract request attributes from method arguments */ @@ -168,7 +172,47 @@ function instrumentMethod( const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); - // TODO: Handle streaming responses + 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( + { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }, + async (span: Span) => { + try { + if (finalOptions.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + + const result = await originalMethod.apply(context, args); + return instrumentStream( + result as AsyncIterable, + span, + finalOptions.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; + } + }, + ); + } + return startSpan( { name: `${operationName} ${model}`, diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts new file mode 100644 index 000000000000..8ebbfc0b42cd --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/streaming.ts @@ -0,0 +1,202 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { setTokenUsageAttributes } from '../ai/utils'; +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[]; + /** Reasons for finishing the response, as reported by the API. */ + finishReasons: string[]; + /** The response ID. */ + responseId: string; + /** The model name. */ + responseModel: string; + /** Number of prompt/input tokens used. */ + promptTokens: number | undefined; + /** Number of completion/output tokens used. */ + completionTokens: number | undefined; + /** Number of cache creation input tokens used. */ + cacheCreationInputTokens: number | undefined; + /** Number of cache read input tokens used. */ + cacheReadInputTokens: number | undefined; +} + +/** + * Checks if an event is an error event + * @param event - The event to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + * @param span - The span to update + * @returns Whether an error occurred + */ + +function isErrorEvent( + event: AnthropicAiStreamingEvent, + state: StreamingState, + recordOutputs: boolean, + span: Span, +): boolean { + if ('type' in event && typeof event.type === 'string') { + // If the event is an error, set the span status and capture the error + // These error events are not rejected by the API by default, but are sent as metadata of the response + if (event.type === 'error') { + const message = event.error?.message ?? 'internal_error'; + span.setStatus({ code: SPAN_STATUS_ERROR, message }); + captureException(new Error(`anthropic_stream_error: ${message}`), { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: 'anthropic_stream_error', + }, + }, + data: { + function: 'anthropic_stream_error', + }, + }); + return true; + } + + if (recordOutputs && event.type === 'content_block_delta') { + const text = event.delta?.text; + if (text) state.responseTexts.push(text); + } + } + return false; +} + +/** + * Processes the message metadata of an event + * @param event - The event to process + * @param state - The state of the streaming process + */ + +function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: StreamingState): void { + // The token counts shown in the usage field of the message_delta event are cumulative. + // @see https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types + if (event.type === 'message_delta' && event.usage) { + if ('output_tokens' in event.usage && typeof event.usage.output_tokens === 'number') { + state.completionTokens = event.usage.output_tokens; + } + } + + if (event.message) { + const message = event.message; + + if (message.id) state.responseId = message.id; + if (message.model) state.responseModel = message.model; + if (message.stop_reason) state.finishReasons.push(message.stop_reason); + + if (message.usage) { + if (typeof message.usage.input_tokens === 'number') state.promptTokens = message.usage.input_tokens; + if (typeof message.usage.cache_creation_input_tokens === 'number') + state.cacheCreationInputTokens = message.usage.cache_creation_input_tokens; + if (typeof message.usage.cache_read_input_tokens === 'number') + state.cacheReadInputTokens = message.usage.cache_read_input_tokens; + } + } +} + +/** + * Processes an event + * @param event - The event to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + * @param span - The span to update + */ + +function processEvent( + event: AnthropicAiStreamingEvent, + state: StreamingState, + recordOutputs: boolean, + span: Span, +): void { + if (!(event && typeof event === 'object')) { + return; + } + + const isError = isErrorEvent(event, state, recordOutputs, span); + if (isError) return; + + handleMessageMetadata(event, state); +} + +/** + * 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( + stream: AsyncIterable, + span: Span, + recordOutputs: boolean, +): AsyncGenerator { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + promptTokens: undefined, + completionTokens: undefined, + cacheCreationInputTokens: undefined, + cacheReadInputTokens: undefined, + }; + + try { + for await (const event of stream) { + processEvent(event, state, recordOutputs, span); + yield event; + } + } finally { + // 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(''), + }); + } + + span.end(); + } +} diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/utils/anthropic-ai/types.ts index 566e9588d56f..fd533b6795bc 100644 --- a/packages/core/src/utils/anthropic-ai/types.ts +++ b/packages/core/src/utils/anthropic-ai/types.ts @@ -61,3 +61,44 @@ export interface AnthropicAiIntegration { } export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number]; + +/** + * Message type for Anthropic AI + */ +export type AnthropicAiMessage = { + id: string; + type: 'message'; + role: string; + model: string; + content: unknown[]; + stop_reason: string | null; + stop_sequence: number | null; + usage?: { + input_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + cache_creation?: unknown; + output_tokens?: number; // Not final; do not treat as total. Use `message_delta.usage.output_tokens` for the final total. + service_tier?: string; + }; +}; + +/** + * Streaming event type for Anthropic AI + */ +export type AnthropicAiStreamingEvent = { + type: 'message_delta' | 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'error'; + error?: { + type: string; + message: string; + }; + index?: number; + delta?: { + type: unknown; + text?: string; + }; + usage?: { + output_tokens: number; // Final total output tokens; emitted on the last `message_delta` event + }; + message?: AnthropicAiMessage; +}; diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index da666290e73c..153b620d0fc2 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,7 +47,7 @@ "dependencies": { "@sentry/core": "10.6.0", "@sentry/react": "10.6.0", - "@sentry/webpack-plugin": "^4.1.0" + "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { "gatsby": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 2fa6ead6889b..dcf68600466b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,7 +85,7 @@ "@sentry/opentelemetry": "10.6.0", "@sentry/react": "10.6.0", "@sentry/vercel-edge": "10.6.0", - "@sentry/webpack-plugin": "^4.1.0", + "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/node/package.json b/packages/node/package.json index f00bb9acca47..b9ebec0768ab 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -71,7 +71,7 @@ "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", - "@opentelemetry/instrumentation-dataloader": "0.21.0", + "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", @@ -79,14 +79,14 @@ "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", - "@opentelemetry/instrumentation-kafkajs": "0.12.0", + "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", - "@opentelemetry/instrumentation-mysql2": "0.49.0", + "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", @@ -94,7 +94,7 @@ "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", - "@prisma/instrumentation": "6.13.0", + "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.6.0", "@sentry/node-core": "10.6.0", "@sentry/opentelemetry": "10.6.0", diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 79826e2e4e8c..ddd5de60197a 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -51,7 +51,7 @@ "@sentry/cloudflare": "10.6.0", "@sentry/core": "10.6.0", "@sentry/node": "10.6.0", - "@sentry/rollup-plugin": "^4.1.0", + "@sentry/rollup-plugin": "^4.1.1", "@sentry/vite-plugin": "^4.1.0", "@sentry/vue": "10.6.0" }, diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 002ca7047fa9..47e0949ec8c6 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,7 +50,7 @@ "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@sentry/browser": "10.6.0", - "@sentry/cli": "^2.51.1", + "@sentry/cli": "^2.52.0", "@sentry/core": "10.6.0", "@sentry/node": "10.6.0", "@sentry/react": "10.6.0", diff --git a/packages/remix/package.json b/packages/remix/package.json index 4858a830ba25..7ede68f0f8fa 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.51.1", + "@sentry/cli": "^2.52.0", "@sentry/core": "10.6.0", "@sentry/node": "10.6.0", "@sentry/react": "10.6.0", diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 03ae7811984a..af3e79d16fc9 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -66,7 +66,7 @@ }, "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { - "@sentry-internal/rrweb": "2.35.0" + "@sentry-internal/rrweb": "2.37.0" }, "dependencies": { "@sentry-internal/replay": "10.6.0", diff --git a/packages/replay-canvas/src/canvas.ts b/packages/replay-canvas/src/canvas.ts index d026567e01b1..7861572b190f 100644 --- a/packages/replay-canvas/src/canvas.ts +++ b/packages/replay-canvas/src/canvas.ts @@ -3,8 +3,12 @@ import { defineIntegration } from '@sentry/core'; import type { CanvasManagerInterface, CanvasManagerOptions } from '@sentry-internal/replay'; import { CanvasManager } from '@sentry-internal/rrweb'; +interface SnapshotOptions { + skipRequestAnimationFrame?: boolean; +} + interface ReplayCanvasIntegration extends Integration { - snapshot: (canvasElement?: HTMLCanvasElement) => Promise; + snapshot: (canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) => Promise; } interface ReplayCanvasOptions { @@ -106,9 +110,10 @@ export const _replayCanvasIntegration = ((options: Partial ...(CANVAS_QUALITY[quality || 'medium'] || CANVAS_QUALITY.medium), }; }, - async snapshot(canvasElement?: HTMLCanvasElement) { + async snapshot(canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) { const canvasManager = await _canvasManager; - canvasManager.snapshot(canvasElement); + + canvasManager.snapshot(canvasElement, options); }, }; }) satisfies IntegrationFn; diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index cfa17f1997db..95c9dd8bf0a1 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -82,8 +82,8 @@ "devDependencies": { "@babel/core": "^7.27.7", "@sentry-internal/replay-worker": "10.6.0", - "@sentry-internal/rrweb": "2.35.0", - "@sentry-internal/rrweb-snapshot": "2.35.0", + "@sentry-internal/rrweb": "2.37.0", + "@sentry-internal/rrweb-snapshot": "2.37.0", "fflate": "0.8.2", "jest-matcher-utils": "^29.0.0", "jsdom-worker": "^0.3.0", diff --git a/yarn.lock b/yarn.lock index 16f91521a991..a7660fbc1e84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5876,10 +5876,10 @@ "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-aws-sdk@0.56.0": - version "0.56.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.56.0.tgz#a65cd88351b7bd8566413798764679295166754a" - integrity sha512-Jl2B/FYEb6tBCk9G31CMomKPikGU2g+CEhrGddDI0o1YeNpg3kAO9dExF+w489/IJUGZX6/wudyNvV7z4k9NjQ== +"@opentelemetry/instrumentation-aws-sdk@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.57.0.tgz#22d0a2ac113718c85c39d3561ee338dfad2234f6" + integrity sha512-RfbyjaeZzX3mPhuaRHlSAQyfX3skfeWOl30jrqSXtE9k0DPdnIqpHhdYS0C/DEDuZbwTmruVJ4cUwMBw5Z6FAg== dependencies: "@opentelemetry/core" "^2.0.0" "@opentelemetry/instrumentation" "^0.203.0" @@ -5896,10 +5896,10 @@ "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.21.0": - version "0.21.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.21.0.tgz#19202a85000cae9612f74bc689005ed3164e30a4" - integrity sha512-Xu4CZ1bfhdkV3G6iVHFgKTgHx8GbKSqrTU01kcIJRGHpowVnyOPEv1CW5ow+9GU2X4Eki8zoNuVUenFc3RluxQ== +"@opentelemetry/instrumentation-dataloader@0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.21.1.tgz#46fbbe59d9d6796980707768cf733225d43adea5" + integrity sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw== dependencies: "@opentelemetry/instrumentation" "^0.203.0" @@ -5962,10 +5962,10 @@ "@opentelemetry/redis-common" "^0.38.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-kafkajs@0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.12.0.tgz#231e6cc8a2a70d06162ed7e4ebe2ab5baa3a6670" - integrity sha512-bIe4aSAAxytp88nzBstgr6M7ZiEpW6/D1/SuKXdxxuprf18taVvFL2H5BDNGZ7A14K27haHqzYqtCTqFXHZOYg== +"@opentelemetry/instrumentation-kafkajs@0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.13.0.tgz#f959fecd0a9d53bed2fd662e41a5c155295ffbc8" + integrity sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw== dependencies: "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.30.0" @@ -6011,10 +6011,10 @@ "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-mysql2@0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.49.0.tgz#ad518f9420cf8d2035bd4f80519406b66b66bb1a" - integrity sha512-dCub9wc02mkJWNyHdVEZ7dvRzy295SmNJa+LrAJY2a/+tIiVBQqEAajFzKwp9zegVVnel9L+WORu34rGLQDzxA== +"@opentelemetry/instrumentation-mysql2@0.50.0": + version "0.50.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.50.0.tgz#259344ba8771fd59c70a1a91360215b4b047a323" + integrity sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g== dependencies: "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" @@ -6296,10 +6296,10 @@ resolved "https://registry.yarnpkg.com/@poppinss/exception/-/exception-1.2.2.tgz#8d30d42e126c54fe84e997433e4dcac610090743" integrity sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg== -"@prisma/instrumentation@6.13.0": - version "6.13.0" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.13.0.tgz#f2f774162b9247c870f306828da580c5102ff679" - integrity sha512-b97b0sBycGh89RQcqobSgjGl3jwPaC5cQIOFod6EX1v0zIxlXPmL3ckSXxoHpy+Js0QV/tgCzFvqicMJCtezBA== +"@prisma/instrumentation@6.14.0": + version "6.14.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.14.0.tgz#8f6e43b73ee2b88b98cec901457f4da7da13aea3" + integrity sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A== dependencies: "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" @@ -6853,22 +6853,22 @@ dependencies: "@sentry-internal/rrweb-snapshot" "2.34.0" -"@sentry-internal/rrdom@2.35.0": - version "2.35.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.35.0.tgz#27dbdfe3249afb65a31f3b680cd0cc92ed2001dd" - integrity sha512-sWZjJpv7/Fu1po5ibzGUojWLMGn/GgqsayE8dqbwI6F2x5gMVYL0/yIk+9Qii0ei3Su3BybWHfftZs+5r2Bong== +"@sentry-internal/rrdom@2.37.0": + version "2.37.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.37.0.tgz#1aaf382eb7b543d7c256d31b73868e81e6649fbb" + integrity sha512-Wj6W4HP6kVYL1oenYq+Ec7QKtsq1Btk/acFLfZ/O7fygLVeAM0KZ4JZirPWdJmpeNNIk1YN7a8C7CfCiM014Ag== dependencies: - "@sentry-internal/rrweb-snapshot" "2.35.0" + "@sentry-internal/rrweb-snapshot" "2.37.0" "@sentry-internal/rrweb-snapshot@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.34.0.tgz#79c2049b6c887e3c128d5fa80d6f745a61dd0e68" integrity sha512-9Tb8jwVufn5GLV0d/CTuoZWo2O06ZB+xWeTJdEkbtJ6PAmO/Q7GQI3uNIx0pfFEnXP+0Km8CKKxpwkEM0z2m6w== -"@sentry-internal/rrweb-snapshot@2.35.0": - version "2.35.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.35.0.tgz#656f4716e3bdda151f122868f6f92d5f4224967c" - integrity sha512-CyERHnGWIkuCtw4xYJMoyDUv+5vj38HBd0upeEhKyYzjZ8rOttwsFjfZUBdotsP8O0/RVt9KIPRbSRESC1qSJw== +"@sentry-internal/rrweb-snapshot@2.37.0": + version "2.37.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.37.0.tgz#2081d1827a108a08cab219234952744e6e79c06b" + integrity sha512-fu2/Fd5J5gJrAgQgl9WykVPQkMjo+9MVFy4Y88STTP3WWsLu1u75YAQM6Lr1/tLEykoQ4NecmNDdcz/DiB/nNg== "@sentry-internal/rrweb-types@2.34.0": version "2.34.0" @@ -6878,12 +6878,12 @@ "@sentry-internal/rrweb-snapshot" "2.34.0" "@types/css-font-loading-module" "0.0.7" -"@sentry-internal/rrweb-types@2.35.0": - version "2.35.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.35.0.tgz#b2e63879a23593505fc3e28aa811e718de71f15f" - integrity sha512-D0mu2bgtvYD8MGijZDSD+q3FC8fDVRvNJD4canKvI3Wy9/LHTPbJ6F4U544vp5VrdBGCYIf/cxuJwmyZDfl5RQ== +"@sentry-internal/rrweb-types@2.37.0": + version "2.37.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.37.0.tgz#edc91ee032896788fa9a6f96c76f1f6dd7c9f538" + integrity sha512-ydtHzfGFO6Tyw4n7yOLUrdaNKmRdyaVfqNgObEbEgO/qobzxBV5zf8eNApTayy6SOji3NrF8PpJzm55OX/ChNA== dependencies: - "@sentry-internal/rrweb-snapshot" "2.35.0" + "@sentry-internal/rrweb-snapshot" "2.37.0" "@types/css-font-loading-module" "0.0.7" "@sentry-internal/rrweb@2.34.0": @@ -6900,14 +6900,14 @@ fflate "^0.4.4" mitt "^3.0.0" -"@sentry-internal/rrweb@2.35.0": - version "2.35.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.35.0.tgz#ae10b9aaf3ee379164ec52f1186ee053d369b0a3" - integrity sha512-Zy3bnzL9GY6SFTZ5x5YNxtkmIUiaLSppLA41xn6zc4UWSYI4DcA+M8OGxI4TiHkQVJhhjwBG1CevrLyrBxyEgA== +"@sentry-internal/rrweb@2.37.0": + version "2.37.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.37.0.tgz#8ea0eb906e194060c60f4a6af4a0d3a16d52cdec" + integrity sha512-erN53M1WSPGpsMw+iVX6qWhI4id41+2AXcnALoB2JIyL/Q1W8f2loq/a4PxWkhaaq7mtGiHTmNTbks8Qgrsl9g== dependencies: - "@sentry-internal/rrdom" "2.35.0" - "@sentry-internal/rrweb-snapshot" "2.35.0" - "@sentry-internal/rrweb-types" "2.35.0" + "@sentry-internal/rrdom" "2.37.0" + "@sentry-internal/rrweb-snapshot" "2.37.0" + "@sentry-internal/rrweb-types" "2.37.0" "@types/css-font-loading-module" "0.0.7" "@xstate/fsm" "^1.4.0" base64-arraybuffer "^1.0.1" @@ -6915,7 +6915,7 @@ mitt "^3.0.0" "@sentry-internal/test-utils@link:dev-packages/test-utils": - version "10.4.0" + version "10.6.0" dependencies: express "^4.21.1" @@ -6924,6 +6924,11 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.0.tgz#6e7168f5fa59f53ac4b68e3f79c5fd54adc13f2e" integrity sha512-UkcnqC7Bp9ODyoBN7BKcRotd1jz/I2vyruE/qjNfRC7UnP+jIRItUWYaXxQPON1fTw+N+egKdByk0M1y2OPv/Q== +"@sentry/babel-plugin-component-annotate@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz#371415afc602f6b2ba0987b51123bd34d1603193" + integrity sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA== + "@sentry/bundler-plugin-core@4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.0.tgz#c1b2f7a890a44e5ac5decc984a133aacf6147dd4" @@ -6938,50 +6943,64 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.51.1.tgz#3a1db065651893f72dad3a502b2d7c2f5e6a7dd8" - integrity sha512-R1u8IQdn/7Rr8sf6bVVr0vJT4OqwCFdYsS44Y3OoWGVJW2aAQTWRJOTlV4ueclVLAyUQzmgBjfR8AtiUhd/M5w== - -"@sentry/cli-linux-arm64@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.51.1.tgz#b4c957a06bafc13623c48971eadb0cff7d3662a3" - integrity sha512-nvA/hdhsw4bKLhslgbBqqvETjXwN1FVmwHLOrRvRcejDO6zeIKUElDiL5UOjGG0NC+62AxyNw5ri8Wzp/7rg9Q== - -"@sentry/cli-linux-arm@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.51.1.tgz#f761d0c58d27be503471cee4ffc41875a7d9430b" - integrity sha512-Klro17OmSSKOOSaxVKBBNPXet2+HrIDZUTSp8NRl4LQsIubdc1S/aQ79cH/g52Muwzpl3aFwPxyXw+46isfEgA== - -"@sentry/cli-linux-i686@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.51.1.tgz#62baaf83c5995e478186289a45315d0acd5bd3bf" - integrity sha512-jp4TmR8VXBdT9dLo6mHniQHN0xKnmJoPGVz9h9VDvO2Vp/8o96rBc555D4Am5wJOXmfuPlyjGcmwHlB3+kQRWw== - -"@sentry/cli-linux-x64@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.51.1.tgz#0010fe24ad8ef492a917c12feb351ba768e72603" - integrity sha512-JuLt0MXM2KHNFmjqXjv23sly56mJmUQzGBWktkpY3r+jE08f5NLKPd5wQ6W/SoLXGIOKnwLz0WoUg7aBVyQdeQ== - -"@sentry/cli-win32-arm64@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.51.1.tgz#0894f9a91e6ecb3021ca09fe644f995ff4ff826d" - integrity sha512-PiwjTdIFDazTQCTyDCutiSkt4omggYSKnO3HE1+LDjElsFrWY9pJs4fU3D40WAyE2oKu0MarjNH/WxYGdqEAlg== - -"@sentry/cli-win32-i686@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.51.1.tgz#6a6c6402cdce4fd038716b2c1e0bfa788b54f3e9" - integrity sha512-TMvZZpeiI2HmrDFNVQ0uOiTuYKvjEGOZdmUxe3WlhZW82A/2Oka7sQ24ljcOovbmBOj5+fjCHRUMYvLMCWiysA== - -"@sentry/cli-win32-x64@2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.51.1.tgz#d361e37146c9269d40c37459271a6c2cfa1fa8a6" - integrity sha512-v2hreYUPPTNK1/N7+DeX7XBN/zb7p539k+2Osf0HFyVBaoUC3Y3+KBwSf4ASsnmgTAK7HCGR+X0NH1vP+icw4w== - -"@sentry/cli@^2.51.0", "@sentry/cli@^2.51.1": - version "2.51.1" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.51.1.tgz#c6bdc6025e8f600e44fc76f8274c369aeb5d4df4" - integrity sha512-FU+54kNcKJABU0+ekvtnoXHM9zVrDe1zXVFbQT7mS0On0m1P0zFRGdzbnWe2XzpzuEAJXtK6aog/W+esRU9AIA== +"@sentry/bundler-plugin-core@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.1.tgz#7e273b83cc8b44f4067f05ab9ed5a7ec7ac6d625" + integrity sha512-Hx9RgXaD1HEYmL5aYoWwCKkVvPp4iklwfD9mvmdpQtcwLg6b6oLnPVDQaOry1ak6Pxt8smlrWcKy4IiKASlvig== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.1.1" + "@sentry/cli" "^2.51.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + +"@sentry/cli-darwin@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.52.0.tgz#05178cd819c2a33eb22a6e90bf7bb8f853f1b476" + integrity sha512-ieQs/p4yTHT27nBzy0wtAb8BSISfWlpXdgsACcwXimYa36NJRwyCqgOXUaH/BYiTdwWSHpuANbUHGJW6zljzxw== + +"@sentry/cli-linux-arm64@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.52.0.tgz#1979141afc93022614f868374ecc4d3090e84833" + integrity sha512-RxT5uzxjCkcvplmx0bavJIEYerRex2Rg/2RAVBdVvWLKFOcmeerTn/VVxPZVuDIVMVyjlZsteWPYwfUm+Ia3wQ== + +"@sentry/cli-linux-arm@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.52.0.tgz#6957e11af62e50d1040488ec75b3d96ae33fbb5a" + integrity sha512-tWMLU+hj+iip5Akx+S76biAOE1eMMWTDq8c0MqMv/ahHgb6/HiVngMcUsp59Oz3EczJGbTkcnS3vRTDodEcMDw== + +"@sentry/cli-linux-i686@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.52.0.tgz#e369ce3afa4b83a482d34cfd25fae4af792b211a" + integrity sha512-sKcJmIg7QWFtlNU5Bs5OZprwdIzzyYMRpFkWioPZ4TE82yvP1+2SAX31VPUlTx+7NLU6YVEWNwvSxh8LWb7iOw== + +"@sentry/cli-linux-x64@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.52.0.tgz#2b447afac1bb96624823a49c0d9f23c54475bff2" + integrity sha512-aPZ7bP02zGkuEqTiOAm4np/ggfgtzrq4ti1Xze96Csi/DV3820SCfLrPlsvcvnqq7x69IL9cI3kXjdEpgrfGxw== + +"@sentry/cli-win32-arm64@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.52.0.tgz#059063774ab5437ea05d82ce316faa77582b8b51" + integrity sha512-90hrB5XdwJVhRpCmVrEcYoKW8nl5/V9OfVvOGeKUPvUkApLzvsInK74FYBZEVyAn1i/NdUv+Xk9q2zqUGK1aLQ== + +"@sentry/cli-win32-i686@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.52.0.tgz#bee3cded721fcf45db2e77bf84ea8653e4d803d9" + integrity sha512-HXlSE4CaLylNrELx4KVmOQjV5bURCNuky6sjCWiTH7HyDqHEak2Rk8iLE0JNLj5RETWMvmaZnZZFfmyGlY1opg== + +"@sentry/cli-win32-x64@2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.52.0.tgz#16e501e5f00834b1f64765774c59740580043dfc" + integrity sha512-hJT0C3FwHk1Mt9oFqcci88wbO1D+yAWUL8J29HEGM5ZAqlhdh7sAtPDIC3P2LceUJOjnXihow47Bkj62juatIQ== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.52.0": + version "2.52.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.52.0.tgz#5162900bbfae57ddfc414bbe5780837622125aed" + integrity sha512-PXyo7Yv7+rVMSBGZfI/eFEzzhiKedTs25sDCjz4a3goAZ/F5R5tn3MKq30pnze5wNnoQmLujAa0uUjfNcWP+uQ== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -6989,21 +7008,21 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.51.1" - "@sentry/cli-linux-arm" "2.51.1" - "@sentry/cli-linux-arm64" "2.51.1" - "@sentry/cli-linux-i686" "2.51.1" - "@sentry/cli-linux-x64" "2.51.1" - "@sentry/cli-win32-arm64" "2.51.1" - "@sentry/cli-win32-i686" "2.51.1" - "@sentry/cli-win32-x64" "2.51.1" - -"@sentry/rollup-plugin@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.1.0.tgz#3948d067bd7cf8a61904b4042190dc9f6888bceb" - integrity sha512-HDwWgQRH7JhG15N1Y4XmPik/Qk03TGbiupDkZ8WL+8257BuyQE+s6feJJGCEUoWwROED+jvsFNvWvT2tqnILrw== + "@sentry/cli-darwin" "2.52.0" + "@sentry/cli-linux-arm" "2.52.0" + "@sentry/cli-linux-arm64" "2.52.0" + "@sentry/cli-linux-i686" "2.52.0" + "@sentry/cli-linux-x64" "2.52.0" + "@sentry/cli-win32-arm64" "2.52.0" + "@sentry/cli-win32-i686" "2.52.0" + "@sentry/cli-win32-x64" "2.52.0" + +"@sentry/rollup-plugin@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.1.1.tgz#ece90c337d1f78a2a445d3986b63321877fd4e41" + integrity sha512-AAZ9OzR2gsJRxgKN2k5jB+MxT13Uj2GJeSofi0EHbgu/yUdod8zTGX+4NRB90aXZIEOAc0Xrwnw1sm8nZYvaFw== dependencies: - "@sentry/bundler-plugin-core" "4.1.0" + "@sentry/bundler-plugin-core" "4.1.1" unplugin "1.0.1" "@sentry/vite-plugin@^4.1.0": @@ -7014,12 +7033,12 @@ "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" -"@sentry/webpack-plugin@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.1.0.tgz#e95e2dcd10e71dc8c3a16ba5cad9153f5e78c3bc" - integrity sha512-YqfDfyGAuT/9YW1kgAPfD7kGUKQCh1E5co+qMdToxi/Mz4xsWJY02rFS5GrJixYktYJfSMze8NiRr89yJMxYHw== +"@sentry/webpack-plugin@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.1.1.tgz#638c6b65cbc19b5027ffbb6bcd68094e0b0f82c6" + integrity sha512-2gFWcQMW1HdJDo/7rADeFs9crkH02l+mW4O1ORbxSjuegauyp1W8SBe7EfPoXbUmLdA3zwnpIxEXjjQpP5Etzg== dependencies: - "@sentry/bundler-plugin-core" "4.1.0" + "@sentry/bundler-plugin-core" "4.1.1" unplugin "1.0.1" uuid "^9.0.0"