From 25930c7e48a4c21b50235321cd4a68910389df7e Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 22 Sep 2026 09:10:03 +0200 Subject: [PATCH 1/8] feat: Port UI span names to low-cardinality conventions When span streaming is enabled, UI component, interaction, action, and web-vital spans use low-cardinality names instead of DOM paths or angle-bracketed component names. Co-Authored-By: Cursor --- MIGRATION.md | 76 +++++++++------ .../test.ts | 19 +++- .../suites/tracing/interactions/spans/test.ts | 25 +++-- .../tests/tracing.client.test.ts | 4 +- .../nuxt-3-min/tests/tracing.client.test.ts | 4 +- .../tests/tracing.client.test.ts | 4 +- .../nuxt-3/tests/tracing.client.test.ts | 4 +- .../nuxt-4/tests/tracing.client.test.ts | 16 +-- .../nuxt-5/tests/tracing.client.test.ts | 16 +-- .../vue-3/tests/performance.test.ts | 44 ++++++--- packages/angular/src/tracing.ts | 29 +++++- packages/angular/test/tracing.test.ts | 92 +++++++++++++++++- .../browser-utils/src/performance/entries.ts | 20 +++- .../src/performance/interactions.ts | 67 ++++++++++--- .../browser-utils/src/web-vitals/spans.ts | 75 +++++++++++++- .../test/performance/interactions.test.ts | 97 ++++++++++++++++--- .../test/web-vitals/spans.test.ts | 94 +++++++++++++++++- .../browser/src/integrations/spotlight.ts | 5 +- .../test/integrations/spotlight.test.ts | 11 ++- packages/core/src/tracing/spans/spanNames.ts | 72 ++++++++++++++ .../ember/src/utils/instrumentEmberGlobals.ts | 26 ++++- packages/react/src/profiler.tsx | 63 +++++++++--- packages/react/test/profiler.test.tsx | 46 ++++++++- packages/svelte/src/performance.ts | 29 ++++-- packages/svelte/test/performance.test.ts | 77 ++++++++++++++- packages/vue/src/tracing.ts | 31 ++++-- .../integration/mixinRegistration.test.ts | 36 +++++++ .../vue/test/tracing/tracingMixin.test.ts | 1 + 28 files changed, 925 insertions(+), 158 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index ab36abf504f4..1ec46c6208fa 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1022,37 +1022,43 @@ Two things hold throughout this section: The following span names were adjusted: -| Span op | Before | Example | After | Example | -| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Pageload` if the SDK has none | `/users/:id`, `Pageload` | -| `navigation`, `navigation.redirect` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Navigation` if the SDK has none | `/users/:id`, `Navigation` | -| `resource.*` | The resource URL, relative to the page origin for same-origin resources | `/assets/app.js` | The resource domain, or `Resource` if the SDK has none | `cdn.example.com`, `Resource` | -| `browser.*` (navigation timing) | The document URL | `https://example.com/users/123?ref=x` | A static name per timing phase. The URL stays on `url.full` | `DNS lookup`, `Request`, `Load event` | -| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one | `GET /users/:id`, `GET /users/123` | The request method and route when one is known, otherwise just the method | `GET /users/:id`, `GET` | -| `http.client`, `http.client.stream` | The request method and sanitized URL | `GET https://api.example.com/users/123` | The request method and the domain, or just the method if there is no domain | `GET api.example.com`, `GET` | -| `router` | Framework-specific, sometimes containing the raw URL | `/users/123`, `SvelteKit Route Change` | The span's `http.route`, or `Router` if the SDK has none | `/users/:id`, `Router` | -| `handler` | Framework-specific, often carrying the request method | `GET /users/:id`, `route-handler`, `getUser` | The span's `http.route`, or `Request handler` if the SDK has none | `/users/:id`, `Request handler` | -| `function` | Integration-specific, sometimes the segment span's name | `serverAction/updateUser`, `LOADER routes/users.$id` | The span's `code.function.name`. The previous name is kept as the span description | `updateUser`, `loader` | -| `function` (Angular `TraceMethod`) | The decorator's `name` option in angle brackets | ``, `` | The decorator's `name` option, or `Function execution` if it has none | `Login.ngOnInit`, `getUsers`, `Function execution` | -| `function` (SvelteKit) | The route the wrapped function ran for, or the raw URL path if the SDK couldn't resolve one | `/users/[id]`, `/users/123`, `GET /api/users/[id]` | The name of the wrapped function | `load`, `GET` | -| `function` (Ember route hooks) | The full route name | `slow-loading-route.index` | The hook the span wraps, matching its `code.function.name`. The route moves to `sentry.description` | `beforeModel`, `model`, `setupController` | -| `function` (React Router route hooks) | The route the hook ran for, the raw URL path if React Router matched no pattern, or the fetcher key | `/users/:id`, `/users/123`, `Fetcher fetcher-1` | The hook the span wraps, matching its `code.function.name`. The previous name moves to `sentry.description` | `loader`, `action`, `clientLoader`, `fetcher` | -| `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | -| `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | -| `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | -| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing | `chat gpt-4`, `chat unknown` | `{operation} {model}`, or `{operation}` if the model is missing | `chat gpt-4`, `chat` | -| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation | `chain format_prompt`, `chain unknown_chain` | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order, or `{operation}` if the span carries none | `invoke_agent format_prompt`, `invoke_agent` | -| `mcp.server` | The method and its target, including the resource URI | `resources/read file:///docs/api.md`, `tools/call get-weather` | The method alone for resource methods. Tool and prompt names are unchanged | `resources/read`, `tools/call get-weather` | -| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name | `notifications/tools/list_changed` | The notification method name, or `MCP notification` if the message carries none | `notifications/tools/list_changed`, `MCP notification` | -| `queue.publish` | Integration-specific | `publish my-exchange`, `send my-topic` | The messaging operation type and the destination, or just the operation type when the destination has no name | `send my-exchange`, `send` | -| `queue.process` | Integration-specific, sometimes containing per-message data | `my-queue process`, `order.created.12345 process` | The messaging operation type and the destination, or just the operation type when the destination has no name | `process my-queue`, `process` | -| `queue.receive` | The kafkajs operation name | `poll my-topic` | The messaging operation type and the destination | `receive my-topic` | -| `cache.*` | The cache key(s), or for dataloader the operation and loader name | `user:123`, `dataloader.load usersLoader` | The cache operation | `cache.get`, `cache.put`, `cache.remove` | -| `db`, `db.query` (SQL) | The statement the driver ran | `SELECT * FROM "User" WHERE id = $1` | A summary of it, or, where there is no statement, the next template the driver can fill: the operation and table, the namespace, the database system, and `Database operation` last | `SELECT "User"`, `postgresql` | -| `db` (mongodb) | The serialized command, or `mongodb.` where there is none | `mongodb.find` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `find users`, `mongodb` | -| `db` (mongoose) | `mongoose..` | `mongoose.BlogPost.findOne` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `findOne blogposts` | -| `db` (supabase) | The query builder call and the table, or `auth ` for auth calls | `select(...) from(users)`, `auth signInWithPassword` | The operation and the table, or the dotted auth method | `select users`, `auth.signInWithPassword` | -| `db.query` (redis, ioredis) | The serialized command, with its arguments redacted, or `redis-` on the diagnostics-channel path | `set test-key [1 other arguments]`, `redis-SET` | The operation and the connection, the operation and the redis function for `FCALL`/`FCALL_RO`, or `redis` when the SDK knows neither | `SET localhost:6379`, `fcall my_func`, `redis` | +| Span op | Before | Example | After | Example | +| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Pageload` if the SDK has none | `/users/:id`, `Pageload` | +| `navigation`, `navigation.redirect` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Navigation` if the SDK has none | `/users/:id`, `Navigation` | +| `resource.*` | The resource URL, relative to the page origin for same-origin resources | `/assets/app.js` | The resource domain, or `Resource` if the SDK has none | `cdn.example.com`, `Resource` | +| `browser.*` (navigation timing) | The document URL | `https://example.com/users/123?ref=x` | A static name per timing phase. The URL stays on `url.full` | `DNS lookup`, `Request`, `Load event` | +| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one | `GET /users/:id`, `GET /users/123` | The request method and route when one is known, otherwise just the method | `GET /users/:id`, `GET` | +| `http.client`, `http.client.stream` | The request method and sanitized URL | `GET https://api.example.com/users/123` | The request method and the domain, or just the method if there is no domain | `GET api.example.com`, `GET` | +| `router` | Framework-specific, sometimes containing the raw URL | `/users/123`, `SvelteKit Route Change` | The span's `http.route`, or `Router` if the SDK has none | `/users/:id`, `Router` | +| `handler` | Framework-specific, often carrying the request method | `GET /users/:id`, `route-handler`, `getUser` | The span's `http.route`, or `Request handler` if the SDK has none | `/users/:id`, `Request handler` | +| `function` | Integration-specific, sometimes the segment span's name | `serverAction/updateUser`, `LOADER routes/users.$id` | The span's `code.function.name`. The previous name is kept as the span description | `updateUser`, `loader` | +| `function` (Angular `TraceMethod`) | The decorator's `name` option in angle brackets | ``, `` | The decorator's `name` option, or `Function execution` if it has none | `Login.ngOnInit`, `getUsers`, `Function execution` | +| `function` (SvelteKit) | The route the wrapped function ran for, or the raw URL path if the SDK couldn't resolve one | `/users/[id]`, `/users/123`, `GET /api/users/[id]` | The name of the wrapped function | `load`, `GET` | +| `function` (Ember route hooks) | The full route name | `slow-loading-route.index` | The hook the span wraps, matching its `code.function.name`. The route moves to `sentry.description` | `beforeModel`, `model`, `setupController` | +| `function` (React Router route hooks) | The route the hook ran for, the raw URL path if React Router matched no pattern, or the fetcher key | `/users/:id`, `/users/123`, `Fetcher fetcher-1` | The hook the span wraps, matching its `code.function.name`. The previous name moves to `sentry.description` | `loader`, `action`, `clientLoader`, `fetcher` | +| `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | +| `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | +| `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | +| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing | `chat gpt-4`, `chat unknown` | `{operation} {model}`, or `{operation}` if the model is missing | `chat gpt-4`, `chat` | +| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation | `chain format_prompt`, `chain unknown_chain` | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order, or `{operation}` if the span carries none | `invoke_agent format_prompt`, `invoke_agent` | +| `mcp.server` | The method and its target, including the resource URI | `resources/read file:///docs/api.md`, `tools/call get-weather` | The method alone for resource methods. Tool and prompt names are unchanged | `resources/read`, `tools/call get-weather` | +| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name | `notifications/tools/list_changed` | The notification method name, or `MCP notification` if the message carries none | `notifications/tools/list_changed`, `MCP notification` | +| `queue.publish` | Integration-specific | `publish my-exchange`, `send my-topic` | The messaging operation type and the destination, or just the operation type when the destination has no name | `send my-exchange`, `send` | +| `queue.process` | Integration-specific, sometimes containing per-message data | `my-queue process`, `order.created.12345 process` | The messaging operation type and the destination, or just the operation type when the destination has no name | `process my-queue`, `process` | +| `queue.receive` | The kafkajs operation name | `poll my-topic` | The messaging operation type and the destination | `receive my-topic` | +| `cache.*` | The cache key(s), or for dataloader the operation and loader name | `user:123`, `dataloader.load usersLoader` | The cache operation | `cache.get`, `cache.put`, `cache.remove` | +| `ui.mount`, `ui.render`, `ui.update`, `ui.unmount` | The component name in angle brackets, sometimes with a framework prefix | ``, `Vue `, `Application Render`, `init` | The unwrapped component name, or `UI component` if the SDK has none | `UserList`, `Root`, `UI component` | +| `ui.task` | A static label for the work | `runloop` | `UI task` | `UI task` | +| `ui.action.click` | The pageload or navigation span's name, including `Pageload`/`Navigation` fallbacks | `/users/:id`, `Pageload` | The span's `url.template` or `http.route`, or `Click` if the SDK has neither | `/users/:id`, `Click` | +| `ui.interaction.click`, `ui.interaction.hover`, `ui.interaction.drag`, `ui.interaction.press` | The element's DOM path | `body > button.submit` | The annotated component name, or `Click`/`Hover`/`Drag`/`Press` | `SubmitButton`, `Click` | +| `ui.webvital.lcp` | The LCP element's DOM path, or `Largest contentful paint` | `body > img#hero` | The annotated component name, or `Largest contentful paint` | `HeroImage`, `Largest contentful paint` | +| `ui.webvital.cls` | The first layout-shift source's DOM path, or `Layout shift` | `body > div.banner` | The annotated component name, or `Layout shift` | `Banner`, `Layout shift` | +| `db`, `db.query` (SQL) | The statement the driver ran | `SELECT * FROM "User" WHERE id = $1` | A summary of it, or, where there is no statement, the next template the driver can fill: the operation and table, the namespace, the database system, and `Database operation` last | `SELECT "User"`, `postgresql` | +| `db` (mongodb) | The serialized command, or `mongodb.` where there is none | `mongodb.find` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `find users`, `mongodb` | +| `db` (mongoose) | `mongoose..` | `mongoose.BlogPost.findOne` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `findOne blogposts` | +| `db` (supabase) | The query builder call and the table, or `auth ` for auth calls | `select(...) from(users)`, `auth signInWithPassword` | The operation and the table, or the dotted auth method | `select users`, `auth.signInWithPassword` | +| `db.query` (redis, ioredis) | The serialized command, with its arguments redacted, or `redis-` on the diagnostics-channel path | `set test-key [1 other arguments]`, `redis-SET` | The operation and the connection, the operation and the redis function for `FCALL`/`FCALL_RO`, or `redis` when the SDK knows neither | `SET localhost:6379`, `fcall my_func`, `redis` | #### Browser navigation timing spans @@ -1203,6 +1209,14 @@ For the Redis cache integration, the `maxCacheKeyLength` option no longer has an A dataloader span no longer carries the loader's `name` either (`dataloader.load usersLoader` becomes `cache.get`). The loader `name` is reported on the `db.collection.name` attribute instead. +#### UI spans + +Component spans from the React profiler, Vue mixins, Svelte `trackComponent`, Angular `TraceDirective`/`TraceClass`, and Ember's initial load drop the angle brackets (and Vue's `Vue ` prefix) from the name. Vue's `Application Render` becomes `Root`, while Ember's `init` becomes `UI component`. Ember runloop spans become `UI task`. The original name is on `sentry.description` so `ignoreSpans` and `beforeSendSpan` rules that matched `` or `Vue ` have to match that attribute, or the unwrapped name. + +Click idle spans (`ui.action.click`) are no longer named after the pageload/navigation span, so they no longer inherit `Pageload` or `Navigation`. They take `url.template` or `http.route` from that route span, or fall back to `Click`. Event Timing and INP `ui.interaction.*` spans are named after `ui.component_name` when the element is annotated, otherwise `Click`/`Hover`/`Drag`/`Press`. The DOM path they used to use as a name is on `ui.element.selector`. Spotlight's built-in `ignoreSpans` filter matches that attribute. + +LCP and CLS spans follow the same pattern: a component name when one is annotated, otherwise `Largest contentful paint` / `Layout shift`. Long task and long animation frame names are unchanged (`Main UI thread blocked`); they now also set `sentry.description` so the backend does not infer a description from `code.function.name` on loAF spans. + #### Database spans The `pg`, `postgres.js`, `mysql`, `mysql2`, `knex`, `tedious`, Prisma, Nitro `db0` and Cloudflare D1 instrumentations all name their query spans the same way. SQL query spans are named after the summary of the statement rather than the statement itself. A statement that touches no table (`SELECT NOW()`) summarizes to the bare operation (`SELECT`). The full, sanitized statement remains available on `db.query.text`, and the summary on the new [`db.query.summary` attribute](#messaging-and-database-attributes). diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts index e9c27f682272..f71fd473702e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/spotlight-interaction-filter-streamed/test.ts @@ -16,7 +16,10 @@ sentryTest( // Set up an observer that fails if a spotlight interaction span is ever sent let sawSpotlightInteractionSpan = false; await observeStreamedSpan(page, span => { - if (getSpanOp(span) === 'ui.interaction.click' && span.name?.includes('#sentry-spotlight')) { + if ( + getSpanOp(span) === 'ui.interaction.click' && + String(span.attributes?.['ui.element.selector']?.value ?? span.name).includes('#sentry-spotlight') + ) { sawSpotlightInteractionSpan = true; return true; } @@ -39,7 +42,11 @@ sentryTest( // Click on the regular button — its ui.interaction.click child should be kept const regularInteractionSpansPromise = waitForStreamedSpans(page, spans => - spans.some(span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight')), + spans.some( + span => + getSpanOp(span) === 'ui.interaction.click' && + !String(span.attributes?.['ui.element.selector']?.value ?? span.name).includes('#sentry-spotlight'), + ), ); await page.locator('[data-test-id=regular-button]').click(); @@ -47,10 +54,14 @@ sentryTest( const regularSpans = await regularInteractionSpansPromise; const regularInteractionSpan = regularSpans.find( - span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight'), + span => + getSpanOp(span) === 'ui.interaction.click' && + !String(span.attributes?.['ui.element.selector']?.value ?? span.name).includes('#sentry-spotlight'), ); expect(regularInteractionSpan).toBeDefined(); - expect(regularInteractionSpan!.name).toContain('button'); + expect( + String(regularInteractionSpan!.attributes?.['ui.element.selector']?.value ?? regularInteractionSpan!.name), + ).toContain('button'); // Verify no spotlight interaction span was ever sent expect(sawSpotlightInteractionSpan).toBe(false); diff --git a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts index 1e8f653607b9..13921d84633a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts @@ -8,6 +8,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { + SENTRY_DESCRIPTION, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, @@ -15,6 +16,7 @@ import { SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, + URL_PATH, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -70,6 +72,10 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: expect.any(String), }, + [URL_PATH]: { + type: 'string', + value: '/index.html', + }, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'idleTimeout', @@ -100,11 +106,15 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser }, [SENTRY_SEGMENT_NAME]: { type: 'string', - value: 'Pageload', + value: 'Click', }, [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', - value: 'url', + value: 'custom', + }, + [SENTRY_DESCRIPTION]: { + type: 'string', + value: 'Pageload', }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', @@ -113,8 +123,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser }, end_timestamp: expect.any(Number), is_segment: true, - // Interaction spans are named after the current route, which is the pageload span's name. - name: 'Pageload', + name: 'Click', span_id: interactionSegmentSpan!.span_id, start_timestamp: expect.any(Number), status: 'ok', @@ -157,16 +166,20 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser }, [SENTRY_SEGMENT_NAME]: { type: 'string', - value: 'Pageload', + value: 'Click', }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, + 'ui.element.selector': { + type: 'string', + value: 'body > button.clicked', + }, }, end_timestamp: expect.any(Number), is_segment: false, - name: 'body > button.clicked', + name: 'Click', parent_span_id: interactionSegmentSpan!.span_id, span_id: expect.stringMatching(/^[\da-f]{16}$/), start_timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts index aed3ad44fd0a..05824f86c3f5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts @@ -32,10 +32,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts index 00c10eed15a0..0ba8a40c6e01 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts @@ -31,10 +31,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts index e7a36a246a23..cb53a2c6941f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts @@ -31,10 +31,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts index b31d9afd311f..0905c8fa32a7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts @@ -32,10 +32,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts index b073eda0b333..3d86b1da1248 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts @@ -56,10 +56,12 @@ test('sends an application render span and a root component span on pageload', a const spans = await spansPromise; const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue'); - const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render'); + const applicationRenderSpans = uiSpans.filter( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.render', + ); expect(applicationRenderSpans).toHaveLength(1); expect(applicationRenderSpans[0]).toMatchObject({ - name: 'Application Render', + name: 'Root', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -72,10 +74,12 @@ test('sends an application render span and a root component span on pageload', a }), }); - const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue '); + const rootComponentSpans = uiSpans.filter( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.mount', + ); expect(rootComponentSpans).toHaveLength(1); expect(rootComponentSpans[0]).toMatchObject({ - name: 'Vue ', + name: 'Root', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -98,10 +102,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts index f8af579792eb..d28e6969291b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -58,10 +58,10 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( await page.goto(`/client-error`); const spans = await spansPromise; - const errorButtonSpan = spans.find(span => span.name === 'Vue '); + const errorButtonSpan = spans.find(span => span.name === 'ErrorButton'); expect(errorButtonSpan).toMatchObject({ - name: 'Vue ', + name: 'ErrorButton', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -86,10 +86,12 @@ test('sends an application render span and a root component span on pageload', a const spans = await spansPromise; const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue'); - const applicationRenderSpans = uiSpans.filter(span => span.name === 'Application Render'); + const applicationRenderSpans = uiSpans.filter( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.render', + ); expect(applicationRenderSpans).toHaveLength(1); expect(applicationRenderSpans[0]).toMatchObject({ - name: 'Application Render', + name: 'Root', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -102,10 +104,12 @@ test('sends an application render span and a root component span on pageload', a }), }); - const rootComponentSpans = uiSpans.filter(span => span.name === 'Vue '); + const rootComponentSpans = uiSpans.filter( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.mount', + ); expect(rootComponentSpans).toHaveLength(1); expect(rootComponentSpans[0]).toMatchObject({ - name: 'Vue ', + name: 'Root', is_segment: false, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index d0df920e9fd0..696f51f00f21 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -117,14 +117,14 @@ test('sends a pageload span with a route name as span name if available', async route: '/', routeDescription: 'a route with a synchronously mounted component', // `HomeView` is missing from `trackComponents`, so the root spans are the only UI spans. - expectedUiSpanNames: ['Application Render', 'Vue '].sort(), + expectedUiSpanNames: ['Root', 'Root'], }, { route: '/components', routeDescription: 'a route with an async component', expectedUiSpanNames: OPTIONS_API_DISABLED - ? ['Application Render', 'Vue '].sort() - : ['Application Render', 'Vue ', 'Vue ', 'Vue '].sort(), + ? ['Root', 'Root'] + : ['ComponentMainView', 'ComponentOneView', 'Root', 'Root'], }, ].forEach(({ route, routeDescription, expectedUiSpanNames }) => { test(`sends an application render span and a root component span on ${routeDescription}`, async ({ page }) => { @@ -144,21 +144,27 @@ test('sends a pageload span with a route name as span name if available', async expect(uiSpans.map(span => span.name).sort()).toEqual(expectedUiSpanNames); - const applicationRenderSpan = uiSpans.find(span => span.name === 'Application Render'); + const applicationRenderSpan = uiSpans.find( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.render', + ); expect(applicationRenderSpan).toMatchObject({ - name: 'Application Render', + name: 'Root', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.render' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Application Render' }, }), }); - const rootComponentSpan = uiSpans.find(span => span.name === 'Vue '); + const rootComponentSpan = uiSpans.find( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.mount', + ); expect(rootComponentSpan).toMatchObject({ - name: 'Vue ', + name: 'Root', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); @@ -182,9 +188,9 @@ test('ends the application render span before a delayed async component mounts', const uiSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ui.vue'); // Neither `DelayedView` nor its child is in `trackComponents`, so both variants expect the same set. - expect(uiSpans.map(span => span.name).sort()).toEqual(['Application Render', 'Vue ']); + expect(uiSpans.map(span => span.name).sort()).toEqual(['Root', 'Root']); - const applicationRenderSpan = uiSpans.find(span => span.name === 'Application Render'); + const applicationRenderSpan = uiSpans.find(span => span.attributes['sentry.op']?.value === 'ui.render'); expect(applicationRenderSpan?.start_timestamp).toEqual(expect.any(Number)); expect(applicationRenderSpan?.end_timestamp).toEqual(expect.any(Number)); @@ -197,7 +203,7 @@ test('sends a lifecycle span for the root and for each tracked component only', // below still come from `app.mixin()`, which is a no-op when the Options API is disabled. test.fail(OPTIONS_API_DISABLED, 'Component tracking (`trackComponents`) needs the Options API'); - const expectedUiSpanNames = ['Application Render', 'Vue ', 'Vue ', 'Vue ']; + const expectedUiSpanNames = ['ComponentMainView', 'ComponentOneView', 'Root', 'Root']; const spansPromise = collectStreamedSpans('vue-3', spans => { return ( @@ -232,37 +238,45 @@ test('sends a lifecycle span for the root and for each tracked component only', expect(uiSpanNames).toEqual(expectedUiSpanNames); - const applicationRenderSpan = uiSpans.find(span => span.name === 'Application Render'); + const applicationRenderSpan = uiSpans.find( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.render', + ); expect(applicationRenderSpan).toMatchObject({ attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.render' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Application Render' }, }), }); - const rootComponentSpan = uiSpans.find(span => span.name === 'Vue '); + const rootComponentSpan = uiSpans.find( + span => span.name === 'Root' && span.attributes['sentry.op']?.value === 'ui.mount', + ); expect(rootComponentSpan).toMatchObject({ attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); - const componentMainViewSpan = uiSpans.find(span => span.name === 'Vue '); + const componentMainViewSpan = uiSpans.find(span => span.name === 'ComponentMainView'); expect(componentMainViewSpan).toMatchObject({ attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); - const componentOneViewSpan = uiSpans.find(span => span.name === 'Vue '); + const componentOneViewSpan = uiSpans.find(span => span.name === 'ComponentOneView'); expect(componentOneViewSpan).toMatchObject({ attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); - expect(uiSpanNames).not.toContain('Vue '); + expect(uiSpanNames).not.toContain('ComponentTwoView'); }); diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index de2059ecdf8f..49334e687f22 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import { ElementRef } from '@angular/core'; @@ -28,6 +29,7 @@ import { URL_PATH, URL_TEMPLATE, SENTRY_DESCRIPTION, + UI_COMPONENT_NAME, } from '@sentry/conventions/attributes'; import { FUNCTION, ROUTER } from '@sentry/conventions/op'; import type { Integration, Span } from '@sentry/core'; @@ -41,6 +43,7 @@ import { timestampInSeconds, filterCollectedUrl, FUNCTION_SPAN_NAME_FALLBACK, + UI_COMPONENT_SPAN_NAME_FALLBACK, } from '@sentry/core'; import type { Observable } from 'rxjs'; import { Subscription } from 'rxjs'; @@ -302,6 +305,7 @@ export class TraceDirective implements OnInit, AfterViewInit { * @inheritdoc */ public ngOnInit(): void { + const explicitComponentName = this.componentName; if (!this.componentName) { // Technically, the `trace` binding should always be provided. // However, if it is incorrectly declared on the element without a @@ -311,12 +315,19 @@ export class TraceDirective implements OnInit, AfterViewInit { } if (getActiveSpan()) { + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const innerName = this.componentName; + const description = `<${innerName}>`; + this._tracingSpan = runOutsideAngular(() => startInactiveSpan({ - name: `<${this.componentName}>`, + name: hasSpanStreaming ? explicitComponentName || UI_COMPONENT_SPAN_NAME_FALLBACK : description, attributes: { [SENTRY_OP]: UI_MOUNT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + ...(explicitComponentName && { [UI_COMPONENT_NAME]: explicitComponentName }), + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }), }, }), ); @@ -355,22 +366,30 @@ interface TraceClassOptions { * Decorator function that can be used to capture initialization lifecycle of the whole component. */ export function TraceClass(options?: TraceClassOptions): ClassDecorator { - let tracingSpan: Span; + const tracingSpans = new WeakMap(); /* eslint-disable @typescript-eslint/no-unsafe-member-access */ return target => { + const componentName = options?.name || target.name; const originalOnInit = target.prototype.ngOnInit; target.prototype.ngOnInit = function (...args: unknown[]): ReturnType { - tracingSpan = runOutsideAngular(() => + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const description = `<${options?.name || 'unnamed'}>`; + + const tracingSpan = runOutsideAngular(() => startInactiveSpan({ onlyIfParent: true, - name: `<${options?.name || 'unnamed'}>`, + name: hasSpanStreaming ? componentName || UI_COMPONENT_SPAN_NAME_FALLBACK : description, attributes: { [SENTRY_OP]: UI_MOUNT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + ...(componentName && { [UI_COMPONENT_NAME]: componentName }), + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }), }, }), ); + tracingSpans.set(this, tracingSpan); if (originalOnInit) { return originalOnInit.apply(this, args); @@ -379,8 +398,10 @@ export function TraceClass(options?: TraceClassOptions): ClassDecorator { const originalAfterViewInit = target.prototype.ngAfterViewInit; target.prototype.ngAfterViewInit = function (...args: unknown[]): ReturnType { + const tracingSpan = tracingSpans.get(this); if (tracingSpan) { runOutsideAngular(() => tracingSpan.end()); + tracingSpans.delete(this); } if (originalAfterViewInit) { return originalAfterViewInit.apply(this, args); diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index b1ffa5451728..864922f08ae4 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -1,7 +1,15 @@ +import { ElementRef } from '@angular/core'; import type { ActivatedRouteSnapshot } from '@angular/router'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SentrySpan, spanToJSON } from '@sentry/core'; +import { + getMainCarrier, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SentrySpan, + spanToJSON, + startSpan, + UI_COMPONENT_SPAN_NAME_FALLBACK, +} from '@sentry/core'; import { describe, it } from 'vitest'; -import { browserTracingIntegration, init, TraceDirective } from '../src/index'; +import { browserTracingIntegration, init, TraceClass, TraceDirective } from '../src/index'; import { _updateSpanAttributesForParametrizedUrl, getParameterizedRouteFromSnapshot } from '../src/tracing'; import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { expect } from 'vitest'; @@ -119,5 +127,85 @@ describe('Angular Tracing', () => { const directive = new TraceDirective(); expect(directive).toBeTruthy(); }); + + it('uses the UI component fallback for a selector-derived name when span streaming is enabled', () => { + getMainCarrier().__SENTRY__ = undefined; + const spans: SentrySpan[] = []; + const client = init({ defaultIntegrations: false, tracesSampleRate: 1, traceLifecycle: 'stream' }); + client?.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.origin'] === 'auto.ui.angular.trace_directive') { + spans.push(span as SentrySpan); + } + }); + + startSpan({ name: 'outer' }, () => { + const directive = new TraceDirective(new ElementRef(document.createElement('app-profile'))); + directive.ngOnInit(); + directive.ngAfterViewInit(); + }); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).name).toBe(UI_COMPONENT_SPAN_NAME_FALLBACK); + expect(spanToJSON(spans[0]!).attributes['sentry.description']).toBe(''); + expect(spanToJSON(spans[0]!).attributes['ui.component_name']).toBeUndefined(); + }); + }); + + describe('TraceClass', () => { + it('uses the decorated class name when span streaming is enabled', () => { + getMainCarrier().__SENTRY__ = undefined; + const spans: SentrySpan[] = []; + const client = init({ defaultIntegrations: false, tracesSampleRate: 1, traceLifecycle: 'stream' }); + client?.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.origin'] === 'auto.ui.angular.trace_class_decorator') { + spans.push(span as SentrySpan); + } + }); + + class ProfileCard { + public ngOnInit(): void {} + public ngAfterViewInit(): void {} + } + TraceClass()(ProfileCard); + + startSpan({ name: 'outer' }, () => { + const instance = new ProfileCard(); + instance.ngOnInit(); + instance.ngAfterViewInit(); + }); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).name).toBe('ProfileCard'); + expect(spanToJSON(spans[0]!).attributes['sentry.description']).toBe(''); + expect(spanToJSON(spans[0]!).attributes['ui.component_name']).toBe('ProfileCard'); + }); + + it('tracks lifecycle spans independently for concurrent component instances', () => { + getMainCarrier().__SENTRY__ = undefined; + const spans: SentrySpan[] = []; + const client = init({ defaultIntegrations: false, tracesSampleRate: 1 }); + client?.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.origin'] === 'auto.ui.angular.trace_class_decorator') { + spans.push(span as SentrySpan); + } + }); + + class ProfileCard { + public ngOnInit(): void {} + public ngAfterViewInit(): void {} + } + TraceClass({ name: 'ProfileCard' })(ProfileCard); + + startSpan({ name: 'outer' }, () => { + const first = new ProfileCard(); + const second = new ProfileCard(); + first.ngOnInit(); + second.ngOnInit(); + first.ngAfterViewInit(); + second.ngAfterViewInit(); + }); + + expect(spans).toHaveLength(2); + }); }); }); diff --git a/packages/browser-utils/src/performance/entries.ts b/packages/browser-utils/src/performance/entries.ts index 336122567ade..6cc5978f95d5 100644 --- a/packages/browser-utils/src/performance/entries.ts +++ b/packages/browser-utils/src/performance/entries.ts @@ -4,12 +4,15 @@ import { BROWSER_NAVIGATION_TIMING_SPAN_NAMES, browserPerformanceTimeOrigin, getActiveSpan, + getClient, + hasSpanStreamingEnabled, parseUrl, RESOURCE_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, filterCollectedUrl, + UI_LONG_TASK_SPAN_NAME_FALLBACK, } from '@sentry/core'; import { BROWSER_PAINT_TYPE, @@ -22,6 +25,7 @@ import { NETWORK_CONNECTION_EFFECTIVE_TYPE, NETWORK_CONNECTION_RTT, NETWORK_CONNECTION_TYPE, + SENTRY_DESCRIPTION, SENTRY_OP, SERVER_ADDRESS, URL_DOMAIN, @@ -118,11 +122,15 @@ export function startTrackingLongTasks(): void { continue; } + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + startAndEndSpan(parent, startTime, startTime + duration, { - name: 'Main UI thread blocked', + name: UI_LONG_TASK_SPAN_NAME_FALLBACK, op: UI_LONG_TASK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: UI_LONG_TASK_SPAN_NAME_FALLBACK }), }, }); } @@ -180,10 +188,16 @@ export function startTrackingLongAnimationFrames(): void { attributes['browser.script.source_char_position'] = sourceCharPosition; } + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + startAndEndSpan(parent, startTime, startTime + duration, { - name: 'Main UI thread blocked', + name: UI_LONG_TASK_SPAN_NAME_FALLBACK, op: UI_LONG_ANIMATION_FRAME, - attributes, + attributes: { + ...attributes, + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: UI_LONG_TASK_SPAN_NAME_FALLBACK }), + }, }); } }); diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts index 067b549a707f..c2a1aad57e2d 100644 --- a/packages/browser-utils/src/performance/interactions.ts +++ b/packages/browser-utils/src/performance/interactions.ts @@ -1,20 +1,30 @@ import { + HTTP_ROUTE, + SENTRY_DESCRIPTION, + SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_OP, SENTRY_ORIGIN, - SENTRY_IDLE_SPAN_FINISH_REASON, - UI_COMPONENT_NAME, - SENTRY_SOURCE, + SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_SOURCE, + UI_COMPONENT_NAME, + URL_FULL, + URL_PATH, + URL_TEMPLATE, } from '@sentry/conventions/attributes'; -import { UI_INTERACTION_CLICK, UI_ACTION_CLICK } from '@sentry/conventions/op'; -import type { IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core'; +import { UI_ACTION_CLICK, UI_INTERACTION_CLICK } from '@sentry/conventions/op'; +import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, defineIntegration, + filterCollectedUrl, getActiveSpan, getRootSpan, + hasSpanStreamingEnabled, spanToJSON, + UI_ACTION_CLICK_SPAN_NAME_FALLBACK, + UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK, } from '@sentry/core'; import { startIdleSpan } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; @@ -56,6 +66,10 @@ interface InteractionsOptions { interface RouteInfo { name: string | undefined; source: TransactionSource | undefined; + urlTemplate: string | undefined; + httpRoute: string | undefined; + urlPath: string | undefined; + urlFull: string | undefined; } const _interactionsIntegration = ((options: InteractionsOptions = {}) => { @@ -66,7 +80,14 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { return; } - const latestRoute: RouteInfo = { name: undefined, source: undefined }; + const latestRoute: RouteInfo = { + name: undefined, + source: undefined, + urlTemplate: undefined, + httpRoute: undefined, + urlPath: undefined, + urlFull: undefined, + }; // The pageload/navigation span that is currently in progress, if any. Clicks that happen while one // is open are not turned into interaction spans, as they'd compete with the route span for children. let inflightRouteSpan: Span | undefined; @@ -78,6 +99,10 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { latestRoute.source = (attributes[SENTRY_SOURCE] || attributes[SENTRY_SEGMENT_NAME_SOURCE]) as | TransactionSource | undefined; + latestRoute.urlTemplate = attributes[URL_TEMPLATE] as string | undefined; + latestRoute.httpRoute = attributes[HTTP_ROUTE] as string | undefined; + latestRoute.urlPath = attributes[URL_PATH] as string | undefined; + latestRoute.urlFull = attributes[URL_FULL] as string | undefined; } client.on('spanStart', span => { @@ -97,9 +122,9 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { trackRoute(span); }); - registerInteractionListener(latestRoute, () => inflightRouteSpan, options); + registerInteractionListener(client, latestRoute, () => inflightRouteSpan, options); - trackInteractionsAsSpans(); + trackInteractionsAsSpans(client); }, }; }) satisfies IntegrationFn; @@ -135,6 +160,7 @@ function isRouteSpan(span: Span): boolean { } function registerInteractionListener( + client: Client, latestRoute: RouteInfo, getInflightRouteSpan: () => Span | undefined, // `startIdleSpan` fills in `TRACING_DEFAULTS` for whatever is left out, which are the same defaults @@ -172,13 +198,24 @@ function registerInteractionListener( return; } + const hasSpanStreaming = hasSpanStreamingEnabled(client); + const description = latestRoute.name; + const streamedName = latestRoute.urlTemplate || latestRoute.httpRoute || UI_ACTION_CLICK_SPAN_NAME_FALLBACK; + const streamedNameSource = latestRoute.urlTemplate || latestRoute.httpRoute ? 'route' : 'custom'; + inflightInteractionSpan = startIdleSpan( { - name: latestRoute.name, + name: hasSpanStreaming ? streamedName : description, attributes: { [SENTRY_OP]: UI_ACTION_CLICK, - [SENTRY_SEGMENT_NAME_SOURCE]: latestRoute.source || 'url', + [SENTRY_SEGMENT_NAME_SOURCE]: hasSpanStreaming ? streamedNameSource : latestRoute.source || 'url', [SENTRY_ORIGIN]: 'auto.browser.interactions', + ...(hasSpanStreaming && { [SENTRY_SEGMENT_NAME]: streamedName }), + ...(latestRoute.urlTemplate && { [URL_TEMPLATE]: latestRoute.urlTemplate }), + ...(latestRoute.httpRoute && { [HTTP_ROUTE]: latestRoute.httpRoute }), + ...(latestRoute.urlPath && { [URL_PATH]: latestRoute.urlPath }), + ...(latestRoute.urlFull && { [URL_FULL]: filterCollectedUrl(latestRoute.urlFull) }), + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }), }, }, idleSpanOptions, @@ -191,7 +228,7 @@ function registerInteractionListener( /** * Record the browser's `event` timing entries for clicks as spans on the currently active span. */ -function trackInteractionsAsSpans(): void { +function trackInteractionsAsSpans(client: Client): void { addPerformanceInstrumentationHandler('event', ({ entries }) => { const parent = getActiveSpan(); if (!parent) { @@ -202,16 +239,20 @@ function trackInteractionsAsSpans(): void { const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); const duration = msToSec(entry.duration); + const selector = htmlTreeAsString(entry.target); + const componentName = getComponentName(entry.target); + const hasSpanStreaming = hasSpanStreamingEnabled(client); + const spanOptions: StartSpanOptions & Required> = { - name: htmlTreeAsString(entry.target), + name: hasSpanStreaming ? componentName || UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK : selector, startTime: startTime, attributes: { [SENTRY_OP]: UI_INTERACTION_CLICK, [SENTRY_ORIGIN]: 'auto.browser.interactions', + 'ui.element.selector': selector, }, }; - const componentName = getComponentName(entry.target); if (componentName) { spanOptions.attributes[UI_COMPONENT_NAME] = componentName; } diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index bda0c049358f..858c2a30f334 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -1,17 +1,26 @@ +/* eslint-disable max-lines */ import type { Client, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, getActiveSpan, + getClient, getRootSpan, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, timestampInSeconds, + UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK, + UI_INTERACTION_DRAG_SPAN_NAME_FALLBACK, + UI_INTERACTION_HOVER_SPAN_NAME_FALLBACK, + UI_INTERACTION_PRESS_SPAN_NAME_FALLBACK, + UI_WEBVITAL_CLS_SPAN_NAME_FALLBACK, + UI_WEBVITAL_LCP_SPAN_NAME_FALLBACK, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; +import { getComponentName } from '../component-name'; import type { InteractionType } from './inp'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION, UNKNOWN_ELEMENT_NAME } from './inp'; import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver'; @@ -21,7 +30,7 @@ import { addLcpInstrumentationHandler, } from '../instrumentation/performanceObserver'; import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; -import { BROWSER_NAVIGATION_TYPE } from '@sentry/conventions/attributes'; +import { BROWSER_NAVIGATION_TYPE, SENTRY_DESCRIPTION, UI_COMPONENT_NAME } from '@sentry/conventions/attributes'; import { _emitWebVitalSpan } from './emitSpan'; import { isValidLcpMetric } from './lcp'; import { listenForWebVitalReportEvents } from './reportEvents'; @@ -45,6 +54,13 @@ const INTERACTION_TYPE_TO_SPAN_OP: Record = { press: UI_INTERACTION_PRESS, }; +const INTERACTION_TYPE_TO_SPAN_NAME_FALLBACK: Record = { + click: UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK, + hover: UI_INTERACTION_HOVER_SPAN_NAME_FALLBACK, + drag: UI_INTERACTION_DRAG_SPAN_NAME_FALLBACK, + press: UI_INTERACTION_PRESS_SPAN_NAME_FALLBACK, +}; + type WebVitalMetric = Parameters[0]>[0]['metric']; type InpMetric = Parameters[0]['metric']; @@ -180,11 +196,25 @@ export function _sendLcpSpan( // Without an entry there is no render time to end at, so the span lasts the value it reports, // like an entry-less INP does. Ending at the time origin instead would invert the span. const endTime = entry ? msToSec(performanceTimeOrigin + entry.startTime) : startTime + msToSec(lcpValue); - const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + const description = entry ? htmlTreeAsString(entry.element) : UI_WEBVITAL_LCP_SPAN_NAME_FALLBACK; + const selector = entry ? description : undefined; + const componentName = entry?.element ? getComponentName(entry.element) : null; + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const name = hasSpanStreaming ? componentName || UI_WEBVITAL_LCP_SPAN_NAME_FALLBACK : description; const attributes: SpanAttributes = {}; - entry?.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + if (selector) { + attributes['ui.element.selector'] = selector; + attributes['browser.web_vital.lcp.element'] = selector; + } + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } + if (hasSpanStreaming && !selector) { + attributes[SENTRY_DESCRIPTION] = description; + } entry?.id && (attributes['browser.web_vital.lcp.id'] = entry.id); entry?.url && (attributes['browser.web_vital.lcp.url'] = entry.url); entry?.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime); @@ -271,10 +301,26 @@ export function _sendClsSpan( // land it outside that navigation, on the route that follows it. const offset = entry?.startTime ?? navigationStartTime ?? 0; const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds(); - const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + const firstSourceNode = entry?.sources[0]?.node; + const description = entry ? htmlTreeAsString(firstSourceNode) : UI_WEBVITAL_CLS_SPAN_NAME_FALLBACK; + const selector = entry ? description : undefined; + const componentName = firstSourceNode ? getComponentName(firstSourceNode) : null; + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const name = hasSpanStreaming ? componentName || UI_WEBVITAL_CLS_SPAN_NAME_FALLBACK : description; const attributes: SpanAttributes = {}; + if (selector) { + attributes['ui.element.selector'] = selector; + } + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } + if (hasSpanStreaming && !selector) { + attributes[SENTRY_DESCRIPTION] = description; + } + if (entry?.sources) { entry.sources.forEach((source, index) => { attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); @@ -384,7 +430,26 @@ export function _sendInpSpan( // With soft navigations the caller knows exactly which navigation the metric belongs to. Without // them we fall back to the span that was active when the interaction was observed. const spanToUse = attributedSpan || cachedContext?.span || rootSpan; - const name = cachedContext?.elementName || (entry ? htmlTreeAsString(entry.target) : 'Interaction to next paint'); + const selector = cachedContext?.elementName || (entry ? htmlTreeAsString(entry.target) : undefined); + const componentName = entry?.target ? getComponentName(entry.target) : null; + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const fallbackName = INTERACTION_TYPE_TO_SPAN_NAME_FALLBACK[interactionType]; + const description = selector !== undefined ? selector : 'Interaction to next paint'; + const name = hasSpanStreaming ? componentName || fallbackName : description; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, + }; + if (selector) { + attributes['ui.element.selector'] = selector; + } + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } + if (hasSpanStreaming && !selector) { + attributes[SENTRY_DESCRIPTION] = description; + } const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, diff --git a/packages/browser-utils/test/performance/interactions.test.ts b/packages/browser-utils/test/performance/interactions.test.ts index e3aa01ca9b1d..cbfa28170408 100644 --- a/packages/browser-utils/test/performance/interactions.test.ts +++ b/packages/browser-utils/test/performance/interactions.test.ts @@ -90,7 +90,14 @@ describe('interactionsIntegration', () => { it('starts an interaction span named after the last route', () => { interactionsIntegration().setup?.(client); - completeRouteSpan(new SentrySpan({ op: 'pageload', name: '/users/:id', sampled: true })); + completeRouteSpan( + new SentrySpan({ + op: 'pageload', + name: '/users/:id', + sampled: true, + attributes: { 'url.template': '/users/:id' }, + }), + ); click(); flushIdleSpan(); @@ -98,7 +105,26 @@ describe('interactionsIntegration', () => { const spans = getInteractionSpans(); expect(spans).toHaveLength(1); expect(spanToJSON(spans[0]!).name).toBe('/users/:id'); - expect(spanToJSON(spans[0]!).attributes).toMatchObject({ 'sentry.origin': 'auto.browser.interactions' }); + expect(spanToJSON(spans[0]!).attributes).toMatchObject({ + 'sentry.origin': 'auto.browser.interactions', + 'sentry.description': '/users/:id', + 'url.template': '/users/:id', + }); + }); + + it('falls back to Click when the route span has no parameterized route', () => { + interactionsIntegration().setup?.(client); + completeRouteSpan(new SentrySpan({ op: 'pageload', name: 'Pageload', sampled: true })); + + click(); + flushIdleSpan(); + + expect(spanToJSON(getInteractionSpans()[0]!).name).toBe('Click'); + expect(spanToJSON(getInteractionSpans()[0]!).attributes).toMatchObject({ + 'sentry.description': 'Pageload', + 'sentry.segment.name': 'Click', + 'sentry.segment.name.source': 'custom', + }); }); it('inherits the source of the route span', () => { @@ -108,7 +134,10 @@ describe('interactionsIntegration', () => { op: 'navigation', name: '/users/:id', sampled: true, - attributes: { 'sentry.segment.name.source': 'route' }, + attributes: { + 'sentry.segment.name.source': 'route', + 'url.template': '/users/:id', + }, }), ); @@ -120,7 +149,7 @@ describe('interactionsIntegration', () => { }); }); - it('falls back to a url source when the route span has none', () => { + it('uses a custom source when the streamed span falls back to Click', () => { interactionsIntegration().setup?.(client); completeRouteSpan(new SentrySpan({ op: 'pageload', name: '/users/1', sampled: true })); @@ -128,14 +157,19 @@ describe('interactionsIntegration', () => { flushIdleSpan(); expect(spanToJSON(getInteractionSpans()[0]!).attributes).toMatchObject({ - 'sentry.segment.name.source': 'url', + 'sentry.segment.name.source': 'custom', }); }); it('picks up the route name a router set after the route span started', () => { interactionsIntegration().setup?.(client); - const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/users/1', sampled: true }); + const pageloadSpan = new SentrySpan({ + op: 'pageload', + name: '/users/1', + sampled: true, + attributes: { 'url.template': '/users/:id' }, + }); client.emit('spanStart', pageloadSpan); updateSpanName(pageloadSpan, '/users/:id'); pageloadSpan.end(); @@ -264,7 +298,10 @@ describe('interactionsIntegration', () => { it('names the span after the DOM tree and omits the component name for unannotated elements', () => { const spans = clickAndReportEventTiming(appendButton({ class: 'clicked' })); - expect(spanToJSON(spans[0]!).name).toBe('body > button.clicked'); + expect(spanToJSON(spans[0]!).name).toBe('Click'); + expect(spanToJSON(spans[0]!).attributes).toMatchObject({ + 'ui.element.selector': 'body > button.clicked', + }); expect(spanToJSON(spans[0]!).attributes).not.toHaveProperty('ui.component_name'); }); @@ -273,15 +310,53 @@ describe('interactionsIntegration', () => { appendButton({ 'data-sentry-component': 'AnnotatedButton', 'data-sentry-element': 'StyledButton' }), ); - expect(spanToJSON(spans[0]!).name).toBe('body > AnnotatedButton'); - expect(spanToJSON(spans[0]!).attributes).toMatchObject({ 'ui.component_name': 'AnnotatedButton' }); + expect(spanToJSON(spans[0]!).name).toBe('AnnotatedButton'); + expect(spanToJSON(spans[0]!).attributes).toMatchObject({ + 'ui.component_name': 'AnnotatedButton', + 'ui.element.selector': 'body > AnnotatedButton', + }); }); it('falls back to the annotated element name when there is no component name', () => { const spans = clickAndReportEventTiming(appendButton({ 'data-sentry-element': 'StyledButton' })); - expect(spanToJSON(spans[0]!).name).toBe('body > StyledButton'); - expect(spanToJSON(spans[0]!).attributes).toMatchObject({ 'ui.component_name': 'StyledButton' }); + expect(spanToJSON(spans[0]!).name).toBe('StyledButton'); + expect(spanToJSON(spans[0]!).attributes).toMatchObject({ + 'ui.component_name': 'StyledButton', + 'ui.element.selector': 'body > StyledButton', + }); }); + + it('keeps the selector name when span streaming is disabled', () => { + client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' })); + setCurrentClient(client); + client.init(); + client.on('spanEnd', span => { + endedSpans.push(span); + }); + + const spans = clickAndReportEventTiming(appendButton({ class: 'clicked' })); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).name).toBe('body > button.clicked'); + }); + }); + + it('keeps the high-cardinality names when span streaming is disabled', () => { + client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' })); + setCurrentClient(client); + client.init(); + client.on('spanEnd', span => { + endedSpans.push(span); + }); + + interactionsIntegration().setup?.(client); + completeRouteSpan(new SentrySpan({ op: 'pageload', name: 'Pageload', sampled: true })); + + click(); + flushIdleSpan(); + + expect(spanToJSON(getInteractionSpans()[0]!).name).toBe('Pageload'); + expect(spanToJSON(getInteractionSpans()[0]!).attributes).not.toHaveProperty('sentry.description'); }); }); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 07ef54099004..b5e3aa81eec8 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -84,7 +84,10 @@ describe('_emitWebVitalSpan', () => { ); // A root span is its own root, which is what the web vital spans are parented to. vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span); - vi.mocked(SentryCore.getClient).mockReturnValue({ getIntegrationByName: () => undefined } as any); + vi.mocked(SentryCore.getClient).mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + getIntegrationByName: () => undefined, + } as any); }); afterEach(() => { @@ -382,6 +385,10 @@ describe('_sendLcpSpan', () => { name: 'test-route', attributes: { 'sentry.op': 'pageload' }, } as any); + vi.mocked(SentryCore.getClient).mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + getIntegrationByName: () => undefined, + } as any); }); afterEach(() => { @@ -440,6 +447,30 @@ describe('_sendLcpSpan', () => { ); }); + it('names the LCP span after the fallback and preserves the description when span streaming is enabled', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) } as any); + + _sendLcpSpan(250, undefined); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Largest contentful paint', + attributes: expect.objectContaining({ + 'sentry.description': 'Largest contentful paint', + }), + }), + ); + }); + + it('preserves an empty LCP selector as the static span name', () => { + vi.mocked(htmlTreeAsString).mockReturnValue(''); + const entry = { element: {} as Element, startTime: 200 } as LargestContentfulPaint; + + _sendLcpSpan(250, entry); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ name: '' })); + }); + it('lasts the reported value when there is no entry to end at', () => { // A soft navigation 2000ms into the page. Ending at the time origin would put the end before // the start. @@ -479,6 +510,10 @@ describe('_sendClsSpan', () => { name: 'test-route', attributes: { 'sentry.op': 'pageload' }, } as any); + vi.mocked(SentryCore.getClient).mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + getIntegrationByName: () => undefined, + } as any); }); afterEach(() => { @@ -540,6 +575,24 @@ describe('_sendClsSpan', () => { ); }); + it('preserves an empty CLS selector as the static span name', () => { + vi.mocked(htmlTreeAsString).mockReturnValue(''); + const entry = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: 0.1, + hadRecentInput: false, + sources: [{ node: {} as Node }], + toJSON: vi.fn(), + } as LayoutShift; + + _sendClsSpan(0.1, entry); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ name: '' })); + }); + it('falls back to the current time when there is no performance time origin', () => { vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); @@ -575,6 +628,10 @@ describe('_sendInpSpan', () => { ); // A root span is its own root, which is what the web vital spans are parented to. vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span); + vi.mocked(SentryCore.getClient).mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + getIntegrationByName: () => undefined, + } as any); }); afterEach(() => { @@ -637,6 +694,37 @@ describe('_sendInpSpan', () => { ); }); + it('uses the click fallback for a streamed INP span without entry data', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) } as any); + + _sendInpSpan(120, undefined); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Click', + attributes: expect.objectContaining({ + 'sentry.description': 'Interaction to next paint', + }), + }), + ); + }); + + it('preserves an empty INP selector as the static span name', () => { + vi.mocked(htmlTreeAsString).mockReturnValue(''); + vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined); + const entry = { + name: 'pointerdown', + startTime: 500, + duration: 120, + interactionId: 1, + target: {}, + }; + + _sendInpSpan(120, entry); + + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ name: '' })); + }); + it('uses cached element name and span from registerInpInteractionListener', () => { const mockRootSpan = createMockPageloadSpan('span-42'); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ @@ -728,6 +816,10 @@ describe('trackInpAsSpan', () => { // A root span is its own root, which is what the web vital spans are parented to. vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span); vi.mocked(htmlTreeAsString).mockReturnValue('