From 96bc4a40ef16732fd1a90f9b791afa6b27d21d04 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 13 Feb 2024 16:43:34 +0100 Subject: [PATCH 1/2] fix(angular): Ensure navigations always create a transaction --- .../test-applications/angular-17/package.json | 14 +++- .../angular-17/playwright.config.ts | 4 +- .../angular-17/src/app/app.routes.ts | 12 +++ .../angular-17/src/app/home/home.component.ts | 1 + .../angular-17/tests/performance.test.ts | 74 +++++++++++++++++++ packages/angular/src/tracing.ts | 37 ++++++---- 6 files changed, 124 insertions(+), 18 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/angular-17/package.json b/dev-packages/e2e-tests/test-applications/angular-17/package.json index c81a2d19c8c4..75ccc8f3e091 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-17/package.json @@ -4,7 +4,8 @@ "scripts": { "ng": "ng", "dev": "ng serve", - "preview": "http-server dist/angular-17/browser", + "proxy": "ts-node-script start-event-proxy.ts", + "preview": "http-server dist/angular-17/browser --port 8080", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "playwright test", @@ -22,6 +23,7 @@ "@angular/platform-browser": "^17.1.0", "@angular/platform-browser-dynamic": "^17.1.0", "@angular/router": "^17.1.0", + "@sentry/angular-ivy": "* || latest", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" @@ -31,7 +33,6 @@ "@angular/cli": "^17.1.1", "@angular/compiler-cli": "^17.1.0", "@playwright/test": "^1.41.1", - "@sentry/angular-ivy": "latest || *", "@types/jasmine": "~5.1.0", "http-server": "^14.1.1", "jasmine-core": "~5.1.0", @@ -40,11 +41,18 @@ "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", - "typescript": "~5.3.2", "ts-node": "10.9.1", + "typescript": "~5.3.2", "wait-port": "1.0.4" }, "volta": { "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/utils": "latest || *" + } } } diff --git a/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts b/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts index 967aad98df5e..3b0d25b80d09 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts @@ -29,8 +29,8 @@ const config: PlaywrightTestConfig = { */ timeout: 10000, }, - /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, + workers: 1, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* `next dev` is incredibly buggy with the app dir */ diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts index 0b44bc341a9b..d4d9a04f6c99 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts @@ -11,6 +11,18 @@ export const routes: Routes = [ path: 'home', component: HomeComponent, }, + { + path: 'redirect1', + redirectTo: '/redirect2', + }, + { + path: 'redirect2', + redirectTo: '/redirect3', + }, + { + path: 'redirect3', + redirectTo: '/users/456', + }, { path: '**', redirectTo: 'home', diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts index 58a375be1a2d..9179f5d79638 100644 --- a/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts @@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router';

Welcome to Sentry's Angular 17 E2E test app

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 8cda20ec3853..9b5f1b08e9d2 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 @@ -52,3 +52,77 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }, }); }); + +test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, pageloadTxn, navigationTxn] = await Promise.all([ + page.locator('#navLink').click(), + pageloadTxnPromise, + navigationTxnPromise, + ]); + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('groups redirects within one navigation root span', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/redirect1'); +}); diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index 43c6227b0f8e..4b55050ae37c 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -8,19 +8,14 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router import { NavigationCancel, NavigationError, Router } from '@angular/router'; import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router'; import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, WINDOW, browserTracingIntegration as originalBrowserTracingIntegration, getCurrentScope, startBrowserTracingNavigationSpan, } from '@sentry/browser'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - getActiveSpan, - getClient, - spanToJSON, - startInactiveSpan, -} from '@sentry/core'; +import { getClient, spanToJSON, startInactiveSpan } from '@sentry/core'; import type { Integration, Span, Transaction, TransactionContext } from '@sentry/types'; import { logger, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/utils'; import type { Observable } from 'rxjs'; @@ -126,24 +121,30 @@ export class TraceService implements OnDestroy { const strippedUrl = stripUrlQueryAndFragment(navigationEvent.url); if (client && hooksBasedInstrumentation) { - if (!getActiveSpan()) { + if (!this._pageloadOngoing) { startBrowserTracingNavigationSpan(client, { name: strippedUrl, - origin: 'auto.navigation.angular', attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, }); + } else { + // The first time we end up here, we set the pageload flag to false + // Subsequent navigations are going to get their own navigation root span + // even if the pageload root span is still ongoing. + this._pageloadOngoing = false; } - // eslint-disable-next-line deprecation/deprecation this._routingSpan = startInactiveSpan({ name: `${navigationEvent.url}`, op: ANGULAR_ROUTING_OP, - origin: 'auto.ui.angular', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, tags: { - 'routing.instrumentation': '@sentry/angular', url: strippedUrl, ...(navigationEvent.navigationTrigger && { navigationTrigger: navigationEvent.navigationTrigger, @@ -232,8 +233,18 @@ export class TraceService implements OnDestroy { private _subscription: Subscription; + /** + * Flag used to determine if navigation events belong to the ongoing pageload. + * The first navigation event after pageload is considered the end of the pageload, + * meaning afterwards, every new navigation start event will create its own + * navigation root span. + */ + private _pageloadOngoing: boolean; + public constructor(private readonly _router: Router) { this._routingSpan = null; + this._pageloadOngoing = true; + this._subscription = new Subscription(); this._subscription.add(this.navStart$.subscribe()); From a22f446cb0348dd85385bec695e750fb79f19963 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 14 Feb 2024 13:27:28 +0100 Subject: [PATCH 2/2] cleanup logic, extract to method, handle edge case --- packages/angular/src/tracing.ts | 53 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index 4b55050ae37c..bbfe3afd3b67 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -15,7 +15,7 @@ import { getCurrentScope, startBrowserTracingNavigationSpan, } from '@sentry/browser'; -import { getClient, spanToJSON, startInactiveSpan } from '@sentry/core'; +import { getActiveSpan, getClient, getRootSpan, spanToJSON, startInactiveSpan } from '@sentry/core'; import type { Integration, Span, Transaction, TransactionContext } from '@sentry/types'; import { logger, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/utils'; import type { Observable } from 'rxjs'; @@ -121,7 +121,8 @@ export class TraceService implements OnDestroy { const strippedUrl = stripUrlQueryAndFragment(navigationEvent.url); if (client && hooksBasedInstrumentation) { - if (!this._pageloadOngoing) { + // see comment in `_isPageloadOngoing` for rationale + if (!this._isPageloadOngoing()) { startBrowserTracingNavigationSpan(client, { name: strippedUrl, attributes: { @@ -234,10 +235,7 @@ export class TraceService implements OnDestroy { private _subscription: Subscription; /** - * Flag used to determine if navigation events belong to the ongoing pageload. - * The first navigation event after pageload is considered the end of the pageload, - * meaning afterwards, every new navigation start event will create its own - * navigation root span. + * @see _isPageloadOngoing() */ private _pageloadOngoing: boolean; @@ -259,6 +257,49 @@ export class TraceService implements OnDestroy { public ngOnDestroy(): void { this._subscription.unsubscribe(); } + + /** + * We only _avoid_ creating a navigation root span in one case: + * + * There is an ongoing pageload span AND the router didn't yet emit the first navigation start event + * + * The first navigation start event will create the child routing span + * and update the pageload root span name on ResolveEnd. + * + * There's an edge case we need to avoid here: If the router fires the first navigation start event + * _after_ the pageload root span finished. This is why we check for the pageload root span. + * Possible real-world scenario: Angular application and/or router is bootstrapped after the pageload + * idle root span finished + * + * The overall rationale is: + * - if we already avoided creating a navigation root span once, we don't avoid it again + * (i.e. set `_pageloadOngoing` to `false`) + * - if `_pageloadOngoing` is already `false`, create a navigation root span + * - if there's no active/pageload root span, create a navigation root span + * - only if there's an ongoing pageload root span AND `_pageloadOngoing` is still `true, + * con't create a navigation root span + */ + private _isPageloadOngoing(): boolean { + if (!this._pageloadOngoing) { + // pageload is already finished, no need to update + return false; + } + + const activeSpan = getActiveSpan(); + if (!activeSpan) { + this._pageloadOngoing = false; + return false; + } + + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) { + this._pageloadOngoing = false; + return false; + } + + this._pageloadOngoing = spanToJSON(rootSpan).op === 'pageload'; + return this._pageloadOngoing; + } } const UNKNOWN_COMPONENT = 'unknown';