From d7efb500580c57dbfa365cfc0cbd17a3571f0910 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 16:24:33 +0200 Subject: [PATCH 1/4] feat(ember): Set url.template, url.path and url.full on router spans Emit standard URL attributes on Ember pageload and navigation root spans, updating navigation attributes at routeDidChange once the destination URL is finalized. Co-Authored-By: Cursor --- .../ember-classic/tests/performance.test.ts | 35 +++++++++ .../ember-embroider/tests/performance.test.ts | 35 +++++++++ ...nstrumentEmberAppInstanceForPerformance.ts | 71 ++++++++++++++++++- .../unit/instrument-router-location-test.ts | 29 +++++++- 4 files changed, 167 insertions(+), 3 deletions(-) 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 e6aa4747bf92..4aa00f61b599 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 @@ -15,6 +15,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'sentry.origin': 'auto.pageload.ember', + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: 'route:index', @@ -43,6 +50,13 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/tracing', + 'url.path': '/tracing', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/tracing$/), + }, }, }, transaction: 'route:tracing', @@ -75,6 +89,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'sentry.origin': 'auto.pageload.ember', + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: 'route:index', @@ -88,6 +109,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/tracing', + 'url.path': '/tracing', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/tracing$/), + }, }, }, transaction: 'route:tracing', @@ -124,6 +152,13 @@ test('captures correct spans for navigation', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/slow-loading-route', + 'url.path': '/slow-loading-route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/slow-loading-route$/), + }, }, }, transaction: 'route:slow-loading-route.index', 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 a82964b46a92..1545409ad3c5 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 @@ -15,6 +15,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'sentry.origin': 'auto.pageload.ember', + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: 'route:index', @@ -43,6 +50,13 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/tracing', + 'url.path': '/tracing', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/tracing$/), + }, }, }, transaction: 'route:tracing', @@ -75,6 +89,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'sentry.origin': 'auto.pageload.ember', + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: 'route:index', @@ -88,6 +109,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/tracing', + 'url.path': '/tracing', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/tracing$/), + }, }, }, transaction: 'route:tracing', @@ -124,6 +152,13 @@ test('captures correct spans for navigation', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'sentry.origin': 'auto.navigation.ember', + 'sentry.source': 'route', + 'url.template': '/slow-loading-route', + 'url.path': '/slow-loading-route', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/slow-loading-route$/), + }, }, }, transaction: 'route:slow-loading-route.index', diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts index c10e3913b4ef..3fd9788ae6bf 100644 --- a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -5,11 +5,22 @@ import type { startBrowserTracingNavigationSpan as startBrowserTracingNavigationSpanType, startBrowserTracingPageLoadSpan as startBrowserTracingPageLoadSpanType, } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startInactiveSpan } from '@sentry/browser'; +import { + getAbsoluteUrl, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startInactiveSpan, +} from '@sentry/browser'; import type { Client, Span } from '@sentry/core'; import type { EmberRouterMain } from '../types'; import { getBackburner } from './performance'; +const URL_FULL = 'url.full'; +const URL_PATH = 'url.path'; +const URL_TEMPLATE = 'url.template'; + +type TransitionWithIntent = Transition & { intent?: { url?: string } }; + export function instrumentEmberAppInstanceForPerformance( client: Client, appInstance: ApplicationInstance, @@ -22,6 +33,7 @@ export function instrumentEmberAppInstanceForPerformance( let routerService = appInstance.lookup('service:router') as RouterService & { externalRouter?: RouterService; _hasMountedSentryPerformanceRouting?: boolean; + currentURL?: string; }; if (routerService.externalRouter) { @@ -60,6 +72,49 @@ function getTransitionInformation( }; } +function getUrlPathFromEmberLocation(url: string): string { + if (!url) { + return '/'; + } + + const withoutQuery = url.split('?')[0] ?? url; + + if (withoutQuery.includes('#')) { + const hashPart = withoutQuery.substring(withoutQuery.indexOf('#') + 1); + return hashPart.startsWith('/') ? hashPart : `/${hashPart}`; + } + + return withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`; +} + +function buildUrlTemplate(path: string, params: Record = {}): string { + let template = path; + + const paramEntries = Object.entries(params) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0) + .sort(([, a], [, b]) => b.length - a.length); + + for (const [key, value] of paramEntries) { + template = template.replace(`/${value}`, `/:${key}`); + } + + return template; +} + +// Only exported for testing +export function _getRouteUrlAttributes( + url: string, + params: Record = {}, +): Record { + const path = getUrlPathFromEmberLocation(url); + + return { + [URL_PATH]: path, + [URL_FULL]: getAbsoluteUrl(path), + [URL_TEMPLATE]: buildUrlTemplate(path, params), + }; +} + // Only exported for testing export function _getLocationURL(location: EmberRouterMain['location']): string { if (!location?.getURL || !location?.formatURL) { @@ -76,7 +131,7 @@ export function _getLocationURL(location: EmberRouterMain['location']): string { function _instrumentEmberRouter( client: Client, - routerService: RouterService, + routerService: RouterService & { currentURL?: string }, routerMain: EmberRouterMain, config: { disableRunloopPerformance?: boolean; instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, startBrowserTracingPageLoadSpan: typeof startBrowserTracingPageLoadSpanType, @@ -96,6 +151,7 @@ function _instrumentEmberRouter( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember', + ..._getRouteUrlAttributes(url, routeInfo.params), url, toRoute: routeInfo.name, }, @@ -124,11 +180,16 @@ function _instrumentEmberRouter( activeRootSpan?.end(); + const targetUrl = + (transition as TransitionWithIntent).intent?.url ?? _getLocationURL(location); + const urlAttributes = _getRouteUrlAttributes(targetUrl, transition.to?.params); + activeRootSpan = startBrowserTracingNavigationSpan(client, { name: `route:${toRoute}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember', + ...urlAttributes, fromRoute, toRoute, }, @@ -150,6 +211,12 @@ function _instrumentEmberRouter( } transitionSpan.end(); + const url = routerService.currentURL ?? _getLocationURL(location); + if (url) { + const routeInfo = routerService.recognize(url); + activeRootSpan.setAttributes(_getRouteUrlAttributes(url, routeInfo.params ?? transition.to?.params)); + } + if (disableRunloopPerformance) { activeRootSpan.end(); return; diff --git a/packages/ember/tests/unit/instrument-router-location-test.ts b/packages/ember/tests/unit/instrument-router-location-test.ts index a225089319af..6e4d134c2307 100644 --- a/packages/ember/tests/unit/instrument-router-location-test.ts +++ b/packages/ember/tests/unit/instrument-router-location-test.ts @@ -1,5 +1,8 @@ import type { EmberRouterMain } from '@sentry/ember/addon/types'; -import { _getLocationURL } from '@sentry/ember/utils/instrumentEmberAppInstanceForPerformance'; +import { + _getLocationURL, + _getRouteUrlAttributes, +} from '@sentry/ember/utils/instrumentEmberAppInstanceForPerformance'; import { setupTest } from 'ember-qunit'; import { module, test } from 'qunit'; import type { SentryTestContext } from '../helpers/setup-sentry'; @@ -97,4 +100,28 @@ module('Unit | Utility | instrument-router-location', function (hooks) { const result = _getLocationURL(mockLocation); assert.strictEqual(result, '', 'Should return empty string when formatURL is not available'); }); + + test('_getRouteUrlAttributes handles history location paths', function (this: SentryTestContext, assert) { + const result = _getRouteUrlAttributes('/tracing'); + + assert.strictEqual(result['url.path'], '/tracing'); + assert.strictEqual(result['url.template'], '/tracing'); + assert.ok(result['url.full']?.includes('/tracing'), 'url.full includes the path'); + }); + + test('_getRouteUrlAttributes parameterizes dynamic segments', function (this: SentryTestContext, assert) { + const result = _getRouteUrlAttributes('/users/123', { user_id: '123' }); + + assert.strictEqual(result['url.path'], '/users/123'); + assert.strictEqual(result['url.template'], '/users/:user_id'); + assert.ok(result['url.full']?.includes('/users/123'), 'url.full includes the path'); + }); + + test('_getRouteUrlAttributes handles hash location paths', function (this: SentryTestContext, assert) { + const result = _getRouteUrlAttributes('/#/tracing'); + + assert.strictEqual(result['url.path'], '/tracing'); + assert.strictEqual(result['url.template'], '/tracing'); + assert.ok(result['url.full']?.includes('/tracing'), 'url.full includes the path'); + }); }); From 8c71aa75d69e9536560b43c10a6b5b866089ea0a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 19:19:32 +0200 Subject: [PATCH 2/4] fix(ember): Avoid stale url attributes on navigation spans At `routeWillChange` the router's location still points at the current (pre-transition) route, so falling back to it when `transition.intent.url` is missing tagged the navigation root span with the previous route's `url.path`, `url.template`, and `url.full` until `routeDidChange` corrected them. Only use `intent.url` when available and otherwise defer the url attributes to `routeDidChange`, which reads them from `currentURL`. Co-Authored-By: Claude Co-authored-by: Cursor --- .../utils/instrumentEmberAppInstanceForPerformance.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts index 3fd9788ae6bf..b4261e247e40 100644 --- a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -180,9 +180,12 @@ function _instrumentEmberRouter( activeRootSpan?.end(); - const targetUrl = - (transition as TransitionWithIntent).intent?.url ?? _getLocationURL(location); - const urlAttributes = _getRouteUrlAttributes(targetUrl, transition.to?.params); + // Only `intent.url` reliably reflects the *destination* URL at `routeWillChange` time. The + // router's location still points at the current (pre-transition) route here, so falling back to + // it would tag the navigation span with the previous route's `url.*` attributes. When we don't + // have a trustworthy target URL, we omit them and let `routeDidChange` set them from `currentURL`. + const targetUrl = (transition as TransitionWithIntent).intent?.url; + const urlAttributes = targetUrl ? _getRouteUrlAttributes(targetUrl, transition.to?.params) : {}; activeRootSpan = startBrowserTracingNavigationSpan(client, { name: `route:${toRoute}`, From 54d077c7c5aebd2c899a3b359a9d3b3f25ab2257 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 19:31:41 +0200 Subject: [PATCH 3/4] format --- .../addon/utils/instrumentEmberAppInstanceForPerformance.ts | 5 +---- packages/ember/tests/unit/instrument-router-location-test.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts index b4261e247e40..fe5bb099bca8 100644 --- a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -102,10 +102,7 @@ function buildUrlTemplate(path: string, params: Record = {}): s } // Only exported for testing -export function _getRouteUrlAttributes( - url: string, - params: Record = {}, -): Record { +export function _getRouteUrlAttributes(url: string, params: Record = {}): Record { const path = getUrlPathFromEmberLocation(url); return { diff --git a/packages/ember/tests/unit/instrument-router-location-test.ts b/packages/ember/tests/unit/instrument-router-location-test.ts index 6e4d134c2307..32b192366ed4 100644 --- a/packages/ember/tests/unit/instrument-router-location-test.ts +++ b/packages/ember/tests/unit/instrument-router-location-test.ts @@ -1,8 +1,5 @@ import type { EmberRouterMain } from '@sentry/ember/addon/types'; -import { - _getLocationURL, - _getRouteUrlAttributes, -} from '@sentry/ember/utils/instrumentEmberAppInstanceForPerformance'; +import { _getLocationURL, _getRouteUrlAttributes } from '@sentry/ember/utils/instrumentEmberAppInstanceForPerformance'; import { setupTest } from 'ember-qunit'; import { module, test } from 'qunit'; import type { SentryTestContext } from '../helpers/setup-sentry'; From 220eed03da9a9959e9a1bf26231ce2c7730a6a97 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 20:04:52 +0200 Subject: [PATCH 4/4] fix(ember): Preserve hash fragment in url.full on router spans `_getRouteUrlAttributes` built `url.full` from the hash-stripped route path, so hash-location apps lost their `#/...` fragment (turning `https://host/#/tracing` into `https://host/tracing`). On `routeDidChange` this was compounded by using `currentURL`, which is the normalized route path and never carries the hash. Derive `url.full` from the unstripped URL and, on `routeDidChange`, source it from the location URL while keeping `currentURL` for path/template. Co-Authored-By: Claude Co-Authored-By: Cursor --- .../instrumentEmberAppInstanceForPerformance.ts | 15 ++++++++++++--- .../tests/unit/instrument-router-location-test.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts index fe5bb099bca8..750f0115ee1a 100644 --- a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -102,12 +102,18 @@ function buildUrlTemplate(path: string, params: Record = {}): s } // Only exported for testing -export function _getRouteUrlAttributes(url: string, params: Record = {}): Record { +export function _getRouteUrlAttributes( + url: string, + params: Record = {}, + fullUrl: string = url, +): Record { const path = getUrlPathFromEmberLocation(url); + // `url.full` is derived from the unstripped URL so that hash-location apps keep their `#/...` + // fragment (e.g. `https://host/#/tracing`), which would otherwise be lost by `getUrlPathFromEmberLocation`. return { [URL_PATH]: path, - [URL_FULL]: getAbsoluteUrl(path), + [URL_FULL]: getAbsoluteUrl(fullUrl), [URL_TEMPLATE]: buildUrlTemplate(path, params), }; } @@ -214,7 +220,10 @@ function _instrumentEmberRouter( const url = routerService.currentURL ?? _getLocationURL(location); if (url) { const routeInfo = routerService.recognize(url); - activeRootSpan.setAttributes(_getRouteUrlAttributes(url, routeInfo.params ?? transition.to?.params)); + // `currentURL` is the normalized route path and never includes the hash fragment, so we source + // `url.full` from the location URL (which preserves `#/...` for hash-location apps) when available. + const fullUrl = _getLocationURL(location) || url; + activeRootSpan.setAttributes(_getRouteUrlAttributes(url, routeInfo.params ?? transition.to?.params, fullUrl)); } if (disableRunloopPerformance) { diff --git a/packages/ember/tests/unit/instrument-router-location-test.ts b/packages/ember/tests/unit/instrument-router-location-test.ts index 32b192366ed4..3c513330e522 100644 --- a/packages/ember/tests/unit/instrument-router-location-test.ts +++ b/packages/ember/tests/unit/instrument-router-location-test.ts @@ -119,6 +119,15 @@ module('Unit | Utility | instrument-router-location', function (hooks) { assert.strictEqual(result['url.path'], '/tracing'); assert.strictEqual(result['url.template'], '/tracing'); - assert.ok(result['url.full']?.includes('/tracing'), 'url.full includes the path'); + assert.ok(result['url.full']?.includes('/#/tracing'), 'url.full preserves the hash fragment'); + }); + + test('_getRouteUrlAttributes sources url.full from an explicit fullUrl', function (this: SentryTestContext, assert) { + // Mirrors `routeDidChange`, where the route path (`currentURL`) has no hash but the location URL does. + const result = _getRouteUrlAttributes('/tracing', {}, '/#/tracing'); + + assert.strictEqual(result['url.path'], '/tracing'); + assert.strictEqual(result['url.template'], '/tracing'); + assert.ok(result['url.full']?.includes('/#/tracing'), 'url.full preserves the hash fragment from fullUrl'); }); });