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 59749ff3cc78..e2238d42a17a 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, 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 a44b454b10ef..66146b4b49ae 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, 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 c41aa5859f82..826959def7b2 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, 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 cfdd2d95c575..18dbd47eb540 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, 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 cee1f939c4c6..d2617be9c653 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, 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 f7a9be7cae56..4eca907379e4 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 @@ -17,6 +17,13 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -46,6 +53,14 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) contexts: { trace: { op: 'navigation', + origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -78,6 +93,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'pageload', origin: 'auto.pageload.angular', + data: { + 'sentry.origin': 'auto.pageload.angular', + 'sentry.source': 'route', + 'url.template': '/home/', + 'url.path': '/home', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/home$/), + }, }, }, transaction: '/home/', @@ -91,6 +113,13 @@ test('sends a navigation transaction even if the pageload span is still active', trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), + }, }, }, transaction: '/users/:id/', @@ -115,6 +144,13 @@ test('groups redirects within one navigation root span', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'route', + 'url.template': '/users/:id/', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), + }, }, }, transaction: '/users/:id/', @@ -145,6 +181,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/cancel', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/cancel$/), + // url.template is not set because the navigation was cancelled before Angular fully resolved the route + }, }, }, transaction: '/cancel', @@ -176,6 +219,13 @@ test.describe('finish routing span', () => { trace: { op: 'navigation', origin: 'auto.navigation.angular', + data: { + 'sentry.origin': 'auto.navigation.angular', + 'sentry.source': 'url', + 'url.path': '/non-existent', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/non-existent$/), + // url.template is not set because the navigation failed before Angular fully resolved the route + }, }, }, transaction: nonExistentRoute, diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index 4a2d2eac3db3..2f41a87c930f 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -4,6 +4,6 @@ "lib": { "entryFile": "src/index.ts" }, - "allowedNonPeerDependencies": ["@sentry/browser", "@sentry/core", "@sentry/types", "tslib"], + "allowedNonPeerDependencies": ["@sentry/browser", "@sentry/core", "@sentry/types", "@sentry/conventions", "tslib"], "assets": [] } diff --git a/packages/angular/package.json b/packages/angular/package.json index 162d6bd565a4..c347457081b1 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,6 +23,7 @@ "dependencies": { "@sentry/browser": "10.64.0", "@sentry/core": "10.64.0", + "@sentry/conventions": "^0.15.1", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index 86d7a4771c9c..f720d5c8803c 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -19,6 +19,7 @@ import { spanToJSON, startBrowserTracingNavigationSpan, startInactiveSpan, + WINDOW, } from '@sentry/browser'; import type { Integration, Span } from '@sentry/core'; import { debug, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/core'; @@ -28,6 +29,7 @@ import { filter, tap } from 'rxjs/operators'; import { ANGULAR_INIT_OP, ANGULAR_OP, ANGULAR_ROUTING_OP } from './constants'; import { IS_DEBUG_BUILD } from './flags'; import { runOutsideAngular } from './zone'; +import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; let instrumentationInitialized: boolean; @@ -54,13 +56,38 @@ export function browserTracingIntegration( /** * This function is extracted to make unit testing easier. */ -export function _updateSpanAttributesForParametrizedUrl(route: string, span?: Span): void { - const attributes = (span && spanToJSON(span).data) || {}; +export function _updateSpanAttributesForParametrizedUrl(route: string, url: string, span?: Span): void { + if (!span) { + return; + } + + const { data: attributes, op } = spanToJSON(span); - if (span && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'url') { + if (!attributes || attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'url') { span.updateName(route); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, `auto.${spanToJSON(span).op}.angular`); + + // Angular router gives us relative paths (e.g. `/users/123`). Resolve against the + // current origin so that `url.full` contains the absolute URL including protocol and host. + const locationOrigin = WINDOW.location?.origin; + let urlFull = url; + let urlPath = url; + if (locationOrigin) { + try { + const parsed = new URL(url, locationOrigin); + urlFull = parsed.href; + urlPath = parsed.pathname; + } catch { + // fall back to the raw string + } + } + + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.${op}.angular`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_FULL]: urlFull, + [URL_PATH]: urlPath, + [URL_TEMPLATE]: route, + }); } } @@ -91,13 +118,30 @@ export class TraceService implements OnDestroy { // see comment in `_isPageloadOngoing` for rationale if (!this._isPageloadOngoing()) { runOutsideAngular(() => { - startBrowserTracingNavigationSpan(client, { - name: strippedUrl, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + // Angular router gives us a relative path; resolve it against the current origin + // so the browser tracing integration can set url.full correctly from the start. + const locationOrigin = WINDOW.location?.origin; + let absoluteUrl: string = navigationEvent.url; + if (locationOrigin) { + try { + absoluteUrl = new URL(navigationEvent.url, locationOrigin).href; + } catch { + // fall back to relative path + } + } + startBrowserTracingNavigationSpan( + client, + { + name: strippedUrl, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }, + { + url: absoluteUrl, }, - }); + ); }); } else { // The first time we end up here, we set the pageload flag to false @@ -149,7 +193,7 @@ export class TraceService implements OnDestroy { const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - _updateSpanAttributesForParametrizedUrl(route, rootSpan); + _updateSpanAttributesForParametrizedUrl(route, event.urlAfterRedirects, rootSpan); }), ); diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index 872fc9c67505..9d34fba938eb 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -8,6 +8,8 @@ import { import { describe, it } from 'vitest'; import { browserTracingIntegration, init, TraceDirective } from '../src/index'; import { _updateSpanAttributesForParametrizedUrl, getParameterizedRouteFromSnapshot } from '../src/tracing'; +import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { expect } from 'vitest'; describe('browserTracingIntegration', () => { it('implements required hooks', () => { @@ -73,18 +75,23 @@ describe('Angular Tracing', () => { it('change the span name to route name if the the source is `url`', async () => { init({ integrations: [browserTracingIntegration()] }); - const route = 'sample-route'; + const route = '/users/:id/'; + const url = '/users/123/'; const span = new SentrySpan({ name: 'initial-span-name' }); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); - _updateSpanAttributesForParametrizedUrl(route, span); + _updateSpanAttributesForParametrizedUrl(route, url, span); expect(spanToJSON(span)).toEqual( expect.objectContaining({ - data: { + data: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.undefined.angular', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, + [URL_TEMPLATE]: route, + // URL_FULL is resolved against jsdom's http://localhost origin + [URL_FULL]: expect.stringContaining('/users/123/'), + [URL_PATH]: '/users/123/', + }), description: route, }), ); @@ -93,11 +100,12 @@ describe('Angular Tracing', () => { it('does not change the span name if the source is something other than `url`', async () => { init({ integrations: [browserTracingIntegration()] }); - const route = 'sample-route'; + const route = '/users/:id/'; + const url = '/users/123/'; const span = new SentrySpan({ name: 'initial-span-name' }); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'sample-source'); - _updateSpanAttributesForParametrizedUrl(route, span); + _updateSpanAttributesForParametrizedUrl(route, url, span); expect(spanToJSON(span)).toEqual( expect.objectContaining({