From 8657dedb012ee890d6bc6749858c6e0a130c7c80 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Jun 2025 14:42:15 +0200 Subject: [PATCH 01/10] fix(react-router): Conditionally add `ReactRouterServer` integration (#16470) --- .../scripts/consistentExports.ts | 1 + packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + packages/react-router/src/server/sdk.ts | 17 +++-- packages/react-router/test/server/sdk.test.ts | 72 +++++++++++++++++++ 7 files changed, 88 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 8c3e51b14024..f355654bf6a2 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -50,6 +50,7 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // not supported in bun: 'NodeClient', + 'NODE_VERSION', 'childProcessIntegration', ], }, diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 29105cfb4b18..ac222eca825b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -134,6 +134,7 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + NODE_VERSION, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 942951c165da..24513a325188 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -120,6 +120,7 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + NODE_VERSION, } from '@sentry/node'; export { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index f5d593312743..f2622e591497 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -120,6 +120,7 @@ export { logger, consoleLoggingIntegration, wrapMcpServerWithSentry, + NODE_VERSION, } from '@sentry/node'; export { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5a933002bc23..589937b21fd4 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -54,6 +54,7 @@ export { createGetModuleFromFilename } from './utils/module'; export { makeNodeTransport } from './transports'; export { NodeClient } from './sdk/client'; export { cron } from './cron'; +export { NODE_VERSION } from './nodeVersion'; export type { NodeOptions } from './types'; diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 55eaf6962a28..07ea80e867ea 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -2,7 +2,7 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import type { EventProcessor, Integration } from '@sentry/core'; import { applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk, NODE_VERSION } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; import { lowQualityTransactionsFilterIntegration } from './integration/lowQualityTransactionsFilterIntegration'; @@ -13,11 +13,16 @@ import { reactRouterServerIntegration } from './integration/reactRouterServer'; * @param options The options for the SDK. */ export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] { - return [ - ...getNodeDefaultIntegrations(options), - lowQualityTransactionsFilterIntegration(options), - reactRouterServerIntegration(), - ]; + const integrations = [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)]; + + if ( + (NODE_VERSION.major === 20 && NODE_VERSION.minor < 19) || // https://nodejs.org/en/blog/release/v20.19.0 + (NODE_VERSION.major === 22 && NODE_VERSION.minor < 12) // https://nodejs.org/en/blog/release/v22.12.0 + ) { + integrations.push(reactRouterServerIntegration()); + } + + return integrations; } /** diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index fdb894299760..861144e3f62b 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -71,5 +71,77 @@ describe('React Router server SDK', () => { expect(filterIntegration).toBeDefined(); }); + + it('adds reactRouterServer integration for Node.js 20.18', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 18, patch: 0 }); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + + const reactRouterServerIntegration = defaultIntegrations.find( + integration => integration.name === 'ReactRouterServer', + ); + + expect(reactRouterServerIntegration).toBeDefined(); + }); + + it('adds reactRouterServer integration for Node.js 22.11', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 11, patch: 0 }); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + + const reactRouterServerIntegration = defaultIntegrations.find( + integration => integration.name === 'ReactRouterServer', + ); + + expect(reactRouterServerIntegration).toBeDefined(); + }); + + it('does not add reactRouterServer integration for Node.js 20.19', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 19, patch: 0 }); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + + const reactRouterServerIntegration = defaultIntegrations.find( + integration => integration.name === 'ReactRouterServer', + ); + + expect(reactRouterServerIntegration).toBeUndefined(); + }); + + it('does not add reactRouterServer integration for Node.js 22.12', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 12, patch: 0 }); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + + const reactRouterServerIntegration = defaultIntegrations.find( + integration => integration.name === 'ReactRouterServer', + ); + + expect(reactRouterServerIntegration).toBeUndefined(); + }); }); }); From b8dd2904d419bc1882708b623afc89027a137c1b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 4 Jun 2025 14:49:38 +0200 Subject: [PATCH 02/10] test(node): Add test demonstrating `withIsolationScope` data loss on uncaught errors (#16479) This PR adds a test demonstrating the unintuitive but expected behaviour reported in https://github.com/getsentry/sentry-javascript/issues/16460 see comment for more details --- .../handle-error-scope-data-loss/server.ts | 7 +++ .../handle-error-scope-data-loss/test.ts | 46 ++++++++++++++++++ .../handle-error-scope-data-loss/server.ts | 7 +++ .../handle-error-scope-data-loss/test.ts | 48 ++++++++++++++++++- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts index 693a22baef59..8f594e449162 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts @@ -25,6 +25,13 @@ app.get('/test/isolationScope', () => { throw new Error('isolation_test_error'); }); +app.get('/test/withIsolationScope', () => { + Sentry.withIsolationScope(iScope => { + iScope.setTag('with-isolation-scope', 'tag'); + throw new Error('with_isolation_scope_test_error'); + }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts index bd2f51c16dbd..306449b09569 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts @@ -86,3 +86,49 @@ test('isolation scope is applied to thrown error caught by global handler', asyn runner.makeRequest('get', '/test/isolationScope', { expectError: true }); await runner.completed(); }); + +/** + * This test shows that an inner isolation scope, created via `withIsolationScope`, is not applied to the error. + * + * This behaviour occurs because, just like in the test above where we use `getIsolationScope().setTag`, + * this isolation scope again is only valid as long as we're in the callback. + * + * So why _does_ the http isolation scope get applied then? Because express' error handler applies on + * a per-request basis, meaning, it's called while we're inside the isolation scope of the http request, + * created from our `httpIntegration`. + */ +test('withIsolationScope scope is NOT applied to thrown error caught by global handler', async () => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'with_isolation_scope_test_error', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + // 'with-isolation-scope' tag is not applied to the event + tags: expect.not.objectContaining({ 'with-isolation-scope': expect.anything() }), + }, + }) + .start(); + + runner.makeRequest('get', '/test/withIsolationScope', { expectError: true }); + + await runner.completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/server.ts index 693a22baef59..8f594e449162 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/server.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/server.ts @@ -25,6 +25,13 @@ app.get('/test/isolationScope', () => { throw new Error('isolation_test_error'); }); +app.get('/test/withIsolationScope', () => { + Sentry.withIsolationScope(iScope => { + iScope.setTag('with-isolation-scope', 'tag'); + throw new Error('with_isolation_scope_test_error'); + }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/test.ts index eda622f1cf6c..61291f86320d 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-scope-data-loss/test.ts @@ -53,7 +53,7 @@ test('withScope scope is NOT applied to thrown error caught by global handler', /** * This test shows that the isolation scope set tags are applied correctly to the error. */ -test('isolation scope is applied to thrown error caught by global handler', async () => { +test('http requestisolation scope is applied to thrown error caught by global handler', async () => { const runner = createRunner(__dirname, 'server.ts') .expect({ event: { @@ -90,3 +90,49 @@ test('isolation scope is applied to thrown error caught by global handler', asyn await runner.completed(); }); + +/** + * This test shows that an inner isolation scope, created via `withIsolationScope`, is not applied to the error. + * + * This behaviour occurs because, just like in the test above where we use `getIsolationScope().setTag`, + * this isolation scope again is only valid as long as we're in the callback. + * + * So why _does_ the http isolation scope get applied then? Because express' error handler applies on + * a per-request basis, meaning, it's called while we're inside the isolation scope of the http request, + * created from our `httpIntegration`. + */ +test('withIsolationScope scope is NOT applied to thrown error caught by global handler', async () => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'with_isolation_scope_test_error', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + // 'with-isolation-scope' tag is not applied to the event + tags: expect.not.objectContaining({ 'with-isolation-scope': expect.anything() }), + }, + }) + .start(); + + runner.makeRequest('get', '/test/withIsolationScope', { expectError: true }); + + await runner.completed(); +}); From 45088a2ab7858e23bedbff3205c20b6705dd546b Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 4 Jun 2025 10:53:31 -0400 Subject: [PATCH 03/10] feat(node): Switch to new semantic conventions for Vercel AI (#16476) resolves https://github.com/getsentry/sentry-javascript/issues/16453 In https://github.com/getsentry/sentry-conventions/pull/57 we deprecated some of the `ai.X` attributes in favour of OTEL's `gen_ai.X` attributes. This updates the Vercel AI integration to address these deprecations. These changes are based on https://ai-sdk.dev/docs/ai-sdk-core/telemetry#collected-data, and we created `attributes.ts` to track these as constants. See the relay change here to switch to new attributes for measuring token usage: https://github.com/getsentry/relay/pull/4768 --- .../suites/tracing/ai/test.ts | 50 +- .../tracing/vercelai/attributes.ts | 794 ++++++++++++++++++ .../integrations/tracing/vercelai/index.ts | 42 +- 3 files changed, 846 insertions(+), 40 deletions(-) create mode 100644 packages/node/src/integrations/tracing/vercelai/attributes.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/ai/test.ts index c0a3ccb4a78a..b97c2b688a69 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ai/test.ts @@ -12,20 +12,18 @@ describe('ai', () => { spans: expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ - 'ai.completion_tokens.used': 20, 'ai.model.id': 'mock-model-id', 'ai.model.provider': 'mock-provider', - 'ai.model_id': 'mock-model-id', 'ai.operationId': 'ai.generateText', 'ai.pipeline.name': 'generateText', - 'ai.prompt_tokens.used': 10, 'ai.response.finishReason': 'stop', 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, - 'ai.total_tokens.used': 30, - 'ai.usage.completionTokens': 20, - 'ai.usage.promptTokens': 10, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, 'operation.name': 'ai.generateText', 'sentry.op': 'ai.pipeline.generateText', 'sentry.origin': 'auto.vercelai.otel', @@ -47,18 +45,17 @@ describe('ai', () => { 'gen_ai.system': 'mock-provider', 'gen_ai.request.model': 'mock-model-id', 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.model_id': 'mock-model-id', 'ai.streaming': false, 'ai.response.finishReason': 'stop', 'ai.response.model': 'mock-model-id', - 'ai.usage.promptTokens': 10, - 'ai.usage.completionTokens': 20, + 'ai.response.id': expect.any(String), + 'ai.response.timestamp': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, 'gen_ai.usage.output_tokens': 20, - 'ai.completion_tokens.used': 20, - 'ai.prompt_tokens.used': 10, - 'ai.total_tokens.used': 30, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, }), description: 'generateText.doGenerate', op: 'ai.run.doGenerate', @@ -67,22 +64,21 @@ describe('ai', () => { }), expect.objectContaining({ data: expect.objectContaining({ - 'ai.completion_tokens.used': 20, 'ai.model.id': 'mock-model-id', 'ai.model.provider': 'mock-provider', - 'ai.model_id': 'mock-model-id', - 'ai.prompt': '{"prompt":"Where is the second span?"}', 'ai.operationId': 'ai.generateText', 'ai.pipeline.name': 'generateText', - 'ai.prompt_tokens.used': 10, + 'ai.prompt': '{"prompt":"Where is the second span?"}', 'ai.response.finishReason': 'stop', - 'ai.input_messages': '{"prompt":"Where is the second span?"}', + 'ai.response.text': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, - 'ai.total_tokens.used': 30, - 'ai.usage.completionTokens': 20, - 'ai.usage.promptTokens': 10, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, 'operation.name': 'ai.generateText', 'sentry.op': 'ai.pipeline.generateText', 'sentry.origin': 'auto.vercelai.otel', @@ -104,18 +100,20 @@ describe('ai', () => { 'gen_ai.system': 'mock-provider', 'gen_ai.request.model': 'mock-model-id', 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.model_id': 'mock-model-id', 'ai.streaming': false, 'ai.response.finishReason': 'stop', 'ai.response.model': 'mock-model-id', - 'ai.usage.promptTokens': 10, - 'ai.usage.completionTokens': 20, + 'ai.response.id': expect.any(String), + 'ai.response.text': expect.any(String), + 'ai.response.timestamp': expect.any(String), + 'ai.prompt.format': expect.any(String), + 'ai.prompt.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, 'gen_ai.usage.output_tokens': 20, - 'ai.completion_tokens.used': 20, - 'ai.prompt_tokens.used': 10, - 'ai.total_tokens.used': 30, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, }), description: 'generateText.doGenerate', op: 'ai.run.doGenerate', diff --git a/packages/node/src/integrations/tracing/vercelai/attributes.ts b/packages/node/src/integrations/tracing/vercelai/attributes.ts new file mode 100644 index 000000000000..8d7b6913a636 --- /dev/null +++ b/packages/node/src/integrations/tracing/vercelai/attributes.ts @@ -0,0 +1,794 @@ +/** + * AI SDK Telemetry Attributes + * Based on https://ai-sdk.dev/docs/ai-sdk-core/telemetry#collected-data + */ + +// ============================================================================= +// COMMON ATTRIBUTES +// ============================================================================= + +/** + * Common attribute for operation name across all functions and spans + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#collected-data + */ +export const OPERATION_NAME_ATTRIBUTE = 'operation.name'; + +/** + * Common attribute for AI operation ID across all functions and spans + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#collected-data + */ +export const AI_OPERATION_ID_ATTRIBUTE = 'ai.operationId'; + +// ============================================================================= +// SHARED ATTRIBUTES +// ============================================================================= + +/** + * `generateText` function - `ai.generateText` span + * `streamText` function - `ai.streamText` span + * + * The prompt that was used when calling the function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_PROMPT_ATTRIBUTE = 'ai.prompt'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The JSON schema version of the schema that was passed into the function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_SCHEMA_ATTRIBUTE = 'ai.schema'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The name of the schema that was passed into the function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_SCHEMA_NAME_ATTRIBUTE = 'ai.schema.name'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The description of the schema that was passed into the function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_SCHEMA_DESCRIPTION_ATTRIBUTE = 'ai.schema.description'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The object that was generated (stringified JSON) + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_RESPONSE_OBJECT_ATTRIBUTE = 'ai.response.object'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The object generation mode, e.g. `json` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_SETTINGS_MODE_ATTRIBUTE = 'ai.settings.mode'; + +/** + * `generateObject` function - `ai.generateObject` span + * `streamObject` function - `ai.streamObject` span + * + * The output type that was used, e.g. `object` or `no-schema` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_SETTINGS_OUTPUT_ATTRIBUTE = 'ai.settings.output'; + +/** + * `embed` function - `ai.embed.doEmbed` span + * `embedMany` function - `ai.embedMany` span + * + * The values that were passed into the function (array) + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embedmany-function + */ +export const AI_VALUES_ATTRIBUTE = 'ai.values'; + +/** + * `embed` function - `ai.embed.doEmbed` span + * `embedMany` function - `ai.embedMany` span + * + * An array of JSON-stringified embeddings + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embedmany-function + */ +export const AI_EMBEDDINGS_ATTRIBUTE = 'ai.embeddings'; + +// ============================================================================= +// GENERATETEXT FUNCTION - UNIQUE ATTRIBUTES +// ============================================================================= + +/** + * `generateText` function - `ai.generateText` span + * + * The text that was generated + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_RESPONSE_TEXT_ATTRIBUTE = 'ai.response.text'; + +/** + * `generateText` function - `ai.generateText` span + * + * The tool calls that were made as part of the generation (stringified JSON) + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'ai.response.toolCalls'; + +/** + * `generateText` function - `ai.generateText` span + * + * The reason why the generation finished + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_RESPONSE_FINISH_REASON_ATTRIBUTE = 'ai.response.finishReason'; + +/** + * `generateText` function - `ai.generateText` span + * + * The maximum number of steps that were set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_SETTINGS_MAX_STEPS_ATTRIBUTE = 'ai.settings.maxSteps'; + +/** + * `generateText` function - `ai.generateText.doGenerate` span + * + * The format of the prompt + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_PROMPT_FORMAT_ATTRIBUTE = 'ai.prompt.format'; + +/** + * `generateText` function - `ai.generateText.doGenerate` span + * + * The messages that were passed into the provider + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_PROMPT_MESSAGES_ATTRIBUTE = 'ai.prompt.messages'; + +/** + * `generateText` function - `ai.generateText.doGenerate` span + * + * Array of stringified tool definitions + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_PROMPT_TOOLS_ATTRIBUTE = 'ai.prompt.tools'; + +/** + * `generateText` function - `ai.generateText.doGenerate` span + * + * The stringified tool choice setting (JSON) + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_PROMPT_TOOL_CHOICE_ATTRIBUTE = 'ai.prompt.toolChoice'; + +// ============================================================================= +// STREAMTEXT FUNCTION - UNIQUE ATTRIBUTES +// ============================================================================= + +/** + * `streamText` function - `ai.streamText.doStream` span + * + * The time it took to receive the first chunk in milliseconds + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_RESPONSE_MS_TO_FIRST_CHUNK_ATTRIBUTE = 'ai.response.msToFirstChunk'; + +/** + * `streamText` function - `ai.streamText.doStream` span + * + * The time it took to receive the finish part of the LLM stream in milliseconds + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_RESPONSE_MS_TO_FINISH_ATTRIBUTE = 'ai.response.msToFinish'; + +/** + * `streamText` function - `ai.streamText.doStream` span + * + * The average completion tokens per second + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_RESPONSE_AVG_COMPLETION_TOKENS_PER_SECOND_ATTRIBUTE = 'ai.response.avgCompletionTokensPerSecond'; + +// ============================================================================= +// EMBED FUNCTION - UNIQUE ATTRIBUTES +// ============================================================================= + +/** + * `embed` function - `ai.embed` span + * + * The value that was passed into the `embed` function + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + */ +export const AI_VALUE_ATTRIBUTE = 'ai.value'; + +/** + * `embed` function - `ai.embed` span + * + * A JSON-stringified embedding + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + */ +export const AI_EMBEDDING_ATTRIBUTE = 'ai.embedding'; + +// ============================================================================= +// BASIC LLM SPAN INFORMATION +// ============================================================================= + +/** + * Basic LLM span information + * Multiple spans + * + * The functionId that was set through `telemetry.functionId` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const RESOURCE_NAME_ATTRIBUTE = 'resource.name'; + +/** + * Basic LLM span information + * Multiple spans + * + * The id of the model + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_MODEL_ID_ATTRIBUTE = 'ai.model.id'; + +/** + * Basic LLM span information + * Multiple spans + * + * The provider of the model + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_MODEL_PROVIDER_ATTRIBUTE = 'ai.model.provider'; + +/** + * Basic LLM span information + * Multiple spans + * + * The request headers that were passed in through `headers` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_REQUEST_HEADERS_ATTRIBUTE = 'ai.request.headers'; + +/** + * Basic LLM span information + * Multiple spans + * + * The maximum number of retries that were set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'ai.settings.maxRetries'; + +/** + * Basic LLM span information + * Multiple spans + * + * The functionId that was set through `telemetry.functionId` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE = 'ai.telemetry.functionId'; + +/** + * Basic LLM span information + * Multiple spans + * + * The metadata that was passed in through `telemetry.metadata` + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_TELEMETRY_METADATA_ATTRIBUTE = 'ai.telemetry.metadata'; + +/** + * Basic LLM span information + * Multiple spans + * + * The number of completion tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE = 'ai.usage.completionTokens'; + +/** + * Basic LLM span information + * Multiple spans + * + * The number of prompt tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'ai.usage.promptTokens'; + +// ============================================================================= +// CALL LLM SPAN INFORMATION +// ============================================================================= + +/** + * Call LLM span information + * Individual LLM call spans + * + * The model that was used to generate the response + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const AI_RESPONSE_MODEL_ATTRIBUTE = 'ai.response.model'; + +/** + * Call LLM span information + * Individual LLM call spans + * + * The id of the response + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const AI_RESPONSE_ID_ATTRIBUTE = 'ai.response.id'; + +/** + * Call LLM span information + * Individual LLM call spans + * + * The timestamp of the response + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const AI_RESPONSE_TIMESTAMP_ATTRIBUTE = 'ai.response.timestamp'; + +// ============================================================================= +// SEMANTIC CONVENTIONS FOR GENAI OPERATIONS +// ============================================================================= + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The provider that was used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_SYSTEM_ATTRIBUTE = 'gen_ai.system'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The model that was requested + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_MODEL_ATTRIBUTE = 'gen_ai.request.model'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The temperature that was set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE = 'gen_ai.request.temperature'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The maximum number of tokens that were set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE = 'gen_ai.request.max_tokens'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The frequency penalty that was set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE = 'gen_ai.request.frequency_penalty'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The presence penalty that was set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE = 'gen_ai.request.presence_penalty'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The topK parameter value that was set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_TOP_K_ATTRIBUTE = 'gen_ai.request.top_k'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The topP parameter value that was set + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_TOP_P_ATTRIBUTE = 'gen_ai.request.top_p'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The stop sequences + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE = 'gen_ai.request.stop_sequences'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The finish reasons that were returned by the provider + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE = 'gen_ai.response.finish_reasons'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The model that was used to generate the response + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_RESPONSE_MODEL_ATTRIBUTE = 'gen_ai.response.model'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The id of the response + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_RESPONSE_ID_ATTRIBUTE = 'gen_ai.response.id'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The number of prompt tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.input_tokens'; + +/** + * Semantic Conventions for GenAI operations + * Individual LLM call spans + * + * The number of completion tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#call-llm-span-information + */ +export const GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.output_tokens'; + +// ============================================================================= +// BASIC EMBEDDING SPAN INFORMATION +// ============================================================================= + +/** + * Basic embedding span information + * Embedding spans + * + * The number of tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-embedding-span-information + */ +export const AI_USAGE_TOKENS_ATTRIBUTE = 'ai.usage.tokens'; + +// ============================================================================= +// TOOL CALL SPANS +// ============================================================================= + +/** + * Tool call spans + * `ai.toolCall` span + * + * The name of the tool + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + */ +export const AI_TOOL_CALL_NAME_ATTRIBUTE = 'ai.toolCall.name'; + +/** + * Tool call spans + * `ai.toolCall` span + * + * The id of the tool call + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + */ +export const AI_TOOL_CALL_ID_ATTRIBUTE = 'ai.toolCall.id'; + +/** + * Tool call spans + * `ai.toolCall` span + * + * The parameters of the tool call + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + */ +export const AI_TOOL_CALL_ARGS_ATTRIBUTE = 'ai.toolCall.args'; + +/** + * Tool call spans + * `ai.toolCall` span + * + * The result of the tool call + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + */ +export const AI_TOOL_CALL_RESULT_ATTRIBUTE = 'ai.toolCall.result'; + +// ============================================================================= +// SPAN ATTRIBUTE OBJECTS +// ============================================================================= + +/** + * Attributes collected for `ai.generateText` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_GENERATE_TEXT_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_PROMPT: AI_PROMPT_ATTRIBUTE, + AI_RESPONSE_TEXT: AI_RESPONSE_TEXT_ATTRIBUTE, + AI_RESPONSE_TOOL_CALLS: AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + AI_RESPONSE_FINISH_REASON: AI_RESPONSE_FINISH_REASON_ATTRIBUTE, + AI_SETTINGS_MAX_STEPS: AI_SETTINGS_MAX_STEPS_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.generateText.doGenerate` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generatetext-function + */ +export const AI_GENERATE_TEXT_DO_GENERATE_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_PROMPT_FORMAT: AI_PROMPT_FORMAT_ATTRIBUTE, + AI_PROMPT_MESSAGES: AI_PROMPT_MESSAGES_ATTRIBUTE, + AI_PROMPT_TOOLS: AI_PROMPT_TOOLS_ATTRIBUTE, + AI_PROMPT_TOOL_CHOICE: AI_PROMPT_TOOL_CHOICE_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, + // Call LLM span information + AI_RESPONSE_MODEL: AI_RESPONSE_MODEL_ATTRIBUTE, + AI_RESPONSE_ID: AI_RESPONSE_ID_ATTRIBUTE, + AI_RESPONSE_TIMESTAMP: AI_RESPONSE_TIMESTAMP_ATTRIBUTE, + // Semantic Conventions for GenAI operations + GEN_AI_SYSTEM: GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_REQUEST_MODEL: GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE: GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS: GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY: GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY: GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_TOP_K: GEN_AI_REQUEST_TOP_K_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P: GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_REQUEST_STOP_SEQUENCES: GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS: GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL: GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_ID: GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS: GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS: GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.streamText` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_STREAM_TEXT_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_PROMPT: AI_PROMPT_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.streamText.doStream` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamtext-function + */ +export const AI_STREAM_TEXT_DO_STREAM_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_RESPONSE_MS_TO_FIRST_CHUNK: AI_RESPONSE_MS_TO_FIRST_CHUNK_ATTRIBUTE, + AI_RESPONSE_MS_TO_FINISH: AI_RESPONSE_MS_TO_FINISH_ATTRIBUTE, + AI_RESPONSE_AVG_COMPLETION_TOKENS_PER_SECOND: AI_RESPONSE_AVG_COMPLETION_TOKENS_PER_SECOND_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, + // Call LLM span information + AI_RESPONSE_MODEL: AI_RESPONSE_MODEL_ATTRIBUTE, + AI_RESPONSE_ID: AI_RESPONSE_ID_ATTRIBUTE, + AI_RESPONSE_TIMESTAMP: AI_RESPONSE_TIMESTAMP_ATTRIBUTE, + // Semantic Conventions for GenAI operations + GEN_AI_SYSTEM: GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_REQUEST_MODEL: GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE: GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS: GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY: GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY: GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_TOP_K: GEN_AI_REQUEST_TOP_K_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P: GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_REQUEST_STOP_SEQUENCES: GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS: GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL: GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_ID: GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS: GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS: GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.generateObject` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#generateobject-function + */ +export const AI_GENERATE_OBJECT_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_SCHEMA: AI_SCHEMA_ATTRIBUTE, + AI_SCHEMA_NAME: AI_SCHEMA_NAME_ATTRIBUTE, + AI_SCHEMA_DESCRIPTION: AI_SCHEMA_DESCRIPTION_ATTRIBUTE, + AI_RESPONSE_OBJECT: AI_RESPONSE_OBJECT_ATTRIBUTE, + AI_SETTINGS_MODE: AI_SETTINGS_MODE_ATTRIBUTE, + AI_SETTINGS_OUTPUT: AI_SETTINGS_OUTPUT_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.streamObject` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#streamobject-function + */ +export const AI_STREAM_OBJECT_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_SCHEMA: AI_SCHEMA_ATTRIBUTE, + AI_SCHEMA_NAME: AI_SCHEMA_NAME_ATTRIBUTE, + AI_SCHEMA_DESCRIPTION: AI_SCHEMA_DESCRIPTION_ATTRIBUTE, + AI_RESPONSE_OBJECT: AI_RESPONSE_OBJECT_ATTRIBUTE, + AI_SETTINGS_MODE: AI_SETTINGS_MODE_ATTRIBUTE, + AI_SETTINGS_OUTPUT: AI_SETTINGS_OUTPUT_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS: AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS: AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.embed` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + */ +export const AI_EMBED_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_VALUE: AI_VALUE_ATTRIBUTE, + AI_EMBEDDING: AI_EMBEDDING_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + // Basic embedding span information + AI_USAGE_TOKENS: AI_USAGE_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.embed.doEmbed` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embed-function + */ +export const AI_EMBED_DO_EMBED_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_VALUES: AI_VALUES_ATTRIBUTE, + AI_EMBEDDINGS: AI_EMBEDDINGS_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + // Basic embedding span information + AI_USAGE_TOKENS: AI_USAGE_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.embedMany` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#embedmany-function + */ +export const AI_EMBED_MANY_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_VALUES: AI_VALUES_ATTRIBUTE, + AI_EMBEDDINGS: AI_EMBEDDINGS_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, + // Basic embedding span information + AI_USAGE_TOKENS: AI_USAGE_TOKENS_ATTRIBUTE, +} as const; + +/** + * Attributes collected for `ai.toolCall` span + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + */ +export const AI_TOOL_CALL_SPAN_ATTRIBUTES = { + OPERATION_NAME: OPERATION_NAME_ATTRIBUTE, + AI_OPERATION_ID: AI_OPERATION_ID_ATTRIBUTE, + AI_TOOL_CALL_NAME: AI_TOOL_CALL_NAME_ATTRIBUTE, + AI_TOOL_CALL_ID: AI_TOOL_CALL_ID_ATTRIBUTE, + AI_TOOL_CALL_ARGS: AI_TOOL_CALL_ARGS_ATTRIBUTE, + AI_TOOL_CALL_RESULT: AI_TOOL_CALL_RESULT_ATTRIBUTE, + // Basic LLM span information + RESOURCE_NAME: RESOURCE_NAME_ATTRIBUTE, + AI_MODEL_ID: AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER: AI_MODEL_PROVIDER_ATTRIBUTE, + AI_REQUEST_HEADERS: AI_REQUEST_HEADERS_ATTRIBUTE, + AI_SETTINGS_MAX_RETRIES: AI_SETTINGS_MAX_RETRIES_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID: AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TELEMETRY_METADATA: AI_TELEMETRY_METADATA_ATTRIBUTE, +} as const; diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index f68b95f0f815..92fe69bea673 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -3,6 +3,16 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; import { generateInstrumentOnce } from '../../../otel/instrument'; import { addOriginToSpan } from '../../../utils/addOriginToSpan'; +import { + AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER_ATTRIBUTE, + AI_PROMPT_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, +} from './attributes'; import { SentryVercelAiInstrumentation } from './instrumentation'; const INTEGRATION_NAME = 'VercelAI'; @@ -27,10 +37,10 @@ const _vercelAIIntegration = (() => { } // The id of the model - const aiModelId = attributes['ai.model.id']; + const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE]; // the provider of the model - const aiModelProvider = attributes['ai.model.provider']; + const aiModelProvider = attributes[AI_MODEL_PROVIDER_ATTRIBUTE]; // both of these must be defined for the integration to work if (typeof aiModelId !== 'string' || typeof aiModelProvider !== 'string' || !aiModelId || !aiModelProvider) { @@ -114,11 +124,11 @@ const _vercelAIIntegration = (() => { span.setAttribute('ai.pipeline.name', functionId); } - if (attributes['ai.prompt']) { - span.setAttribute('ai.input_messages', attributes['ai.prompt']); + if (attributes[AI_PROMPT_ATTRIBUTE]) { + span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); } - if (attributes['ai.model.id']) { - span.setAttribute('ai.model_id', attributes['ai.model.id']); + if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { + span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); } span.setAttribute('ai.streaming', name.includes('stream')); }); @@ -132,18 +142,22 @@ const _vercelAIIntegration = (() => { continue; } - if (attributes['ai.usage.completionTokens'] != undefined) { - attributes['ai.completion_tokens.used'] = attributes['ai.usage.completionTokens']; + if (attributes[AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE] != undefined) { + attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = attributes[AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE]; } - if (attributes['ai.usage.promptTokens'] != undefined) { - attributes['ai.prompt_tokens.used'] = attributes['ai.usage.promptTokens']; + if (attributes[AI_USAGE_PROMPT_TOKENS_ATTRIBUTE] != undefined) { + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = attributes[AI_USAGE_PROMPT_TOKENS_ATTRIBUTE]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[AI_USAGE_PROMPT_TOKENS_ATTRIBUTE]; } if ( - typeof attributes['ai.usage.completionTokens'] == 'number' && - typeof attributes['ai.usage.promptTokens'] == 'number' + typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && + typeof attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] === 'number' ) { - attributes['ai.total_tokens.used'] = - attributes['ai.usage.completionTokens'] + attributes['ai.usage.promptTokens']; + attributes['gen_ai.usage.total_tokens'] = + attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; } } } From bfe5e888b1124e86e19aa50ffe95df501776fc10 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 4 Jun 2025 23:41:35 -0400 Subject: [PATCH 04/10] feat(node): Expand how vercel ai input/outputs can be set (#16455) resolves https://github.com/getsentry/sentry-javascript/issues/16452 Based on feedback, we want to enable attaching prompts (inputs/outputs) to vercel AI spans if `sendDefaultPii` is `true`. We also add new integration options to the Vercel AI integration that allows you to override this. ```ts export interface VercelAiOptions { /** * Enable or disable input recording. Enabled if `sendDefaultPii` is `true` * or if you set `isEnabled` to `true` in your ai SDK method telemetry settings */ recordInputs?: boolean; /** * Enable or disable output recording. Enabled if `sendDefaultPii` is `true` * or if you set `isEnabled` to `true` in your ai SDK method telemetry settings */ recordOutputs?: boolean; } ``` Usage: ```ts Sentry.vercelAIIntegration({ recordInputs: true }); ``` --- .../node-integration-tests/package.json | 2 +- .../suites/tracing/ai/test.ts | 131 --------- .../tracing/vercelai/instrument-with-pii.mjs | 11 + .../tracing/{ai => vercelai}/instrument.mjs | 1 + .../tracing/{ai => vercelai}/scenario.mjs | 0 .../suites/tracing/vercelai/test.ts | 267 ++++++++++++++++++ .../tracing/vercelai/constants.ts | 1 + .../integrations/tracing/vercelai/index.ts | 7 +- .../tracing/vercelai/instrumentation.ts | 74 ++++- .../integrations/tracing/vercelai/types.ts | 19 ++ .../tracing/vercelai/instrumentation.test.ts | 214 ++++++++++++++ yarn.lock | 241 +++------------- 12 files changed, 619 insertions(+), 349 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/ai/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs rename dev-packages/node-integration-tests/suites/tracing/{ai => vercelai}/instrument.mjs (84%) rename dev-packages/node-integration-tests/suites/tracing/{ai => vercelai}/scenario.mjs (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts create mode 100644 packages/node/src/integrations/tracing/vercelai/constants.ts create mode 100644 packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index c5b446a70536..4f0cb5849c76 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -36,7 +36,7 @@ "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", - "ai": "^4.0.6", + "ai": "^4.3.16", "amqplib": "^0.10.7", "apollo-server": "^3.11.1", "body-parser": "^1.20.3", diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/ai/test.ts deleted file mode 100644 index b97c2b688a69..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; - -// `ai` SDK only support Node 18+ -describe('ai', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - const EXPECTED_TRANSACTION = { - transaction: 'main', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.response.finishReason': 'stop', - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.usage.total_tokens': 30, - 'operation.name': 'ai.generateText', - 'sentry.op': 'ai.pipeline.generateText', - 'sentry.origin': 'auto.vercelai.otel', - }), - description: 'generateText', - op: 'ai.pipeline.generateText', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.vercelai.otel', - 'sentry.op': 'ai.run.doGenerate', - 'operation.name': 'ai.generateText.doGenerate', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.model.provider': 'mock-provider', - 'ai.model.id': 'mock-model-id', - 'ai.settings.maxRetries': 2, - 'gen_ai.system': 'mock-provider', - 'gen_ai.request.model': 'mock-model-id', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.streaming': false, - 'ai.response.finishReason': 'stop', - 'ai.response.model': 'mock-model-id', - 'ai.response.id': expect.any(String), - 'ai.response.timestamp': expect.any(String), - 'gen_ai.response.finish_reasons': ['stop'], - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.response.id': expect.any(String), - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.total_tokens': 30, - }), - description: 'generateText.doGenerate', - op: 'ai.run.doGenerate', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.prompt': '{"prompt":"Where is the second span?"}', - 'ai.response.finishReason': 'stop', - 'ai.response.text': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, - 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.usage.total_tokens': 30, - 'operation.name': 'ai.generateText', - 'sentry.op': 'ai.pipeline.generateText', - 'sentry.origin': 'auto.vercelai.otel', - }), - description: 'generateText', - op: 'ai.pipeline.generateText', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.origin': 'auto.vercelai.otel', - 'sentry.op': 'ai.run.doGenerate', - 'operation.name': 'ai.generateText.doGenerate', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.model.provider': 'mock-provider', - 'ai.model.id': 'mock-model-id', - 'ai.settings.maxRetries': 2, - 'gen_ai.system': 'mock-provider', - 'gen_ai.request.model': 'mock-model-id', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.streaming': false, - 'ai.response.finishReason': 'stop', - 'ai.response.model': 'mock-model-id', - 'ai.response.id': expect.any(String), - 'ai.response.text': expect.any(String), - 'ai.response.timestamp': expect.any(String), - 'ai.prompt.format': expect.any(String), - 'ai.prompt.messages': expect.any(String), - 'gen_ai.response.finish_reasons': ['stop'], - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.response.id': expect.any(String), - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.total_tokens': 30, - }), - description: 'generateText.doGenerate', - op: 'ai.run.doGenerate', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - ]), - }; - - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('creates ai related spans ', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs new file mode 100644 index 000000000000..b798e21228f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs similarity index 84% rename from dev-packages/node-integration-tests/suites/tracing/ai/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs index 46a27dd03b74..5e898ee1949d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ai/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs @@ -6,4 +6,5 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/ai/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts new file mode 100644 index 000000000000..7876dbccb440 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -0,0 +1,267 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// `ai` SDK only support Node 18+ +describe('Vercel AI integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry but not record inputs/outputs when sendDefaultPii: false + expect.objectContaining({ + data: { + 'ai.model.id': 'mock-model-id', + 'ai.model.provider': 'mock-provider', + 'ai.operationId': 'ai.generateText', + 'ai.pipeline.name': 'generateText', + 'ai.response.finishReason': 'stop', + 'ai.settings.maxRetries': 2, + 'ai.settings.maxSteps': 1, + 'ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'ai.pipeline.generateText', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'ai.pipeline.generateText', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - explicitly enabled telemetry but recordInputs/recordOutputs not set, should not record when sendDefaultPii: false + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'ai.run.doGenerate', + 'operation.name': 'ai.generateText.doGenerate', + 'ai.operationId': 'ai.generateText.doGenerate', + 'ai.model.provider': 'mock-provider', + 'ai.model.id': 'mock-model-id', + 'ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'ai.pipeline.name': 'generateText.doGenerate', + 'ai.streaming': false, + 'ai.response.finishReason': 'stop', + 'ai.response.model': 'mock-model-id', + 'ai.response.id': expect.any(String), + 'ai.response.timestamp': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generateText.doGenerate', + op: 'ai.run.doGenerate', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicit telemetry enabled, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: { + 'ai.model.id': 'mock-model-id', + 'ai.model.provider': 'mock-provider', + 'ai.operationId': 'ai.generateText', + 'ai.pipeline.name': 'generateText', + 'ai.prompt': '{"prompt":"Where is the second span?"}', + 'ai.response.finishReason': 'stop', + 'ai.response.text': expect.any(String), + 'ai.settings.maxRetries': 2, + 'ai.settings.maxSteps': 1, + 'ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'ai.pipeline.generateText', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'ai.pipeline.generateText', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicit telemetry enabled call + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'ai.run.doGenerate', + 'operation.name': 'ai.generateText.doGenerate', + 'ai.operationId': 'ai.generateText.doGenerate', + 'ai.model.provider': 'mock-provider', + 'ai.model.id': 'mock-model-id', + 'ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'ai.pipeline.name': 'generateText.doGenerate', + 'ai.streaming': false, + 'ai.response.finishReason': 'stop', + 'ai.response.model': 'mock-model-id', + 'ai.response.id': expect.any(String), + 'ai.response.text': expect.any(String), + 'ai.response.timestamp': expect.any(String), + 'ai.prompt.format': expect.any(String), + 'ai.prompt.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generateText.doGenerate', + op: 'ai.run.doGenerate', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry AND record inputs/outputs when sendDefaultPii: true + expect.objectContaining({ + data: { + 'ai.model.id': 'mock-model-id', + 'ai.model.provider': 'mock-provider', + 'ai.operationId': 'ai.generateText', + 'ai.pipeline.name': 'generateText', + 'ai.prompt': '{"prompt":"Where is the first span?"}', + 'ai.response.finishReason': 'stop', + 'ai.response.text': 'First span here!', + 'ai.settings.maxRetries': 2, + 'ai.settings.maxSteps': 1, + 'ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'ai.pipeline.generateText', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'ai.pipeline.generateText', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true + expect.objectContaining({ + data: { + 'ai.model.id': 'mock-model-id', + 'ai.model.provider': 'mock-provider', + 'ai.operationId': 'ai.generateText.doGenerate', + 'ai.pipeline.name': 'generateText.doGenerate', + 'ai.prompt.format': 'prompt', + 'ai.prompt.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', + 'ai.response.finishReason': 'stop', + 'ai.response.id': expect.any(String), + 'ai.response.model': 'mock-model-id', + 'ai.response.text': 'First span here!', + 'ai.response.timestamp': expect.any(String), + 'ai.settings.maxRetries': 2, + 'ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'ai.run.doGenerate', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText.doGenerate', + op: 'ai.run.doGenerate', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicitly enabled telemetry, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: { + 'ai.model.id': 'mock-model-id', + 'ai.model.provider': 'mock-provider', + 'ai.operationId': 'ai.generateText', + 'ai.pipeline.name': 'generateText', + 'ai.prompt': '{"prompt":"Where is the second span?"}', + 'ai.response.finishReason': 'stop', + 'ai.response.text': expect.any(String), + 'ai.settings.maxRetries': 2, + 'ai.settings.maxSteps': 1, + 'ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'ai.pipeline.generateText', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'ai.pipeline.generateText', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicitly enabled telemetry call + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'ai.run.doGenerate', + 'operation.name': 'ai.generateText.doGenerate', + 'ai.operationId': 'ai.generateText.doGenerate', + 'ai.model.provider': 'mock-provider', + 'ai.model.id': 'mock-model-id', + 'ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'ai.pipeline.name': 'generateText.doGenerate', + 'ai.streaming': false, + 'ai.response.finishReason': 'stop', + 'ai.response.model': 'mock-model-id', + 'ai.response.id': expect.any(String), + 'ai.response.text': expect.any(String), + 'ai.response.timestamp': expect.any(String), + 'ai.prompt.format': expect.any(String), + 'ai.prompt.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generateText.doGenerate', + op: 'ai.run.doGenerate', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); + }); + }); +}); diff --git a/packages/node/src/integrations/tracing/vercelai/constants.ts b/packages/node/src/integrations/tracing/vercelai/constants.ts new file mode 100644 index 000000000000..fd4473c4c084 --- /dev/null +++ b/packages/node/src/integrations/tracing/vercelai/constants.ts @@ -0,0 +1 @@ +export const INTEGRATION_NAME = 'VercelAI'; diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index 92fe69bea673..44bc2dca915f 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -13,17 +13,18 @@ import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from './attributes'; +import { INTEGRATION_NAME } from './constants'; import { SentryVercelAiInstrumentation } from './instrumentation'; - -const INTEGRATION_NAME = 'VercelAI'; +import type { VercelAiOptions } from './types'; export const instrumentVercelAi = generateInstrumentOnce(INTEGRATION_NAME, () => new SentryVercelAiInstrumentation({})); -const _vercelAIIntegration = (() => { +const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { let instrumentation: undefined | SentryVercelAiInstrumentation; return { name: INTEGRATION_NAME, + options, setupOnce() { instrumentation = instrumentVercelAi(); }, diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index e4f8a5ba25ae..4b823670793a 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,7 +1,8 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { SDK_VERSION } from '@sentry/core'; -import type { TelemetrySettings } from './types'; +import { getCurrentScope, SDK_VERSION } from '@sentry/core'; +import { INTEGRATION_NAME } from './constants'; +import type { TelemetrySettings, VercelAiIntegration } from './types'; // List of patched methods // From: https://sdk.vercel.ai/docs/ai-sdk-core/telemetry#collected-data @@ -23,6 +24,47 @@ type MethodArgs = [MethodFirstArg, ...unknown[]]; type PatchedModuleExports = Record<(typeof INSTRUMENTED_METHODS)[number], (...args: MethodArgs) => unknown> & Record; +interface RecordingOptions { + recordInputs?: boolean; + recordOutputs?: boolean; +} + +/** + * Determines whether to record inputs and outputs for Vercel AI telemetry based on the configuration hierarchy. + * + * The order of precedence is: + * 1. The vercel ai integration options + * 2. The experimental_telemetry options in the vercel ai method calls + * 3. When telemetry is explicitly enabled (isEnabled: true), default to recording + * 4. Otherwise, use the sendDefaultPii option from client options + */ +export function determineRecordingSettings( + integrationRecordingOptions: RecordingOptions | undefined, + methodTelemetryOptions: RecordingOptions, + telemetryExplicitlyEnabled: boolean | undefined, + defaultRecordingEnabled: boolean, +): { recordInputs: boolean; recordOutputs: boolean } { + const recordInputs = + integrationRecordingOptions?.recordInputs !== undefined + ? integrationRecordingOptions.recordInputs + : methodTelemetryOptions.recordInputs !== undefined + ? methodTelemetryOptions.recordInputs + : telemetryExplicitlyEnabled === true + ? true // When telemetry is explicitly enabled, default to recording inputs + : defaultRecordingEnabled; + + const recordOutputs = + integrationRecordingOptions?.recordOutputs !== undefined + ? integrationRecordingOptions.recordOutputs + : methodTelemetryOptions.recordOutputs !== undefined + ? methodTelemetryOptions.recordOutputs + : telemetryExplicitlyEnabled === true + ? true // When telemetry is explicitly enabled, default to recording inputs + : defaultRecordingEnabled; + + return { recordInputs, recordOutputs }; +} + /** * This detects is added by the Sentry Vercel AI Integration to detect if the integration should * be enabled. @@ -71,16 +113,24 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { const existingExperimentalTelemetry = args[0].experimental_telemetry || {}; const isEnabled = existingExperimentalTelemetry.isEnabled; - // if `isEnabled` is not explicitly set to `true` or `false`, enable telemetry - // but disable capturing inputs and outputs by default - if (isEnabled === undefined) { - args[0].experimental_telemetry = { - isEnabled: true, - recordInputs: false, - recordOutputs: false, - ...existingExperimentalTelemetry, - }; - } + const client = getCurrentScope().getClient(); + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + const integrationOptions = integration?.options; + const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; + + const { recordInputs, recordOutputs } = determineRecordingSettings( + integrationOptions, + existingExperimentalTelemetry, + isEnabled, + shouldRecordInputsAndOutputs, + ); + + args[0].experimental_telemetry = { + ...existingExperimentalTelemetry, + isEnabled: isEnabled !== undefined ? isEnabled : true, + recordInputs, + recordOutputs, + }; // @ts-expect-error we know that the method exists return originalMethod.apply(this, args); diff --git a/packages/node/src/integrations/tracing/vercelai/types.ts b/packages/node/src/integrations/tracing/vercelai/types.ts index 8773f84d52c6..50434b70604f 100644 --- a/packages/node/src/integrations/tracing/vercelai/types.ts +++ b/packages/node/src/integrations/tracing/vercelai/types.ts @@ -1,3 +1,5 @@ +import type { Integration } from '@sentry/core'; + /** * Telemetry configuration. */ @@ -42,3 +44,20 @@ export declare type AttributeValue = | Array | Array | Array; + +export interface VercelAiOptions { + /** + * Enable or disable input recording. Enabled if `sendDefaultPii` is `true` + * or if you set `isEnabled` to `true` in your ai SDK method telemetry settings + */ + recordInputs?: boolean; + /** + * Enable or disable output recording. Enabled if `sendDefaultPii` is `true` + * or if you set `isEnabled` to `true` in your ai SDK method telemetry settings + */ + recordOutputs?: boolean; +} + +export interface VercelAiIntegration extends Integration { + options: VercelAiOptions; +} diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts new file mode 100644 index 000000000000..9a9d8cc50f0a --- /dev/null +++ b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from 'vitest'; +import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation'; + +describe('determineRecordingSettings', () => { + test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => { + const result = determineRecordingSettings( + { recordInputs: true, recordOutputs: false }, // integrationRecordingOptions + {}, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: false, + }); + }); + + test('should use integration recording options when provided (recordInputs: false, recordOutputs: true)', () => { + const result = determineRecordingSettings( + { recordInputs: false, recordOutputs: true }, // integrationRecordingOptions + {}, // methodTelemetryOptions + true, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, + recordOutputs: true, + }); + }); + + test('should fall back to method telemetry options when integration options not provided', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + { recordInputs: true, recordOutputs: false }, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: false, + }); + }); + + test('should prefer integration recording options over method telemetry options', () => { + const result = determineRecordingSettings( + { recordInputs: false, recordOutputs: false }, // integrationRecordingOptions + { recordInputs: true, recordOutputs: true }, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, + recordOutputs: false, + }); + }); + + test('should default to recording when telemetry is explicitly enabled', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + {}, // methodTelemetryOptions + true, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: true, + }); + }); + + test('should use default recording setting when telemetry is explicitly disabled', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + {}, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: true, + }); + }); + + test('should use default recording setting when telemetry enablement is undefined', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + {}, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: true, + }); + }); + + test('should not record when default recording is disabled and no explicit configuration', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + {}, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, + recordOutputs: false, + }); + }); + + test('should handle partial integration recording options (only recordInputs)', () => { + const result = determineRecordingSettings( + { recordInputs: true }, // integrationRecordingOptions + {}, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: false, // falls back to defaultRecordingEnabled + }); + }); + + test('should handle partial integration recording options (only recordOutputs)', () => { + const result = determineRecordingSettings( + { recordOutputs: true }, // integrationRecordingOptions + {}, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, // falls back to defaultRecordingEnabled + recordOutputs: true, + }); + }); + + test('should handle partial method telemetry options (only recordInputs)', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + { recordInputs: true }, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: false, // falls back to defaultRecordingEnabled + }); + }); + + test('should handle partial method telemetry options (only recordOutputs)', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + { recordOutputs: true }, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, // falls back to defaultRecordingEnabled + recordOutputs: true, + }); + }); + + test('should prefer integration recording options over method telemetry for partial configs', () => { + const result = determineRecordingSettings( + { recordInputs: false }, // integrationRecordingOptions + { recordInputs: true, recordOutputs: true }, // methodTelemetryOptions + false, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled + ); + + expect(result).toEqual({ + recordInputs: false, // from integration recording options + recordOutputs: true, // from method telemetry options + }); + }); + + test('complex scenario: sendDefaultPii enabled, telemetry enablement undefined, mixed options', () => { + const result = determineRecordingSettings( + { recordOutputs: false }, // integrationRecordingOptions + { recordInputs: false }, // methodTelemetryOptions + undefined, // telemetryExplicitlyEnabled + true, // defaultRecordingEnabled (sendDefaultPii: true) + ); + + expect(result).toEqual({ + recordInputs: false, // from method telemetry options + recordOutputs: false, // from integration recording options + }); + }); + + test('complex scenario: explicit telemetry enabled overrides sendDefaultPii disabled', () => { + const result = determineRecordingSettings( + {}, // integrationRecordingOptions + {}, // methodTelemetryOptions + true, // telemetryExplicitlyEnabled + false, // defaultRecordingEnabled (sendDefaultPii: false) + ); + + expect(result).toEqual({ + recordInputs: true, + recordOutputs: true, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index a547a6d053a5..f8ee2a1e749b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,41 +80,40 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.2.tgz#c836b1bd81e6d62cd6cdf3ee4948bcdce8ea79c8" integrity sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A== -"@ai-sdk/provider-utils@2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-2.0.2.tgz#ea9d510be442b38bd40ae50dbf5b64ffc396952b" - integrity sha512-IAvhKhdlXqiSmvx/D4uNlFYCl8dWT+M9K+IuEcSgnE2Aj27GWu8sDIpAf4r4Voc+wOUkOECVKQhFo8g9pozdjA== +"@ai-sdk/provider-utils@2.2.8": + version "2.2.8" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz#ad11b92d5a1763ab34ba7b5fc42494bfe08b76d1" + integrity sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA== dependencies: - "@ai-sdk/provider" "1.0.1" - eventsource-parser "^3.0.0" - nanoid "^3.3.7" + "@ai-sdk/provider" "1.1.3" + nanoid "^3.3.8" secure-json-parse "^2.7.0" -"@ai-sdk/provider@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-1.0.1.tgz#8172a3cbbfa61bb40b88512165f70fe3c186cb60" - integrity sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg== +"@ai-sdk/provider@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-1.1.3.tgz#ebdda8077b8d2b3f290dcba32c45ad19b2704681" + integrity sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg== dependencies: json-schema "^0.4.0" -"@ai-sdk/react@1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-1.0.3.tgz#b9bc24e20bdc5768cbb0d9c65471fb60ab2675ec" - integrity sha512-Mak7qIRlbgtP4I7EFoNKRIQTlABJHhgwrN8SV2WKKdmsfWK2RwcubQWz1hp88cQ0bpF6KxxjSY1UUnS/S9oR5g== +"@ai-sdk/react@1.2.12": + version "1.2.12" + resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-1.2.12.tgz#f4250b6df566b170af98a71d5708b52108dd0ce1" + integrity sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g== dependencies: - "@ai-sdk/provider-utils" "2.0.2" - "@ai-sdk/ui-utils" "1.0.2" + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/ui-utils" "1.2.11" swr "^2.2.5" throttleit "2.1.0" -"@ai-sdk/ui-utils@1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@ai-sdk/ui-utils/-/ui-utils-1.0.2.tgz#2b5ad527f821b055663ddc60f2c45a82956091a0" - integrity sha512-hHrUdeThGHu/rsGZBWQ9PjrAU9Htxgbo9MFyR5B/aWoNbBeXn1HLMY1+uMEnXL5pRPlmyVRjgIavWg7UgeNDOw== +"@ai-sdk/ui-utils@1.2.11": + version "1.2.11" + resolved "https://registry.yarnpkg.com/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz#4f815589d08d8fef7292ade54ee5db5d09652603" + integrity sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w== dependencies: - "@ai-sdk/provider" "1.0.1" - "@ai-sdk/provider-utils" "2.0.2" - zod-to-json-schema "^3.23.5" + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + zod-to-json-schema "^3.24.1" "@ampproject/remapping@2.2.0": version "2.2.0" @@ -2965,11 +2964,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== -"@esbuild/aix-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" - integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== - "@esbuild/aix-ppc64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" @@ -3010,11 +3004,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== -"@esbuild/android-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" - integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== - "@esbuild/android-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f" @@ -3060,11 +3049,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== -"@esbuild/android-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" - integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== - "@esbuild/android-arm@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26" @@ -3105,11 +3089,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== -"@esbuild/android-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" - integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== - "@esbuild/android-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff" @@ -3150,11 +3129,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== -"@esbuild/darwin-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" - integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== - "@esbuild/darwin-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz#49d8bf8b1df95f759ac81eb1d0736018006d7e34" @@ -3195,11 +3169,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== -"@esbuild/darwin-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" - integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== - "@esbuild/darwin-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418" @@ -3240,11 +3209,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== -"@esbuild/freebsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" - integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== - "@esbuild/freebsd-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c" @@ -3285,11 +3249,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== -"@esbuild/freebsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" - integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== - "@esbuild/freebsd-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f" @@ -3330,11 +3289,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== -"@esbuild/linux-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" - integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== - "@esbuild/linux-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8" @@ -3375,11 +3329,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== -"@esbuild/linux-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" - integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== - "@esbuild/linux-arm@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911" @@ -3420,11 +3369,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== -"@esbuild/linux-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" - integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== - "@esbuild/linux-ia32@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783" @@ -3475,11 +3419,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== -"@esbuild/linux-loong64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" - integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== - "@esbuild/linux-loong64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506" @@ -3520,11 +3459,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== -"@esbuild/linux-mips64el@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" - integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== - "@esbuild/linux-mips64el@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96" @@ -3565,11 +3499,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== -"@esbuild/linux-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" - integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== - "@esbuild/linux-ppc64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9" @@ -3610,11 +3539,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== -"@esbuild/linux-riscv64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" - integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== - "@esbuild/linux-riscv64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e" @@ -3655,11 +3579,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== -"@esbuild/linux-s390x@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" - integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== - "@esbuild/linux-s390x@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d" @@ -3700,21 +3619,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== -"@esbuild/linux-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" - integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== - "@esbuild/linux-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4" integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== -"@esbuild/netbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" - integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== - "@esbuild/netbsd-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d" @@ -3755,11 +3664,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== -"@esbuild/netbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" - integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== - "@esbuild/netbsd-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79" @@ -3770,11 +3674,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== -"@esbuild/openbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" - integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== - "@esbuild/openbsd-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd" @@ -3815,11 +3714,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== -"@esbuild/openbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" - integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== - "@esbuild/openbsd-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0" @@ -3860,11 +3754,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== -"@esbuild/sunos-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" - integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== - "@esbuild/sunos-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5" @@ -3905,11 +3794,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== -"@esbuild/win32-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" - integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== - "@esbuild/win32-arm64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e" @@ -3950,11 +3834,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== -"@esbuild/win32-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" - integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== - "@esbuild/win32-ia32@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d" @@ -3995,11 +3874,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== -"@esbuild/win32-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" - integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== - "@esbuild/win32-x64@0.25.5": version "0.25.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1" @@ -9648,18 +9522,17 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -ai@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ai/-/ai-4.0.6.tgz#94ef793df8525c01043e15a60030ce88d7b5c7d5" - integrity sha512-TD7fH0LymjIYWmdQViB5SoBb1iuuDPOZ7RMU3W9r4SeUf68RzWyixz118QHQTENNqPiGA6vs5NDVAmZOnhzqYA== +ai@^4.3.16: + version "4.3.16" + resolved "https://registry.yarnpkg.com/ai/-/ai-4.3.16.tgz#c9446da1024cdc1dfe2913d151b70c91d40f2378" + integrity sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g== dependencies: - "@ai-sdk/provider" "1.0.1" - "@ai-sdk/provider-utils" "2.0.2" - "@ai-sdk/react" "1.0.3" - "@ai-sdk/ui-utils" "1.0.2" + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/react" "1.2.12" + "@ai-sdk/ui-utils" "1.2.11" "@opentelemetry/api" "1.9.0" jsondiffpatch "0.6.0" - zod-to-json-schema "^3.23.5" ajv-formats@2.1.1, ajv-formats@^2.1.1: version "2.1.1" @@ -15091,37 +14964,6 @@ esbuild@^0.23.0, esbuild@^0.23.1: "@esbuild/win32-ia32" "0.23.1" "@esbuild/win32-x64" "0.23.1" -esbuild@^0.24.2: - version "0.24.2" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" - integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== - optionalDependencies: - "@esbuild/aix-ppc64" "0.24.2" - "@esbuild/android-arm" "0.24.2" - "@esbuild/android-arm64" "0.24.2" - "@esbuild/android-x64" "0.24.2" - "@esbuild/darwin-arm64" "0.24.2" - "@esbuild/darwin-x64" "0.24.2" - "@esbuild/freebsd-arm64" "0.24.2" - "@esbuild/freebsd-x64" "0.24.2" - "@esbuild/linux-arm" "0.24.2" - "@esbuild/linux-arm64" "0.24.2" - "@esbuild/linux-ia32" "0.24.2" - "@esbuild/linux-loong64" "0.24.2" - "@esbuild/linux-mips64el" "0.24.2" - "@esbuild/linux-ppc64" "0.24.2" - "@esbuild/linux-riscv64" "0.24.2" - "@esbuild/linux-s390x" "0.24.2" - "@esbuild/linux-x64" "0.24.2" - "@esbuild/netbsd-arm64" "0.24.2" - "@esbuild/netbsd-x64" "0.24.2" - "@esbuild/openbsd-arm64" "0.24.2" - "@esbuild/openbsd-x64" "0.24.2" - "@esbuild/sunos-x64" "0.24.2" - "@esbuild/win32-arm64" "0.24.2" - "@esbuild/win32-ia32" "0.24.2" - "@esbuild/win32-x64" "0.24.2" - esbuild@^0.25.0: version "0.25.5" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430" @@ -15537,11 +15379,6 @@ events@^3.0.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -eventsource-parser@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.0.tgz#9303e303ef807d279ee210a17ce80f16300d9f57" - integrity sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA== - exec-sh@^0.3.2, exec-sh@^0.3.4: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -15940,7 +15777,7 @@ fbjs@^0.8.0: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fdir@^6.2.0, fdir@^6.3.0, fdir@^6.4.2, fdir@^6.4.4: +fdir@^6.2.0, fdir@^6.3.0, fdir@^6.4.4: version "6.4.5" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.5.tgz#328e280f3a23699362f95f2e82acf978a0c0cb49" integrity sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw== @@ -21553,7 +21390,7 @@ named-placeholders@^1.1.3: dependencies: lru-cache "^7.14.1" -nanoid@^3.3.11, nanoid@^3.3.3, nanoid@^3.3.4, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8: +nanoid@^3.3.11, nanoid@^3.3.3, nanoid@^3.3.4, nanoid@^3.3.6, nanoid@^3.3.8: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -24192,7 +24029,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.2, postcss@^8.5.3: +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.3: version "8.5.4" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0" integrity sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w== @@ -25782,7 +25619,7 @@ rollup@^3.27.1, rollup@^3.28.1: optionalDependencies: fsevents "~2.3.2" -rollup@^4.18.0, rollup@^4.20.0, rollup@^4.30.1, rollup@^4.34.9, rollup@^4.35.0: +rollup@^4.18.0, rollup@^4.20.0, rollup@^4.34.9, rollup@^4.35.0: version "4.41.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.41.1.tgz#46ddc1b33cf1b0baa99320d3b0b4973dc2253b6a" integrity sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw== @@ -30253,10 +30090,10 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" -zod-to-json-schema@^3.23.5: - version "3.23.5" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.5.tgz#ec23def47dcafe3a4d640eba6a346b34f9a693a5" - integrity sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA== +zod-to-json-schema@^3.24.1: + version "3.24.5" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" + integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== zod@^3.22.3, zod@^3.22.4, zod@^3.24.1: version "3.24.1" From cfa8d41aa20c502354cb07818bac09d56a5a8047 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Jun 2025 09:08:51 +0200 Subject: [PATCH 05/10] feat(react-router): Export wrappers for server loaders and actions (#16481) --- .../.gitignore | 32 ++++ .../react-router-7-framework-custom/.npmrc | 2 + .../app/app.css | 6 + .../app/entry.client.tsx | 23 +++ .../app/entry.server.tsx | 26 +++ .../app/root.tsx | 69 ++++++++ .../app/routes.ts | 21 +++ .../app/routes/errors/client-action.tsx | 18 ++ .../app/routes/errors/client-loader.tsx | 16 ++ .../app/routes/errors/client-param.tsx | 17 ++ .../app/routes/errors/client.tsx | 15 ++ .../app/routes/errors/server-action.tsx | 18 ++ .../app/routes/errors/server-loader.tsx | 16 ++ .../app/routes/home.tsx | 9 + .../app/routes/performance/dynamic-param.tsx | 17 ++ .../app/routes/performance/index.tsx | 14 ++ .../app/routes/performance/server-action.tsx | 25 +++ .../app/routes/performance/server-loader.tsx | 17 ++ .../app/routes/performance/ssr.tsx | 7 + .../app/routes/performance/static.tsx | 3 + .../instrument.mjs | 13 ++ .../package.json | 67 +++++++ .../playwright.config.mjs | 8 + .../public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 6 + .../start-event-proxy.mjs | 6 + .../tests/constants.ts | 1 + .../tests/errors/errors.client.test.ts | 138 +++++++++++++++ .../tests/errors/errors.server.test.ts | 98 +++++++++++ .../performance/navigation.client.test.ts | 107 ++++++++++++ .../tests/performance/pageload.client.test.ts | 132 ++++++++++++++ .../performance/performance.server.test.ts | 163 ++++++++++++++++++ .../performance/trace-propagation.test.ts | 43 +++++ .../tsconfig.json | 21 +++ .../vite.config.ts | 6 + packages/react-router/src/server/index.ts | 2 + .../src/server/wrapServerAction.ts | 70 ++++++++ .../src/server/wrapServerLoader.ts | 70 ++++++++ .../test/server/wrapServerAction.test.ts | 60 +++++++ .../test/server/wrapServerLoader.test.ts | 60 +++++++ 40 files changed, 1442 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/app.css create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/dynamic-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/ssr.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/static.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts create mode 100644 packages/react-router/src/server/wrapServerAction.ts create mode 100644 packages/react-router/src/server/wrapServerLoader.ts create mode 100644 packages/react-router/test/server/wrapServerAction.test.ts create mode 100644 packages/react-router/test/server/wrapServerLoader.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx new file mode 100644 index 000000000000..925c1e6ab143 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // todo: get this from env + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx new file mode 100644 index 000000000000..97260755da21 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx @@ -0,0 +1,26 @@ +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { renderToPipeableStream } from 'react-dom/server'; +import { ServerRouter } from 'react-router'; +import { type HandleErrorFunction } from 'react-router'; + +const ABORT_DELAY = 5_000; + +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); + +export default handleRequest; + +export const handleError: HandleErrorFunction = (error, { request }) => { + // React Router may abort some interrupted requests, don't log those + if (!request.signal.aborted) { + Sentry.captureException(error); + + // make sure to still log the error so you can see it + console.error(error); + } +}; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx new file mode 100644 index 000000000000..227c08f7730c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx @@ -0,0 +1,69 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + Sentry.captureException(error); + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts new file mode 100644 index 000000000000..b412893def52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes.ts @@ -0,0 +1,21 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...prefix('errors', [ + route('client', 'routes/errors/client.tsx'), + route('client/:client-param', 'routes/errors/client-param.tsx'), + route('client-loader', 'routes/errors/client-loader.tsx'), + route('server-loader', 'routes/errors/server-loader.tsx'), + route('client-action', 'routes/errors/client-action.tsx'), + route('server-action', 'routes/errors/server-action.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('ssr', 'routes/performance/ssr.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + route('static', 'routes/performance/static.tsx'), + route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-action.tsx new file mode 100644 index 000000000000..d3b2d08eef2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function clientAction() { + throw new Error('Madonna mia! Che casino nella Client Action!'); +} + +export default function ClientActionErrorPage() { + return ( +
+

Client Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-loader.tsx new file mode 100644 index 000000000000..72d9e62a99dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function clientLoader() { + throw new Error('¡Madre mía del client loader!'); + return { data: 'sad' }; +} + +export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Client Loader Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-param.tsx new file mode 100644 index 000000000000..a2e423391f03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/client-param'; + +export default function ClientErrorParamPage({ params }: Route.ComponentProps) { + return ( +
+

Client Error Param Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client.tsx new file mode 100644 index 000000000000..190074a5ef09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/client.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( +
+

Client Error Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-action.tsx new file mode 100644 index 000000000000..863c320f3557 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function action() { + throw new Error('Madonna mia! Che casino nella Server Action!'); +} + +export default function ServerActionErrorPage() { + return ( +
+

Server Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-loader.tsx new file mode 100644 index 000000000000..cb777686d540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/errors/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function loader() { + throw new Error('¡Madre mía del server!'); + return { data: 'sad' }; +} + +export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/home.tsx new file mode 100644 index 000000000000..4498e7a0d017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/home.tsx @@ -0,0 +1,9 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..1ac02775f2ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/dynamic-param'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + const { param } = params; + + return ( +
+

Dynamic Parameter Page

+

The parameter value is: {param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx new file mode 100644 index 000000000000..e5383306625a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/index.tsx @@ -0,0 +1,14 @@ +import { Link } from 'react-router'; + +export default function PerformancePage() { + return ( +
+

Performance Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..f149c5466b5a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-action.tsx @@ -0,0 +1,25 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; +import * as Sentry from '@sentry/react-router'; + +export const action = Sentry.wrapServerAction({}, async ({ request }: Route.ActionArgs) => { + let formData = await request.formData(); + let name = formData.get('name'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + greeting: `Hola ${name}`, + }; +}); + +export default function Project({ actionData }: Route.ComponentProps) { + return ( +
+

Server action page

+
+ + +
+ {actionData ?

{actionData.greeting}

: null} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx new file mode 100644 index 000000000000..da688d4dfe3e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/server-loader.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/server-loader'; +import * as Sentry from '@sentry/react-router'; + +export const loader = Sentry.wrapServerLoader({}, async ({}: Route.LoaderArgs) => { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +}); + +export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/ssr.tsx new file mode 100644 index 000000000000..253e964ff15d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/ssr.tsx @@ -0,0 +1,7 @@ +export default function SsrPage() { + return ( +
+

SSR Page

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/static.tsx new file mode 100644 index 000000000000..3dea24381fdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/routes/performance/static.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

Static Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs new file mode 100644 index 000000000000..a43afcba814f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server + integrations: function (integrations) { + return integrations.filter(integration => { + return integration.name !== 'ReactRouterServer'; + }); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json new file mode 100644 index 000000000000..6f793c0d20eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/package.json @@ -0,0 +1,67 @@ +{ + "name": "react-router-7-framework-custom", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.1.5", + "@react-router/node": "^7.1.5", + "@react-router/serve": "^7.1.5", + "@sentry/react-router": "latest || *", + "@sentry-internal/feedback": "latest || *", + "@sentry-internal/replay-canvas": "latest || *", + "@sentry-internal/browser-utils": "latest || *", + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/react": "latest || *", + "@sentry-internal/replay": "latest || *", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.1.5", + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/playwright.config.mjs new file mode 100644 index 000000000000..3ed5721107a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `PORT=3030 pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts new file mode 100644 index 000000000000..bb1f96469dd2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/react-router.config.ts @@ -0,0 +1,6 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + prerender: ['/performance/static'], +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs new file mode 100644 index 000000000000..fb8dabc7fcfa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-custom', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts new file mode 100644 index 000000000000..91653303b335 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-custom'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts new file mode 100644 index 000000000000..d6c80924c121 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client-side errors', () => { + const errorMessage = '¡Madre mía!'; + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/client`); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/errors/client', + request: { + url: expect.stringContaining('errors/client'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'javascript', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'browser' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + breadcrumbs: [ + { + category: 'ui.click', + message: 'body > div > button#throw-on-click', + }, + ], + }); + }); + + test('captures error thrown on click from a parameterized route', async ({ page }) => { + const errorMessage = '¡Madre mía de churros!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client/churros'); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: '¡Madre mía de churros!', + mechanism: { + handled: false, + }, + }, + ], + }, + // todo: should be '/errors/client/:client-param' + transaction: '/errors/client/churros', + }); + }); + + test('captures error thrown in a clientLoader', async ({ page }) => { + const errorMessage = '¡Madre mía del client loader!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-loader'); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + transaction: '/errors/client-loader', + }); + }); + + test('captures error thrown in a clientAction', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Client Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-action'); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + transaction: '/errors/client-action', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..d702f8cee597 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server-side errors', () => { + test('captures error thrown in server loader', async ({ page }) => { + const errorMessage = '¡Madre mía del server!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-loader`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + // todo: should be 'GET /errors/server-loader' + transaction: 'GET *', + request: { + url: expect.stringContaining('errors/server-loader'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('captures error thrown in server action', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Server Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-action`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + // todo: should be 'POST /errors/server-action' + transaction: 'POST *', + request: { + url: expect.stringContaining('errors/server-action'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..57e3e764d6a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - navigation performance', () => { + test('should create navigation transaction', async ({ page }) => { + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/ssr'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation + + const transaction = await navigationPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react-router', + 'sentry.op': 'navigation', + 'sentry.source': 'url', + }, + op: 'navigation', + origin: 'auto.navigation.react-router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/ssr', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/ssr'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should update navigation transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with/:param'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react-router', + 'sentry.op': 'navigation', + 'sentry.source': 'route', + }, + op: 'navigation', + origin: 'auto.navigation.react-router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..b18ae44e0e71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - pageload performance', () => { + test('should send pageload transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/', + type: 'transaction', + transaction_info: { source: 'url' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should update pageload transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with/:param'; + }); + + await page.goto(`/performance/with/sentry`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should send pageload transaction for prerendered pages', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/static/'; + }); + + await page.goto(`/performance/static`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + transaction: '/performance/static/', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..abca82a6d938 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('servery - performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should instrument wrapped server loader', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + console.log(110, transactionEvent.transaction); + return transactionEvent.transaction === 'GET /performance/server-loader'; + }); + + await page.goto(`/performance`); + await page.waitForTimeout(500); + await page.getByRole('link', { name: 'Server Loader' }).click(); + + const transaction = await txPromise; + + expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.react-router', + 'sentry.op': 'function.react-router.loader', + }, + description: 'Executing Server Loader', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.loader', + origin: 'auto.http.react-router', + }); + }); + + test('should instrument a wrapped server action', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'POST /performance/server-action'; + }); + + await page.goto(`/performance/server-action`); + await page.getByRole('button', { name: 'Submit' }).click(); + + const transaction = await txPromise; + + expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.react-router', + 'sentry.op': 'function.react-router.action', + }, + description: 'Executing Server Action', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.action', + origin: 'auto.http.react-router', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..7562297b2d4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET *'; + }); + + const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + }); + + test('should not have trace connection for prerendered pages', async ({ page }) => { + await page.goto('/performance/static'); + + const sentryTraceElement = await page.$('meta[name="sentry-trace"]'); + expect(sentryTraceElement).toBeNull(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json new file mode 100644 index 000000000000..1b510b528de9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "exclude": ["tests/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +}); diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 67436582aedd..b42b769a78e6 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -4,3 +4,5 @@ export { init } from './sdk'; // eslint-disable-next-line deprecation/deprecation export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest'; export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; +export { wrapServerAction } from './wrapServerAction'; +export { wrapServerLoader } from './wrapServerLoader'; diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts new file mode 100644 index 000000000000..9da0e8d351f8 --- /dev/null +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -0,0 +1,70 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + getActiveSpan, + getRootSpan, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, + startSpan, +} from '@sentry/core'; +import type { ActionFunctionArgs } from 'react-router'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; + +type SpanOptions = { + name?: string; + attributes?: SpanAttributes; +}; + +/** + * Wraps a React Router server action function with Sentry performance monitoring. + * @param options - Optional span configuration options including name, operation, description and attributes + * @param actionFn - The server action function to wrap + * + * @example + * ```ts + * // Wrap an action function with custom span options + * export const action = wrapServerAction( + * { + * name: 'Submit Form Data', + * description: 'Processes form submission data', + * }, + * async ({ request }) => { + * // ... your action logic + * } + * ); + * ``` + */ +export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: ActionFunctionArgs) => Promise) { + return async function (args: ActionFunctionArgs) { + const name = options.name || 'Executing Server Action'; + const active = getActiveSpan(); + if (active) { + const root = getRootSpan(active); + // coming from auto.http.otel.http + if (spanToJSON(root).description === 'POST') { + const url = parseStringToURLObject(args.request.url); + if (url?.pathname) { + root.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`, + }); + } + } + } + + return startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + ...options.attributes, + }, + }, + () => actionFn(args), + ); + }; +} diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts new file mode 100644 index 000000000000..dda64a1a9204 --- /dev/null +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -0,0 +1,70 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + getActiveSpan, + getRootSpan, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, + startSpan, +} from '@sentry/core'; +import type { LoaderFunctionArgs } from 'react-router'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; + +type SpanOptions = { + name?: string; + attributes?: SpanAttributes; +}; + +/** + * Wraps a React Router server loader function with Sentry performance monitoring. + * @param options - Optional span configuration options including name, operation, description and attributes + * @param loaderFn - The server loader function to wrap + * + * @example + * ```ts + * // Wrap a loader function with custom span options + * export const loader = wrapServerLoader( + * { + * name: 'Load Some Data', + * description: 'Loads some data from the db', + * }, + * async ({ params }) => { + * // ... your loader logic + * } + * ); + * ``` + */ +export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: LoaderFunctionArgs) => Promise) { + return async function (args: LoaderFunctionArgs) { + const name = options.name || 'Executing Server Loader'; + const active = getActiveSpan(); + if (active) { + const root = getRootSpan(active); + // coming from auto.http.otel.http + if (spanToJSON(root).description === 'GET') { + const url = parseStringToURLObject(args.request.url); + + if (url?.pathname) { + root.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`, + }); + } + } + } + return startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + ...options.attributes, + }, + }, + () => loaderFn(args), + ); + }; +} diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts new file mode 100644 index 000000000000..931e4c72b446 --- /dev/null +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -0,0 +1,60 @@ +import * as core from '@sentry/core'; +import type { ActionFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerAction } from '../../src/server/wrapServerAction'; + +describe('wrapServerAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap an action function with default options', async () => { + const mockActionFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + const spy = vi.spyOn(core, 'startSpan'); + const wrappedAction = wrapServerAction({}, mockActionFn); + await wrappedAction(mockArgs); + + expect(spy).toHaveBeenCalledWith( + { + name: 'Executing Server Action', + attributes: { + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + }, + }, + expect.any(Function), + ); + expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + }); + + it('should wrap an action function with custom options', async () => { + const customOptions = { + name: 'Custom Action', + attributes: { + 'sentry.custom': 'value', + }, + }; + + const mockActionFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + const spy = vi.spyOn(core, 'startSpan'); + const wrappedAction = wrapServerAction(customOptions, mockActionFn); + await wrappedAction(mockArgs); + + expect(spy).toHaveBeenCalledWith( + { + name: 'Custom Action', + attributes: { + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + 'sentry.custom': 'value', + }, + }, + expect.any(Function), + ); + expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + }); +}); diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts new file mode 100644 index 000000000000..53fce752286b --- /dev/null +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -0,0 +1,60 @@ +import * as core from '@sentry/core'; +import type { LoaderFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerLoader } from '../../src/server/wrapServerLoader'; + +describe('wrapServerLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap a loader function with default options', async () => { + const mockLoaderFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + const spy = vi.spyOn(core, 'startSpan'); + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + await wrappedLoader(mockArgs); + + expect(spy).toHaveBeenCalledWith( + { + name: 'Executing Server Loader', + attributes: { + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + }, + }, + expect.any(Function), + ); + expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + }); + + it('should wrap a loader function with custom options', async () => { + const customOptions = { + name: 'Custom Loader', + attributes: { + 'sentry.custom': 'value', + }, + }; + + const mockLoaderFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + const spy = vi.spyOn(core, 'startSpan'); + const wrappedLoader = wrapServerLoader(customOptions, mockLoaderFn); + await wrappedLoader(mockArgs); + + expect(spy).toHaveBeenCalledWith( + { + name: 'Custom Loader', + attributes: { + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + 'sentry.custom': 'value', + }, + }, + expect.any(Function), + ); + expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + }); +}); From 497b76e23acf5314a84505e2c5021fd99ea9696c Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:03:40 +0200 Subject: [PATCH 06/10] feat(react-router): Add component annotation plugin (#16472) Adds the possibility to add React component annotations in RR. closes https://github.com/getsentry/sentry-javascript/issues/16471 --- .../src/vite/makeCustomSentryVitePlugins.ts | 14 ++++++++++++- packages/react-router/src/vite/types.ts | 19 +++++++++++++++++ .../vite/makeCustomSentryVitePlugins.test.ts | 21 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 41580f4a7c7f..80e540c9760a 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -14,6 +14,7 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil org, project, telemetry, + reactComponentAnnotation, release, } = options; @@ -30,6 +31,11 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil }, ...unstable_sentryVitePluginOptions?._metaOptions, }, + reactComponentAnnotation: { + enabled: reactComponentAnnotation?.enabled ?? undefined, + ignoredComponents: reactComponentAnnotation?.ignoredComponents ?? undefined, + ...unstable_sentryVitePluginOptions?.reactComponentAnnotation, + }, release: { ...unstable_sentryVitePluginOptions?.release, ...release, @@ -45,7 +51,13 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil // only use a subset of the plugins as all upload and file deletion tasks will be handled in the buildEnd hook return [ ...sentryVitePlugins.filter(plugin => { - return ['sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin'].includes(plugin.name); + return [ + 'sentry-telemetry-plugin', + 'sentry-vite-release-injection-plugin', + ...(reactComponentAnnotation?.enabled || unstable_sentryVitePluginOptions?.reactComponentAnnotation?.enabled + ? ['sentry-vite-component-name-annotate-plugin'] + : []), + ].includes(plugin.name); }), ]; } diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index de8175b0141c..fb488d2ca8bc 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -125,6 +125,25 @@ export type SentryReactRouterBuildOptions = { */ debug?: boolean; + /** + * Options related to react component name annotations. + * Disabled by default, unless a value is set for this option. + * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. + * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. + * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components + */ + reactComponentAnnotation?: { + /** + * Whether the component name annotate plugin should be enabled or not. + */ + enabled?: boolean; + + /** + * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + */ + ignoredComponents?: string[]; + }; + /** * Options for the Sentry Vite plugin to customize the source maps upload process. * diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index 04576076a561..b4db6d85d028 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -8,6 +8,7 @@ vi.mock('@sentry/vite-plugin', () => ({ .mockReturnValue([ { name: 'sentry-telemetry-plugin' }, { name: 'sentry-vite-release-injection-plugin' }, + { name: 'sentry-vite-component-name-annotate-plugin' }, { name: 'other-plugin' }, ]), })); @@ -60,4 +61,24 @@ describe('makeCustomSentryVitePlugins', () => { expect(plugins?.[0]?.name).toBe('sentry-telemetry-plugin'); expect(plugins?.[1]?.name).toBe('sentry-vite-release-injection-plugin'); }); + + it('should include component annotation plugin when reactComponentAnnotation.enabled is true', async () => { + const plugins = await makeCustomSentryVitePlugins({ reactComponentAnnotation: { enabled: true } }); + + expect(plugins).toHaveLength(3); + expect(plugins?.[0]?.name).toBe('sentry-telemetry-plugin'); + expect(plugins?.[1]?.name).toBe('sentry-vite-release-injection-plugin'); + expect(plugins?.[2]?.name).toBe('sentry-vite-component-name-annotate-plugin'); + }); + + it('should include component annotation plugin when unstable_sentryVitePluginOptions.reactComponentAnnotation.enabled is true', async () => { + const plugins = await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { reactComponentAnnotation: { enabled: true } }, + }); + + expect(plugins).toHaveLength(3); + expect(plugins?.[0]?.name).toBe('sentry-telemetry-plugin'); + expect(plugins?.[1]?.name).toBe('sentry-vite-release-injection-plugin'); + expect(plugins?.[2]?.name).toBe('sentry-vite-component-name-annotate-plugin'); + }); }); From b1fd4a1d47a36cd30fd1bec3e9859ec16fcf1d02 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 5 Jun 2025 12:19:20 +0200 Subject: [PATCH 07/10] test(vue): Add tests for Vue tracing mixins (#16486) There were no tests yet for the Vue tracing mixins. To be more comfortable when continuing working on it, it's better to have some :D --- .../vue/test/tracing/tracingMixin.test.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/vue/test/tracing/tracingMixin.test.ts diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts new file mode 100644 index 000000000000..b9a92a4f7395 --- /dev/null +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -0,0 +1,240 @@ +import { getActiveSpan, startInactiveSpan } from '@sentry/browser'; +import type { Mock } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_HOOKS } from '../../src/constants'; +import { createTracingMixins } from '../../src/tracing'; + +vi.mock('@sentry/browser', () => { + return { + getActiveSpan: vi.fn(), + startInactiveSpan: vi.fn().mockImplementation(({ name, op }) => { + return { + end: vi.fn(), + startChild: vi.fn(), + name, + op, + }; + }), + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + }; +}); + +vi.mock('../../src/vendor/components', () => { + return { + formatComponentName: vi.fn().mockImplementation(vm => { + return vm.componentName || 'TestComponent'; + }), + }; +}); + +const mockSpanFactory = (): { name?: string; op?: string; end: Mock; startChild: Mock } => ({ + name: undefined, + op: undefined, + end: vi.fn(), + startChild: vi.fn(), +}); + +vi.useFakeTimers(); + +describe('Vue Tracing Mixins', () => { + let mockVueInstance: any; + let mockRootInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRootInstance = { + $root: null, + componentName: 'RootComponent', + $_sentrySpans: {}, + }; + mockRootInstance.$root = mockRootInstance; // Self-reference for root + + mockVueInstance = { + $root: mockRootInstance, + componentName: 'TestComponent', + $_sentrySpans: {}, + }; + + (getActiveSpan as any).mockReturnValue({ id: 'parent-span' }); + (startInactiveSpan as any).mockImplementation(({ name, op }: { name: string; op: string }) => { + const newSpan = mockSpanFactory(); + newSpan.name = name; + newSpan.op = op; + return newSpan; + }); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + describe('Mixin Creation', () => { + it('should create mixins for default hooks', () => { + const mixins = createTracingMixins(); + + DEFAULT_HOOKS.forEach(hook => { + const hookPairs = { + mount: ['beforeMount', 'mounted'], + update: ['beforeUpdate', 'updated'], + destroy: ['beforeDestroy', 'destroyed'], + unmount: ['beforeUnmount', 'unmounted'], + create: ['beforeCreate', 'created'], + activate: ['activated', 'deactivated'], + }; + + if (hook in hookPairs) { + hookPairs[hook as keyof typeof hookPairs].forEach(lifecycleHook => { + expect(mixins).toHaveProperty(lifecycleHook); + // @ts-expect-error we check the type here + expect(typeof mixins[lifecycleHook]).toBe('function'); + }); + } + }); + }); + + it('should always include the activate and mount hooks', () => { + const mixins = createTracingMixins({ hooks: undefined }); + + expect(Object.keys(mixins)).toEqual(['activated', 'deactivated', 'beforeMount', 'mounted']); + }); + + it('should create mixins for custom hooks', () => { + const mixins = createTracingMixins({ hooks: ['update'] }); + + expect(Object.keys(mixins)).toEqual([ + 'beforeUpdate', + 'updated', + 'activated', + 'deactivated', + 'beforeMount', + 'mounted', + ]); + }); + }); + + describe('Root Component Behavior', () => { + it('should always create a root component span for the Vue root component regardless of tracking options', () => { + const mixins = createTracingMixins({ trackComponents: false }); + + mixins.beforeMount.call(mockRootInstance); + + expect(startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Application Render', + op: 'ui.vue.render', + }), + ); + }); + + it('should finish root component span on timer after component spans end', () => { + // todo/fixme: This root component span is only finished if trackComponents is true --> it should probably be always finished + const mixins = createTracingMixins({ trackComponents: true, timeout: 1000 }); + const rootMockSpan = mockSpanFactory(); + mockRootInstance.$_sentryRootSpan = rootMockSpan; + + // Create and finish a component span + mixins.beforeMount.call(mockVueInstance); + mixins.mounted.call(mockVueInstance); + + // Root component span should not end immediately + expect(rootMockSpan.end).not.toHaveBeenCalled(); + + // After timeout, root component span should end + vi.advanceTimersByTime(1001); + expect(rootMockSpan.end).toHaveBeenCalled(); + }); + }); + + describe('Component Span Lifecycle', () => { + it('should create and end spans correctly through lifecycle hooks', () => { + const mixins = createTracingMixins({ trackComponents: true }); + + // 1. Create span in "before" hook + mixins.beforeMount.call(mockVueInstance); + + // Verify span was created with correct details + expect(startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Vue TestComponent', + op: 'ui.vue.mount', + }), + ); + expect(mockVueInstance.$_sentrySpans.mount).toBeDefined(); + + // 2. Get the span for verification + const componentSpan = mockVueInstance.$_sentrySpans.mount; + + // 3. End span in "after" hook + mixins.mounted.call(mockVueInstance); + expect(componentSpan.end).toHaveBeenCalled(); + }); + + it('should clean up existing spans when creating new ones', () => { + const mixins = createTracingMixins({ trackComponents: true }); + + // Create an existing span first + const oldSpan = mockSpanFactory(); + mockVueInstance.$_sentrySpans.mount = oldSpan; + + // Create a new span for the same operation + mixins.beforeMount.call(mockVueInstance); + + // Verify old span was ended and new span was created + expect(oldSpan.end).toHaveBeenCalled(); + expect(mockVueInstance.$_sentrySpans.mount).not.toBe(oldSpan); + }); + + it('should gracefully handle when "after" hook is called without "before" hook', () => { + const mixins = createTracingMixins(); + + // Call mounted hook without calling beforeMount first + expect(() => mixins.mounted.call(mockVueInstance)).not.toThrow(); + }); + + it('should skip spans when no active root component span (transaction) exists', () => { + const mixins = createTracingMixins({ trackComponents: true }); + + // Remove active spans + (getActiveSpan as any).mockReturnValue(null); + mockRootInstance.$_sentryRootSpan = null; + + // Try to create a span + mixins.beforeMount.call(mockVueInstance); + + // No span should be created + expect(startInactiveSpan).not.toHaveBeenCalled(); + }); + }); + + describe('Component Tracking Options', () => { + it.each([ + { trackComponents: undefined, expected: false, description: 'defaults to not tracking components' }, + { trackComponents: false, expected: false, description: 'does not track when explicitly disabled' }, + ])('$description', ({ trackComponents }) => { + const mixins = createTracingMixins({ trackComponents }); + mixins.beforeMount.call(mockVueInstance); + expect(startInactiveSpan).not.toHaveBeenCalled(); + }); + + it.each([ + { trackComponents: true, description: 'tracks all components when enabled' }, + { trackComponents: ['TestComponent'], description: 'tracks components that match the name list' }, + ])('$description', ({ trackComponents }) => { + const mixins = createTracingMixins({ trackComponents }); + mixins.beforeMount.call(mockVueInstance); + expect(startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Vue TestComponent', + op: 'ui.vue.mount', + }), + ); + }); + + it('does not track components not in the tracking list', () => { + const mixins = createTracingMixins({ trackComponents: ['OtherComponent'] }); + mixins.beforeMount.call(mockVueInstance); // TestComponent + expect(startInactiveSpan).not.toHaveBeenCalled(); + }); + }); +}); From 0a7b915fd7dec01eb4ef0657e1e9c8ecdfae9e82 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:12:17 +0200 Subject: [PATCH 08/10] ref(vue): Clarify Vue tracing (#16487) Adds some clarifying comments and changes variable naming to make it easier to understand and parse. --- packages/vue/src/tracing.ts | 78 ++++++++++--------- .../vue/test/tracing/tracingMixin.test.ts | 16 ++-- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index e4e2fdb70f4e..5aadfdd876be 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -12,11 +12,11 @@ type Mixins = Parameters[0]; interface VueSentry extends ViewModel { readonly $root: VueSentry; - $_sentrySpans?: { + $_sentryComponentSpans?: { [key: string]: Span | undefined; }; - $_sentryRootSpan?: Span; - $_sentryRootSpanTimer?: ReturnType; + $_sentryRootComponentSpan?: Span; + $_sentryRootComponentSpanTimer?: ReturnType; } // Mappings from operation to corresponding lifecycle hook. @@ -31,16 +31,16 @@ const HOOKS: { [key in Operation]: Hook[] } = { update: ['beforeUpdate', 'updated'], }; -/** Finish top-level span and activity with a debounce configured using `timeout` option */ -function finishRootSpan(vm: VueSentry, timestamp: number, timeout: number): void { - if (vm.$_sentryRootSpanTimer) { - clearTimeout(vm.$_sentryRootSpanTimer); +/** Finish top-level component span and activity with a debounce configured using `timeout` option */ +function finishRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { + if (vm.$_sentryRootComponentSpanTimer) { + clearTimeout(vm.$_sentryRootComponentSpanTimer); } - vm.$_sentryRootSpanTimer = setTimeout(() => { - if (vm.$root?.$_sentryRootSpan) { - vm.$root.$_sentryRootSpan.end(timestamp); - vm.$root.$_sentryRootSpan = undefined; + vm.$_sentryRootComponentSpanTimer = setTimeout(() => { + if (vm.$root?.$_sentryRootComponentSpan) { + vm.$root.$_sentryRootComponentSpan.end(timestamp); + vm.$root.$_sentryRootComponentSpan = undefined; } }, timeout); } @@ -77,11 +77,12 @@ export const createTracingMixins = (options: Partial = {}): Mixi for (const internalHook of internalHooks) { mixins[internalHook] = function (this: VueSentry) { - const isRoot = this.$root === this; + const isRootComponent = this.$root === this; - if (isRoot) { - this.$_sentryRootSpan = - this.$_sentryRootSpan || + // 1. Root Component span creation + if (isRootComponent) { + this.$_sentryRootComponentSpan = + this.$_sentryRootComponentSpan || startInactiveSpan({ name: 'Application Render', op: `${VUE_OP}.render`, @@ -92,35 +93,39 @@ export const createTracingMixins = (options: Partial = {}): Mixi }); } - // Skip components that we don't want to track to minimize the noise and give a more granular control to the user - const name = formatComponentName(this, false); + // 2. Component tracking filter + const componentName = formatComponentName(this, false); - const shouldTrack = Array.isArray(options.trackComponents) - ? findTrackComponent(options.trackComponents, name) - : options.trackComponents; + const shouldTrack = + isRootComponent || // We always want to track the root component + (Array.isArray(options.trackComponents) + ? findTrackComponent(options.trackComponents, componentName) + : options.trackComponents); - // We always want to track root component - if (!isRoot && !shouldTrack) { + if (!shouldTrack) { return; } - this.$_sentrySpans = this.$_sentrySpans || {}; + this.$_sentryComponentSpans = this.$_sentryComponentSpans || {}; - // Start a new span if current hook is a 'before' hook. - // Otherwise, retrieve the current span and finish it. - if (internalHook == internalHooks[0]) { - const activeSpan = this.$root?.$_sentryRootSpan || getActiveSpan(); + // 3. Span lifecycle management based on the hook type + const isBeforeHook = internalHook === internalHooks[0]; + const activeSpan = this.$root?.$_sentryRootComponentSpan || getActiveSpan(); + + if (isBeforeHook) { + // Starting a new span in the "before" hook if (activeSpan) { - // Cancel old span for this hook operation in case it didn't get cleaned up. We're not actually sure if it - // will ever be the case that cleanup hooks re not called, but we had users report that spans didn't get - // finished so we finish the span before starting a new one, just to be sure. - const oldSpan = this.$_sentrySpans[operation]; + // Cancel any existing span for this operation (safety measure) + // We're actually not sure if it will ever be the case that cleanup hooks were not called. + // However, we had users report that spans didn't get finished, so we finished the span before + // starting a new one, just to be sure. + const oldSpan = this.$_sentryComponentSpans[operation]; if (oldSpan) { oldSpan.end(); } - this.$_sentrySpans[operation] = startInactiveSpan({ - name: `Vue ${name}`, + this.$_sentryComponentSpans[operation] = startInactiveSpan({ + name: `Vue ${componentName}`, op: `${VUE_OP}.${operation}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.vue', @@ -131,13 +136,14 @@ export const createTracingMixins = (options: Partial = {}): Mixi } } else { // The span should already be added via the first handler call (in the 'before' hook) - const span = this.$_sentrySpans[operation]; + const span = this.$_sentryComponentSpans[operation]; // The before hook did not start the tracking span, so the span was not added. // This is probably because it happened before there is an active transaction - if (!span) return; + if (!span) return; // Skip if no span was created in the "before" hook span.end(); - finishRootSpan(this, timestampInSeconds(), options.timeout || 2000); + // For any "after" hook, also schedule the root component span to finish + finishRootComponentSpan(this, timestampInSeconds(), options.timeout || 2000); } }; } diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts index b9a92a4f7395..d67690271ed2 100644 --- a/packages/vue/test/tracing/tracingMixin.test.ts +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -46,14 +46,14 @@ describe('Vue Tracing Mixins', () => { mockRootInstance = { $root: null, componentName: 'RootComponent', - $_sentrySpans: {}, + $_sentryComponentSpans: {}, }; mockRootInstance.$root = mockRootInstance; // Self-reference for root mockVueInstance = { $root: mockRootInstance, componentName: 'TestComponent', - $_sentrySpans: {}, + $_sentryComponentSpans: {}, }; (getActiveSpan as any).mockReturnValue({ id: 'parent-span' }); @@ -131,7 +131,7 @@ describe('Vue Tracing Mixins', () => { // todo/fixme: This root component span is only finished if trackComponents is true --> it should probably be always finished const mixins = createTracingMixins({ trackComponents: true, timeout: 1000 }); const rootMockSpan = mockSpanFactory(); - mockRootInstance.$_sentryRootSpan = rootMockSpan; + mockRootInstance.$_sentryRootComponentSpan = rootMockSpan; // Create and finish a component span mixins.beforeMount.call(mockVueInstance); @@ -160,10 +160,10 @@ describe('Vue Tracing Mixins', () => { op: 'ui.vue.mount', }), ); - expect(mockVueInstance.$_sentrySpans.mount).toBeDefined(); + expect(mockVueInstance.$_sentryComponentSpans.mount).toBeDefined(); // 2. Get the span for verification - const componentSpan = mockVueInstance.$_sentrySpans.mount; + const componentSpan = mockVueInstance.$_sentryComponentSpans.mount; // 3. End span in "after" hook mixins.mounted.call(mockVueInstance); @@ -175,14 +175,14 @@ describe('Vue Tracing Mixins', () => { // Create an existing span first const oldSpan = mockSpanFactory(); - mockVueInstance.$_sentrySpans.mount = oldSpan; + mockVueInstance.$_sentryComponentSpans.mount = oldSpan; // Create a new span for the same operation mixins.beforeMount.call(mockVueInstance); // Verify old span was ended and new span was created expect(oldSpan.end).toHaveBeenCalled(); - expect(mockVueInstance.$_sentrySpans.mount).not.toBe(oldSpan); + expect(mockVueInstance.$_sentryComponentSpans.mount).not.toBe(oldSpan); }); it('should gracefully handle when "after" hook is called without "before" hook', () => { @@ -197,7 +197,7 @@ describe('Vue Tracing Mixins', () => { // Remove active spans (getActiveSpan as any).mockReturnValue(null); - mockRootInstance.$_sentryRootSpan = null; + mockRootInstance.$_sentryRootComponentSpan = null; // Try to create a span mixins.beforeMount.call(mockVueInstance); From 6d61be03372943d5fa908336c3d018cf102d7f66 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 5 Jun 2025 14:28:05 +0200 Subject: [PATCH 09/10] fix(browser): Ignore unrealistically long INP values (#16484) We shouldn't send values INP spans if the reported value is unrealistically long. I decided to draw the line at 60 seconds for now but if anyone has concerns or wants a different upper bound, happy to change it. --- .size-limit.js | 2 +- packages/browser-utils/src/metrics/inp.ts | 109 +++++++++------- .../browser-utils/src/metrics/instrument.ts | 10 +- .../test/instrument/metrics/inpt.test.ts | 116 ++++++++++++++++++ 4 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 packages/browser-utils/test/instrument/metrics/inpt.test.ts diff --git a/.size-limit.js b/.size-limit.js index 10efb849a582..c3105a772987 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '42 KB', + limit: '43 KB', }, // SvelteKit SDK (ESM) { diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 05b7d7ed17a8..30a628b5997f 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -12,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import type { InstrumentationHandlerCallback } from './instrument'; import { addInpInstrumentationHandler, addPerformanceInstrumentationHandler, @@ -22,6 +23,11 @@ import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from ' const LAST_INTERACTIONS: number[] = []; const INTERACTIONS_SPAN_MAP = new Map(); +/** + * 60 seconds is the maximum for a plausible INP value + * (source: Me) + */ +const MAX_PLAUSIBLE_INP_DURATION = 60; /** * Start tracking INP webvital events. */ @@ -67,62 +73,77 @@ const INP_ENTRY_MAP: Record = { input: 'press', }; -/** Starts tracking the Interaction to Next Paint on the current page. */ -function _trackINP(): () => void { - return addInpInstrumentationHandler(({ metric }) => { - if (metric.value == undefined) { - return; - } +/** Starts tracking the Interaction to Next Paint on the current page. # + * exported only for testing + */ +export function _trackINP(): () => void { + return addInpInstrumentationHandler(_onInp); +} + +/** + * exported only for testing + */ +export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (metric.value == undefined) { + return; + } - const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]); + const duration = msToSec(metric.value); - if (!entry) { - return; - } + // We received occasional reports of hour-long INP values. + // Therefore, we add a sanity check to avoid creating spans for + // unrealistically long INP durations. + if (duration > MAX_PLAUSIBLE_INP_DURATION) { + return; + } - const { interactionId } = entry; - const interactionType = INP_ENTRY_MAP[entry.name]; + const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]); - /** Build the INP span, create an envelope from the span, and then send the envelope */ - const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); - const duration = msToSec(metric.value); - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + if (!entry) { + return; + } - // We first try to lookup the span from our INTERACTIONS_SPAN_MAP, - // where we cache the route per interactionId - const cachedSpan = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; + const { interactionId } = entry; + const interactionType = INP_ENTRY_MAP[entry.name]; - const spanToUse = cachedSpan || rootSpan; + /** Build the INP span, create an envelope from the span, and then send the envelope */ + const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - // Else, we try to use the active span. - // Finally, we fall back to look at the transactionName on the scope - const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName; + // We first try to lookup the span from our INTERACTIONS_SPAN_MAP, + // where we cache the route per interactionId + const cachedSpan = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; - const name = htmlTreeAsString(entry.target); - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`, - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, - }; + const spanToUse = cachedSpan || rootSpan; - const span = startStandaloneWebVitalSpan({ - name, - transaction: routeName, - attributes, - startTime, - }); + // Else, we try to use the active span. + // Finally, we fall back to look at the transactionName on the scope + const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName; - if (span) { - span.addEvent('inp', { - [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', - [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value, - }); + const name = htmlTreeAsString(entry.target); + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + }; - span.end(startTime + duration); - } + const span = startStandaloneWebVitalSpan({ + name, + transaction: routeName, + attributes, + startTime, }); -} + + if (span) { + span.addEvent('inp', { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value, + }); + + span.end(startTime + duration); + } +}; /** * Register a listener to cache route information for INP interactions. diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 7b9d7e562f37..cb84908ce55b 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -158,13 +158,17 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); } +export type InstrumentationHandlerCallback = (data: { + metric: Omit & { + entries: PerformanceEventTiming[]; + }; +}) => void; + /** * Add a callback that will be triggered when a INP metric is available. * Returns a cleanup callback which can be called to remove the instrumentation handler. */ -export function addInpInstrumentationHandler( - callback: (data: { metric: Omit & { entries: PerformanceEventTiming[] } }) => void, -): CleanupHandlerCallback { +export function addInpInstrumentationHandler(callback: InstrumentationHandlerCallback): CleanupHandlerCallback { return addMetricObserver('inp', callback, instrumentInp, _previousInp); } diff --git a/packages/browser-utils/test/instrument/metrics/inpt.test.ts b/packages/browser-utils/test/instrument/metrics/inpt.test.ts new file mode 100644 index 000000000000..437ae650d0fe --- /dev/null +++ b/packages/browser-utils/test/instrument/metrics/inpt.test.ts @@ -0,0 +1,116 @@ +import { afterEach } from 'node:test'; +import { describe, expect, it, vi } from 'vitest'; +import { _onInp, _trackINP } from '../../../src/metrics/inp'; +import * as instrument from '../../../src/metrics/instrument'; +import * as utils from '../../../src/metrics/utils'; + +describe('_trackINP', () => { + const addInpInstrumentationHandler = vi.spyOn(instrument, 'addInpInstrumentationHandler'); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('adds an instrumentation handler', () => { + _trackINP(); + expect(addInpInstrumentationHandler).toHaveBeenCalledOnce(); + }); + + it('returns an unsubscribe dunction', () => { + const handler = _trackINP(); + expect(typeof handler).toBe('function'); + }); +}); + +describe('_onInp', () => { + it('early-returns if the INP metric entry has no value', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: undefined, + entries: [], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled(); + }); + + it('early-returns if the INP metric value is greater than 60 seconds', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: 60_001, + entries: [ + { name: 'click', duration: 60_001, interactionId: 1 }, + { name: 'click', duration: 60_000, interactionId: 2 }, + ], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled(); + }); + + it('early-returns if the inp metric has an unknown interaction type', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: 10, + entries: [{ name: 'unknown', duration: 10, interactionId: 1 }], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled(); + }); + + it('starts a span for a valid INP metric entry', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: 10, + entries: [{ name: 'click', duration: 10, interactionId: 1 }], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1); + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({ + attributes: { + 'sentry.exclusive_time': 10, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + }, + name: '', + startTime: NaN, + transaction: undefined, + }); + }); + + it('takes the correct entry based on the metric value', () => { + const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan'); + + const metric = { + value: 10, + entries: [ + { name: 'click', duration: 10, interactionId: 1 }, + { name: 'click', duration: 9, interactionId: 2 }, + ], + }; + // @ts-expect-error - incomplete metric object + _onInp({ metric }); + + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1); + expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({ + attributes: { + 'sentry.exclusive_time': 10, + 'sentry.op': 'ui.interaction.click', + 'sentry.origin': 'auto.http.browser.inp', + }, + name: '', + startTime: NaN, + transaction: undefined, + }); + }); +}); From 9d1c05ecf3dcd82bd7f9b358f8ac9ea8da5e15f8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Jun 2025 15:59:58 +0200 Subject: [PATCH 10/10] meta(changelog): Update changelog for 9.27.0 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e9251f671f..1823b8c57462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.27.0 + +- feat(node): Expand how vercel ai input/outputs can be set ([#16455](https://github.com/getsentry/sentry-javascript/pull/16455)) +- feat(node): Switch to new semantic conventions for Vercel AI ([#16476](https://github.com/getsentry/sentry-javascript/pull/16476)) +- feat(react-router): Add component annotation plugin ([#16472](https://github.com/getsentry/sentry-javascript/pull/16472)) +- feat(react-router): Export wrappers for server loaders and actions ([#16481](https://github.com/getsentry/sentry-javascript/pull/16481)) +- fix(browser): Ignore unrealistically long INP values ([#16484](https://github.com/getsentry/sentry-javascript/pull/16484)) +- fix(react-router): Conditionally add `ReactRouterServer` integration ([#16470](https://github.com/getsentry/sentry-javascript/pull/16470)) + ## 9.26.0 - feat(react-router): Re-export functions from `@sentry/react` ([#16465](https://github.com/getsentry/sentry-javascript/pull/16465))