diff --git a/MIGRATION.md b/MIGRATION.md index ab36abf504f4..cf5e833d40e3 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 the op's fallback (`Component mount`/`render`/`update`/`unmount`) if the SDK has none | `UserList`, `Root`, `Component mount` | +| `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 `router.navigation.route.id`, `url.template` or `http.route`, or `Click` if the SDK has none | `UserProfile`, `/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`/`Key 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 `Component mount`. Where no component name is known, the fallback follows the op: `Component mount`, `Component update`, `Component render`, or `Component unmount`. 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 `router.navigation.route.id`, `url.template` or `http.route` from that route span, in that order, or fall back to `Click`. The route span's `router.navigation.route.id`, `url.template`, `http.route`, `url.path` and `url.full` are copied onto the click span, so the route it happened on is still queryable. Event Timing and INP `ui.interaction.*` spans are named after `ui.component_name` when the element is annotated, otherwise `Click`/`Hover`/`Drag`/`Key press`. The DOM path they used to use as a name is on `browser.web_vital.inp.target`, which is also what the description is derived from. Spotlight's built-in `ignoreSpans` filter matches that attribute. `browser.web_vital.inp.target` and `ui.component_name` are set in both trace lifecycles, so static-mode users get them too. + +LCP and CLS spans follow the same pattern: a component name when one is annotated, otherwise `Largest contentful paint` / `Layout shift`. Their DOM paths stay on `browser.web_vital.lcp.element` and `browser.web_vital.cls.source.1`, the first of the layout shift's sources. Long task and long animation frame names are unchanged (`Main UI thread blocked`). + #### 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..0a646701fde8 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 @@ -3,6 +3,11 @@ import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipCdnBundleTest, shouldSkipTracingTest } from '../../../../utils/helpers'; import { getSpanOp, observeStreamedSpan, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; +// Interaction spans are named after the component, so the element is on `browser.web_vital.inp.target`. +// The click handler adds the `clicked` class before the event timing entry is recorded. +const SPOTLIGHT_BUTTON = 'body > div#sentry-spotlight > button.clicked'; +const REGULAR_BUTTON = 'body > button.clicked'; + sentryTest( 'filters ui.interaction.click spans for spotlight elements via ignoreSpans in streaming mode', async ({ getLocalTestUrl, page }) => { @@ -16,7 +21,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' && + span.attributes['browser.web_vital.inp.target']?.value === SPOTLIGHT_BUTTON + ) { sawSpotlightInteractionSpan = true; return true; } @@ -39,7 +47,7 @@ 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 => span.attributes['browser.web_vital.inp.target']?.value === REGULAR_BUTTON), ); await page.locator('[data-test-id=regular-button]').click(); @@ -47,10 +55,9 @@ sentryTest( const regularSpans = await regularInteractionSpansPromise; const regularInteractionSpan = regularSpans.find( - span => getSpanOp(span) === 'ui.interaction.click' && !span.name?.includes('#sentry-spotlight'), + span => span.attributes['browser.web_vital.inp.target']?.value === REGULAR_BUTTON, ); - expect(regularInteractionSpan).toBeDefined(); - expect(regularInteractionSpan!.name).toContain('button'); + expect(getSpanOp(regularInteractionSpan!)).toBe('ui.interaction.click'); // 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..bba835779c52 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 @@ -15,6 +15,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 +71,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 +105,11 @@ 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', }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', @@ -113,8 +118,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 +161,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', }, + 'browser.web_vital.inp.target': { + 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/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 1b500a7c18b4..811aabb055c9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -75,9 +75,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'NormalButton', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index fc02119e9303..725a59e8f346 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -75,9 +75,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'NavigationLink', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, @@ -163,9 +164,9 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 362d568ebdc4..45f6ead4f2f8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -76,9 +76,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'NormalButton', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index baec4c7f43e4..15c7b7e4c392 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -76,9 +76,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'NormalButton', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, // the parametrized route name flows onto the INP span diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts index fab530bf383e..f1ba7c9138f7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts @@ -40,7 +40,9 @@ sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, pa expect(inpSpan.attributes['sentry.exclusive_time']?.value).toBeGreaterThan(0); - expect(inpSpan.name).toBe('body > NormalButton'); + expect(inpSpan.name).toBe('NormalButton'); + expect(inpSpan.attributes['ui.component_name']).toEqual({ type: 'string', value: 'NormalButton' }); + expect(inpSpan.attributes['browser.web_vital.inp.target']).toEqual({ type: 'string', value: 'body > NormalButton' }); expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); @@ -75,7 +77,9 @@ sentryTest('captures the slowest interaction as streamed INP span', async ({ get const inpSpan = await inpSpanPromise; - expect(inpSpan.name).toBe('body > SlowButton'); + expect(inpSpan.name).toBe('SlowButton'); + expect(inpSpan.attributes['ui.component_name']).toEqual({ type: 'string', value: 'SlowButton' }); + expect(inpSpan.attributes['browser.web_vital.inp.target']).toEqual({ type: 'string', value: 'body > SlowButton' }); expect(inpSpan.attributes['sentry.exclusive_time']?.value).toBeGreaterThan(400); const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index f1fc6cc92f37..12d8360acbcf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -79,9 +79,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'NormalButton', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, @@ -148,9 +149,10 @@ sentryTest( attributes: { 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'ui.component_name': { value: 'SlowButton', type: 'string' }, + 'browser.web_vital.inp.target': { value: 'body > SlowButton', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, - 'browser.web_vital.inp.target': { value: 'body > SlowButton', type: 'string' }, 'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' }, 'browser.navigation.type': { value: 'navigate', type: 'string' }, 'sentry.transaction': { value: 'test-url', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts index adb440a59b57..57d35cd4a5ad 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts @@ -275,17 +275,21 @@ test.describe('TraceDirective', () => { expect(traceDirectiveSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: '', // custom component name passed to trace directive + name: 'sample-component', // custom component name passed to trace directive attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), expect.objectContaining({ - name: '', // fallback selector name + name: 'app-sample-component', // fallback element tag name attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'app-sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ]), @@ -318,10 +322,12 @@ test.describe('TraceClass Decorator', () => { expect(classDecoratorSpan).toBeDefined(); expect(classDecoratorSpan).toEqual( expect.objectContaining({ - name: '', + name: 'ComponentTrackingComponent', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_class_decorator' }, + 'ui.component_name': { type: 'string', value: 'ComponentTrackingComponent' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts index 15c5d1abe318..db760f425a3a 100644 --- a/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-18/tests/performance.test.ts @@ -273,10 +273,12 @@ test.describe('TraceDirective', () => { expect(traceDirectiveSpan).toBeDefined(); expect(traceDirectiveSpan).toEqual( expect.objectContaining({ - name: '', + name: 'sample-component', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); @@ -308,10 +310,12 @@ test.describe('TraceClass Decorator', () => { expect(classDecoratorSpan).toBeDefined(); expect(classDecoratorSpan).toEqual( expect.objectContaining({ - name: '', + name: 'ComponentTrackingComponent', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_class_decorator' }, + 'ui.component_name': { type: 'string', value: 'ComponentTrackingComponent' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/angular-19/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-19/tests/performance.test.ts index 27497f74fdbb..2abfce92b545 100644 --- a/dev-packages/e2e-tests/test-applications/angular-19/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-19/tests/performance.test.ts @@ -267,6 +267,7 @@ test.describe('TraceDirective', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.mount', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + 'ui.component_name': 'sample-component', }, description: '', // custom component name passed to trace directive op: 'ui.mount', @@ -278,6 +279,7 @@ test.describe('TraceDirective', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.mount', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + 'ui.component_name': 'app-sample-component', }, description: '', // fallback selector name op: 'ui.mount', @@ -311,6 +313,7 @@ test.describe('TraceClass Decorator', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.mount', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + 'ui.component_name': 'ComponentTrackingComponent', }, description: '', op: 'ui.mount', diff --git a/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts index e4516f724e7a..3599dd18b430 100644 --- a/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-20/tests/performance.test.ts @@ -275,17 +275,21 @@ test.describe('TraceDirective', () => { expect(traceDirectiveSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: '', // custom component name passed to trace directive + name: 'sample-component', // custom component name passed to trace directive attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), expect.objectContaining({ - name: '', // fallback selector name + name: 'app-sample-component', // fallback element tag name attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'app-sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ]), @@ -318,10 +322,12 @@ test.describe('TraceClass Decorator', () => { expect(classDecoratorSpan).toBeDefined(); expect(classDecoratorSpan).toEqual( expect.objectContaining({ - name: '', + name: 'ComponentTrackingComponent', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_class_decorator' }, + 'ui.component_name': { type: 'string', value: 'ComponentTrackingComponent' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts index 2e36b5700d6d..db37b3073f67 100644 --- a/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts @@ -275,17 +275,21 @@ test.describe('TraceDirective', () => { expect(traceDirectiveSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: '', // custom component name passed to trace directive + name: 'sample-component', // custom component name passed to trace directive attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), expect.objectContaining({ - name: '', // fallback selector name + name: 'app-sample-component', // fallback element tag name attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'app-sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ]), @@ -318,10 +322,12 @@ test.describe('TraceClass Decorator', () => { expect(classDecoratorSpan).toBeDefined(); expect(classDecoratorSpan).toEqual( expect.objectContaining({ - name: '', + name: 'ComponentTrackingComponent', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_class_decorator' }, + 'ui.component_name': { type: 'string', value: 'ComponentTrackingComponent' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/angular-22/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-22/tests/performance.test.ts index b763b72bf61e..3126b4abbee5 100644 --- a/dev-packages/e2e-tests/test-applications/angular-22/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/angular-22/tests/performance.test.ts @@ -275,17 +275,21 @@ test.describe('TraceDirective', () => { expect(traceDirectiveSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: '', // custom component name passed to trace directive + name: 'sample-component', // custom component name passed to trace directive attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), expect.objectContaining({ - name: '', // fallback selector name + name: 'app-sample-component', // fallback element tag name attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_directive' }, + 'ui.component_name': { type: 'string', value: 'app-sample-component' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ]), @@ -318,10 +322,12 @@ test.describe('TraceClass Decorator', () => { expect(classDecoratorSpan).toBeDefined(); expect(classDecoratorSpan).toEqual( expect.objectContaining({ - name: '', + name: 'ComponentTrackingComponent', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.angular.trace_class_decorator' }, + 'ui.component_name': { type: 'string', value: 'ComponentTrackingComponent' }, + 'sentry.description': { type: 'string', value: '' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-classic/tests/performance.test.ts index e9ff45718ab9..1f2fad788c91 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-classic/tests/performance.test.ts @@ -239,11 +239,12 @@ test('captures correct spans for navigation', async ({ page }) => { expect(renderSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'runloop', + name: 'UI task', attributes: expect.objectContaining({ 'ember.runloop.queue': { type: 'string', value: 'render' }, 'sentry.op': { type: 'string', value: 'ui.task' }, 'sentry.origin': { type: 'string', value: 'auto.ui.ember' }, + 'sentry.description': { type: 'string', value: 'runloop' }, }), }), ]), diff --git a/dev-packages/e2e-tests/test-applications/ember-embroider/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-embroider/tests/performance.test.ts index 1cf6c9827b24..809f584d1a51 100644 --- a/dev-packages/e2e-tests/test-applications/ember-embroider/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-embroider/tests/performance.test.ts @@ -239,11 +239,12 @@ test('captures correct spans for navigation', async ({ page }) => { expect(renderSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'runloop', + name: 'UI task', attributes: expect.objectContaining({ 'ember.runloop.queue': { type: 'string', value: 'render' }, 'sentry.op': { type: 'string', value: 'ui.task' }, 'sentry.origin': { type: 'string', value: 'auto.ui.ember' }, + 'sentry.description': { type: 'string', value: 'runloop' }, }), }), ]), 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..90d4d218656f 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}/), @@ -45,6 +45,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); 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..c35120c5f6ef 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}/), @@ -44,6 +44,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); 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..d8c60dbae889 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}/), @@ -44,6 +44,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); 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..47f218969fcf 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}/), @@ -45,6 +45,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts index 81121e0de396..3fa8850d8fd3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts @@ -80,7 +80,7 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); const expected = { - data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' }, + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount', 'ui.component_name': 'ErrorButton' }, description: 'Vue ', op: 'ui.mount', parent_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..d6cca7092c4e 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}/), @@ -69,13 +71,17 @@ test('sends an application render span and a root component span on pageload', a attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.render' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + 'sentry.description': { type: 'string', value: 'Application Render' }, }), }); - 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}/), @@ -85,6 +91,8 @@ test('sends an application render span and a root component span on pageload', a attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); @@ -98,10 +106,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}/), @@ -111,6 +119,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); 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..4eb04430c83d 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}/), @@ -71,6 +71,8 @@ test('sends component tracking spans when `trackComponents` is enabled', async ( attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'ErrorButton' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); @@ -86,10 +88,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}/), @@ -99,13 +103,17 @@ test('sends an application render span and a root component span on pageload', a attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.render' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + 'sentry.description': { type: 'string', value: 'Application Render' }, }), }); - 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}/), @@ -115,6 +123,8 @@ test('sends an application render span and a root component span on pageload', a attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'ui.mount' }, 'sentry.origin': { type: 'string', value: 'auto.ui.vue' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts index c225e2f03dfa..6eba5f11e4a2 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts @@ -70,7 +70,8 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan).toEqual( expect.objectContaining({ - name: 'body > div#root > input#exception-button[type="button"]', + // The element is not annotated with a component name, so the span takes the op's fallback name. + name: 'Click', span_id: expect.stringMatching(/^[\da-f]{16}$/), trace_id: expect.stringMatching(/^[\da-f]{32}$/), parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), @@ -88,6 +89,10 @@ test('sends an INP span', async ({ page }) => { expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'browser.web_vital.inp.target': { + value: 'body > div#root > input#exception-button[type="button"]', + type: 'string', + }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index 1da847691cae..ecd63899dfee 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -70,7 +70,8 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan).toEqual( expect.objectContaining({ - name: 'body > div#root > input#exception-button[type="button"]', + // The element is not annotated with a component name, so the span takes the op's fallback name. + name: 'Click', span_id: expect.stringMatching(/^[\da-f]{16}$/), trace_id: expect.stringMatching(/^[\da-f]{32}$/), parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), @@ -88,6 +89,10 @@ test('sends an INP span', async ({ page }) => { expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'browser.web_vital.inp.target': { + value: 'body > div#root > input#exception-button[type="button"]', + type: 'string', + }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts index 9083d7e89846..3d0e91e50b60 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -61,7 +61,11 @@ test('sends an INP span', async ({ page }) => { const inpSpan = await inpSpanPromise; - expect(inpSpan.name).toBe('body > div#root > input#exception-button[type="button"]'); + // The element is not annotated with a component name, so the span takes the op's fallback name. + expect(inpSpan.name).toBe('Click'); + expect(inpSpan.attributes['browser.web_vital.inp.target']?.value).toBe( + 'body > div#root > input#exception-button[type="button"]', + ); expect(inpSpan.trace_id).toMatch(/[a-f0-9]{32}/); expect(inpSpan.span_id).toMatch(/[a-f0-9]{16}/); expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts index 68a35372a1e4..65ae46c0e1d4 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts @@ -70,7 +70,8 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan).toEqual( expect.objectContaining({ - name: 'body > div#root > input#exception-button[type="button"]', + // The element is not annotated with a component name, so the span takes the op's fallback name. + name: 'Click', span_id: expect.stringMatching(/^[\da-f]{16}$/), trace_id: expect.stringMatching(/^[\da-f]{32}$/), parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), @@ -88,6 +89,10 @@ test('sends an INP span', async ({ page }) => { expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'browser.web_vital.inp.target': { + value: 'body > div#root > input#exception-button[type="button"]', + type: 'string', + }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts index 721f795837ba..6c9ad5efc502 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts @@ -70,7 +70,8 @@ test('sends an INP span', async ({ page }) => { expect(inpSpan).toEqual( expect.objectContaining({ - name: 'body > div#root > input#exception-button[type="button"]', + // The element is not annotated with a component name, so the span takes the op's fallback name. + name: 'Click', span_id: expect.stringMatching(/^[\da-f]{16}$/), trace_id: expect.stringMatching(/^[\da-f]{32}$/), parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), @@ -88,6 +89,10 @@ test('sends an INP span', async ({ page }) => { expect.objectContaining({ 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'browser.web_vital.inp.target': { + value: 'body > div#root > input#exception-button[type="button"]', + type: 'string', + }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, 'sentry.transaction': { value: '/', type: 'string' }, diff --git a/dev-packages/e2e-tests/test-applications/svelte-5-static/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/svelte-5-static/tests/performance.test.ts index 7ffd622648b0..f9b5c2f24e55 100644 --- a/dev-packages/e2e-tests/test-applications/svelte-5-static/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/svelte-5-static/tests/performance.test.ts @@ -31,6 +31,7 @@ test('sends a pageload transaction with component tracking init spans', async ({ data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', + 'ui.component_name': 'App', }, }), expect.objectContaining({ @@ -39,6 +40,7 @@ test('sends a pageload transaction with component tracking init spans', async ({ data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', + 'ui.component_name': 'Counter', }, }), ]), diff --git a/dev-packages/e2e-tests/test-applications/svelte-5/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/svelte-5/tests/spans.test.ts index 6b83cb13d53c..fe4d6d0aeea4 100644 --- a/dev-packages/e2e-tests/test-applications/svelte-5/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/svelte-5/tests/spans.test.ts @@ -20,17 +20,21 @@ test('sends a pageload span with component tracking init spans', async ({ page } expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: '', + name: 'App', attributes: expect.objectContaining({ 'sentry.op': { value: 'ui.mount', type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: 'App', type: 'string' }, + 'sentry.description': { value: '', type: 'string' }, }), }), expect.objectContaining({ - name: '', + name: 'Counter', attributes: expect.objectContaining({ 'sentry.op': { value: 'ui.mount', type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: 'Counter', type: 'string' }, + 'sentry.description': { value: '', type: 'string' }, }), }), ]), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts index 477f560f1f0f..76d73f37a73c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.client.test.ts @@ -67,15 +67,17 @@ test.describe('client-specific performance events', () => { attributes: expect.objectContaining({ 'sentry.op': { value: 'ui.mount', type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: name, type: 'string' }, + 'sentry.description': { value: `<${name}>`, type: 'string' }, }), }); expect(componentTraceSpans).toEqual( expect.arrayContaining([ - componentSpan(''), - componentSpan(''), - componentSpan(''), - componentSpan(''), + componentSpan('components/+page'), + componentSpan('Component1'), + componentSpan('Component2'), + componentSpan('Component3'), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts index 1c50a01c0e59..631fbdfe7ac2 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/performance.client.test.ts @@ -96,49 +96,53 @@ test.describe('client-specific performance events', () => { expect(componentTxnEvent.spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'components/+page' }, description: '', op: 'ui.mount', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component1' }, description: '', op: 'ui.mount', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component2' }, description: '', op: 'ui.mount', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.mount', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component3' }, description: '', op: 'ui.mount', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + data: { + 'sentry.op': 'ui.update', + 'sentry.origin': 'auto.ui.svelte', + 'ui.component_name': 'components/+page', + }, description: '', op: 'ui.update', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component1' }, description: '', op: 'ui.update', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component2' }, description: '', op: 'ui.update', origin: 'auto.ui.svelte', }), expect.objectContaining({ - data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte' }, + data: { 'sentry.op': 'ui.update', 'sentry.origin': 'auto.ui.svelte', 'ui.component_name': 'Component3' }, description: '', op: 'ui.update', origin: 'auto.ui.svelte', diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts index f439416cce14..0239c6968354 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts @@ -67,15 +67,17 @@ test.describe('client-specific performance events', () => { attributes: expect.objectContaining({ 'sentry.op': { value: 'ui.mount', type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: name, type: 'string' }, + 'sentry.description': { value: `<${name}>`, type: 'string' }, }), }); expect(componentTraceSpans).toEqual( expect.arrayContaining([ - componentSpan(''), - componentSpan(''), - componentSpan(''), - componentSpan(''), + componentSpan('components/+page'), + componentSpan('Component1'), + componentSpan('Component2'), + componentSpan('Component3'), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts index 9e43743d1594..cbbb153361c0 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.client.test.ts @@ -62,19 +62,21 @@ test.describe('client-specific performance events', () => { attributes: expect.objectContaining({ 'sentry.op': { value: op, type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: name, type: 'string' }, + 'sentry.description': { value: `<${name}>`, type: 'string' }, }), }); expect(componentTraceSpans).toEqual( expect.arrayContaining([ - componentSpan('ui.mount', ''), - componentSpan('ui.mount', ''), - componentSpan('ui.mount', ''), - componentSpan('ui.mount', ''), - componentSpan('ui.update', ''), - componentSpan('ui.update', ''), - componentSpan('ui.update', ''), - componentSpan('ui.update', ''), + componentSpan('ui.mount', 'components/+page'), + componentSpan('ui.mount', 'Component1'), + componentSpan('ui.mount', 'Component2'), + componentSpan('ui.mount', 'Component3'), + componentSpan('ui.update', 'components/+page'), + componentSpan('ui.update', 'Component1'), + componentSpan('ui.update', 'Component2'), + componentSpan('ui.update', 'Component3'), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts index 20c009c78c4b..47aa28294a58 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts @@ -62,15 +62,17 @@ test.describe('client-specific performance events', () => { attributes: expect.objectContaining({ 'sentry.op': { value: 'ui.mount', type: 'string' }, 'sentry.origin': { value: 'auto.ui.svelte', type: 'string' }, + 'ui.component_name': { value: name, type: 'string' }, + 'sentry.description': { value: `<${name}>`, type: 'string' }, }), }); expect(componentTraceSpans).toEqual( expect.arrayContaining([ - componentSpan(''), - componentSpan(''), - componentSpan(''), - componentSpan(''), + componentSpan('components/+page'), + componentSpan('Component1'), + componentSpan('Component2'), + componentSpan('Component3'), ]), ); }); 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..107ac0fa9f82 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,29 @@ 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' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + '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' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + 'sentry.description': { type: 'string', value: 'Vue ' }, }), }); }); @@ -182,9 +190,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 +205,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 +240,49 @@ 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' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + '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' }, + 'ui.component_name': { type: 'string', value: 'Root' }, + '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' }, + 'ui.component_name': { type: 'string', value: 'ComponentMainView' }, + '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' }, + 'ui.component_name': { type: 'string', value: 'ComponentOneView' }, + '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..553da897f9e2 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_MOUNT_SPAN_NAME_FALLBACK, } from '@sentry/core'; import type { Observable } from 'rxjs'; import { Subscription } from 'rxjs'; @@ -311,12 +314,19 @@ export class TraceDirective implements OnInit, AfterViewInit { } if (getActiveSpan()) { + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const componentName = this.componentName; + const description = `<${componentName}>`; + this._tracingSpan = runOutsideAngular(() => startInactiveSpan({ - name: `<${this.componentName}>`, + name: hasSpanStreaming ? componentName : description, attributes: { [SENTRY_OP]: UI_MOUNT, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + [UI_COMPONENT_NAME]: componentName, + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }), }, }), ); @@ -355,22 +365,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_MOUNT_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 +397,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..13acbc9d8622 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -1,7 +1,8 @@ +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 } 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 +120,85 @@ describe('Angular Tracing', () => { const directive = new TraceDirective(); expect(directive).toBeTruthy(); }); + + it('names the span after the element tag when the directive has no name and 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('app-profile'); + expect(spanToJSON(spans[0]!).attributes['sentry.description']).toBe(''); + expect(spanToJSON(spans[0]!).attributes['ui.component_name']).toBe('app-profile'); + }); + }); + + 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..f28e60f1cb3e 100644 --- a/packages/browser-utils/src/performance/entries.ts +++ b/packages/browser-utils/src/performance/entries.ts @@ -10,6 +10,7 @@ import { setMeasurement, spanToJSON, filterCollectedUrl, + UI_LONG_TASK_SPAN_NAME_FALLBACK, } from '@sentry/core'; import { BROWSER_PAINT_TYPE, @@ -119,7 +120,7 @@ export function startTrackingLongTasks(): void { } 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', @@ -181,7 +182,7 @@ export function startTrackingLongAnimationFrames(): void { } startAndEndSpan(parent, startTime, startTime + duration, { - name: 'Main UI thread blocked', + name: UI_LONG_TASK_SPAN_NAME_FALLBACK, op: UI_LONG_ANIMATION_FRAME, attributes, }); diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts index 067b549a707f..2be7d7a52272 100644 --- a/packages/browser-utils/src/performance/interactions.ts +++ b/packages/browser-utils/src/performance/interactions.ts @@ -1,25 +1,36 @@ import { + HTTP_ROUTE, + ROUTER_NAVIGATION_ROUTE_ID, + 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'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { addPerformanceInstrumentationHandler } from '../instrumentation/performanceObserver'; +import { UNKNOWN_ELEMENT_NAME } from '../web-vitals/inp'; import { isBotUserAgent } from '../isBotUserAgent'; import { WINDOW } from '../types'; import { msToSec, startAndEndSpan } from './utils'; @@ -56,6 +67,11 @@ interface InteractionsOptions { interface RouteInfo { name: string | undefined; source: TransactionSource | undefined; + routeId: string | undefined; + urlTemplate: string | undefined; + httpRoute: string | undefined; + urlPath: string | undefined; + urlFull: string | undefined; } const _interactionsIntegration = ((options: InteractionsOptions = {}) => { @@ -66,7 +82,15 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { return; } - const latestRoute: RouteInfo = { name: undefined, source: undefined }; + const latestRoute: RouteInfo = { + name: undefined, + source: undefined, + routeId: 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 +102,11 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { latestRoute.source = (attributes[SENTRY_SOURCE] || attributes[SENTRY_SEGMENT_NAME_SOURCE]) as | TransactionSource | undefined; + latestRoute.routeId = attributes[ROUTER_NAVIGATION_ROUTE_ID] as string | 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 +126,9 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { trackRoute(span); }); - registerInteractionListener(latestRoute, () => inflightRouteSpan, options); + registerInteractionListener(client, latestRoute, () => inflightRouteSpan, options); - trackInteractionsAsSpans(); + trackInteractionsAsSpans(client); }, }; }) satisfies IntegrationFn; @@ -135,6 +164,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 +202,25 @@ function registerInteractionListener( return; } + const hasSpanStreaming = hasSpanStreamingEnabled(client); + const routeName = latestRoute.routeId || latestRoute.urlTemplate || latestRoute.httpRoute; + const streamedName = routeName || UI_ACTION_CLICK_SPAN_NAME_FALLBACK; + // A route id is a name the app gave the route, so it is `custom` rather than a `route` pattern. + const streamedNameSource = !routeName || latestRoute.routeId ? 'custom' : 'route'; + inflightInteractionSpan = startIdleSpan( { - name: latestRoute.name, + name: hasSpanStreaming ? streamedName : latestRoute.name, 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.routeId && { [ROUTER_NAVIGATION_ROUTE_ID]: latestRoute.routeId }), + ...(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) }), }, }, idleSpanOptions, @@ -191,7 +233,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 +244,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', + ...(selector && selector !== UNKNOWN_ELEMENT_NAME && { 'browser.web_vital.inp.target': 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..faa727642899 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, 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,22 @@ 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 selector = entry ? htmlTreeAsString(entry.element) : 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 + : (selector ?? UI_WEBVITAL_LCP_SPAN_NAME_FALLBACK); const attributes: SpanAttributes = {}; - entry?.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + if (selector) { + attributes['browser.web_vital.lcp.element'] = selector; + } + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } 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 +298,21 @@ 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 selector = entry ? htmlTreeAsString(firstSourceNode) : 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 + : (selector ?? UI_WEBVITAL_CLS_SPAN_NAME_FALLBACK); const attributes: SpanAttributes = {}; + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } + if (entry?.sources) { entry.sources.forEach((source, index) => { attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); @@ -384,7 +422,12 @@ 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 name = hasSpanStreaming ? componentName || fallbackName : (selector ?? 'Interaction to next paint'); const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, @@ -392,11 +435,16 @@ export function _sendInpSpan( // The span's name and op always have a value, even for an INP without an entry, so they can't // say whether there was an interaction to describe. These attributes can: they are only set for - // what was actually observed. + // what was actually observed. The name no longer holds the selector either, now that it is the + // component name or the op's fallback under span streaming. // TODO: use the `@sentry/conventions` constants once getsentry/sentry-conventions#641 is released. - entry && name !== UNKNOWN_ELEMENT_NAME && (attributes['browser.web_vital.inp.target'] = name); + selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes['browser.web_vital.inp.target'] = selector); entryInteractionType && (attributes['browser.web_vital.inp.interaction_type'] = entryInteractionType); + if (componentName) { + attributes[UI_COMPONENT_NAME] = componentName; + } + _emitWebVitalSpan({ name, op: INTERACTION_TYPE_TO_SPAN_OP[interactionType], diff --git a/packages/browser-utils/test/performance/interactions.test.ts b/packages/browser-utils/test/performance/interactions.test.ts index e3aa01ca9b1d..b68855034b4f 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,48 @@ 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', + 'url.template': '/users/:id', + }); + }); + + it('prefers the route id over the parameterized route', () => { + interactionsIntegration().setup?.(client); + completeRouteSpan( + new SentrySpan({ + op: 'pageload', + name: 'UserProfile', + sampled: true, + attributes: { 'router.navigation.route.id': 'UserProfile', 'url.template': '/users/:id' }, + }), + ); + + click(); + flushIdleSpan(); + + const spans = getInteractionSpans(); + expect(spanToJSON(spans[0]!).name).toBe('UserProfile'); + expect(spanToJSON(spans[0]!).attributes).toMatchObject({ + 'router.navigation.route.id': 'UserProfile', + 'url.template': '/users/:id', + 'sentry.segment.name': 'UserProfile', + 'sentry.segment.name.source': 'custom', + }); + }); + + 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.segment.name': 'Click', + 'sentry.segment.name.source': 'custom', + }); }); it('inherits the source of the route span', () => { @@ -108,7 +156,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 +171,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 +179,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(); @@ -212,7 +268,7 @@ describe('interactionsIntegration', () => { describe('browser event timing entries', () => { /** Clicks `target` and feeds the matching `event` timing entry through the observer handler. */ - function clickAndReportEventTiming(target: Element): Span[] { + function clickAndReportEventTiming(target: Element | null): Span[] { let handler: ((data: { entries: PerformanceEntry[] }) => void) | undefined; vi.spyOn(performanceObserver, 'addPerformanceInstrumentationHandler').mockImplementation((type, callback) => { if (type === 'event') { @@ -264,7 +320,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({ + 'browser.web_vital.inp.target': 'body > button.clicked', + }); expect(spanToJSON(spans[0]!).attributes).not.toHaveProperty('ui.component_name'); }); @@ -273,15 +332,60 @@ 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', + 'browser.web_vital.inp.target': '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', + 'browser.web_vital.inp.target': 'body > StyledButton', + }); + }); + + it('omits the target attribute when the entry has no element to describe', () => { + const spans = clickAndReportEventTiming(null); + + expect(spanToJSON(spans[0]!).name).toBe('Click'); + expect(spanToJSON(spans[0]!).attributes).not.toHaveProperty('browser.web_vital.inp.target'); + }); + + 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..f9182b52d153 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,25 @@ describe('_sendLcpSpan', () => { ); }); + it('names the LCP span after the fallback 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' }), + ); + }); + + 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 +505,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 +570,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 +623,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(() => { @@ -603,9 +655,9 @@ describe('_sendInpSpan', () => { 'sentry.origin': 'auto.http.browser.inp', 'sentry.op': 'ui.interaction.click', 'sentry.exclusive_time': 120, + 'browser.web_vital.inp.target': '