Skip to content

Commit efa04ff

Browse files
authored
fix(angular): Ensure navigations always create a transaction (#10646)
This PR addresses the problematic check of `getActiveSpan()` in our Angular SDK's `TraceService` raised in #10634. In certain situations (e.g. very quick navigations after the initial pageload) the check of `getActiveSpan()` could lead to navigations not getting their own transaction, as the pageload transaction was still active at that time. ref #10634
1 parent fa8b205 commit efa04ff

File tree

6 files changed

+165
-18
lines changed

6 files changed

+165
-18
lines changed

dev-packages/e2e-tests/test-applications/angular-17/package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"scripts": {
55
"ng": "ng",
66
"dev": "ng serve",
7-
"preview": "http-server dist/angular-17/browser",
7+
"proxy": "ts-node-script start-event-proxy.ts",
8+
"preview": "http-server dist/angular-17/browser --port 8080",
89
"build": "ng build",
910
"watch": "ng build --watch --configuration development",
1011
"test": "playwright test",
@@ -22,6 +23,7 @@
2223
"@angular/platform-browser": "^17.1.0",
2324
"@angular/platform-browser-dynamic": "^17.1.0",
2425
"@angular/router": "^17.1.0",
26+
"@sentry/angular-ivy": "* || latest",
2527
"rxjs": "~7.8.0",
2628
"tslib": "^2.3.0",
2729
"zone.js": "~0.14.3"
@@ -31,7 +33,6 @@
3133
"@angular/cli": "^17.1.1",
3234
"@angular/compiler-cli": "^17.1.0",
3335
"@playwright/test": "^1.41.1",
34-
"@sentry/angular-ivy": "latest || *",
3536
"@types/jasmine": "~5.1.0",
3637
"http-server": "^14.1.1",
3738
"jasmine-core": "~5.1.0",
@@ -40,11 +41,18 @@
4041
"karma-coverage": "~2.2.0",
4142
"karma-jasmine": "~5.1.0",
4243
"karma-jasmine-html-reporter": "~2.1.0",
43-
"typescript": "~5.3.2",
4444
"ts-node": "10.9.1",
45+
"typescript": "~5.3.2",
4546
"wait-port": "1.0.4"
4647
},
4748
"volta": {
4849
"extends": "../../package.json"
50+
},
51+
"pnpm": {
52+
"overrides": {
53+
"@sentry/browser": "latest || *",
54+
"@sentry/core": "latest || *",
55+
"@sentry/utils": "latest || *"
56+
}
4957
}
5058
}

dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ const config: PlaywrightTestConfig = {
2929
*/
3030
timeout: 10000,
3131
},
32-
/* Run tests in files in parallel */
33-
fullyParallel: true,
32+
fullyParallel: false,
33+
workers: 1,
3434
/* Fail the build on CI if you accidentally left test.only in the source code. */
3535
forbidOnly: !!process.env.CI,
3636
/* `next dev` is incredibly buggy with the app dir */

dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ export const routes: Routes = [
1111
path: 'home',
1212
component: HomeComponent,
1313
},
14+
{
15+
path: 'redirect1',
16+
redirectTo: '/redirect2',
17+
},
18+
{
19+
path: 'redirect2',
20+
redirectTo: '/redirect3',
21+
},
22+
{
23+
path: 'redirect3',
24+
redirectTo: '/users/456',
25+
},
1426
{
1527
path: '**',
1628
redirectTo: 'home',

dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router';
1010
<h1>Welcome to Sentry's Angular 17 E2E test app</h1>
1111
<ul>
1212
<li> <a id="navLink" [routerLink]="['/users', '123']">Visit User 123</a> </li>
13+
<li> <a id="redirectLink" [routerLink]="['/redirect1']">Redirect</a> </li>
1314
</ul>
1415
<button id="errorBtn" (click)="throwError()">Throw error</button>
1516
</main>

dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,77 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
5252
},
5353
});
5454
});
55+
56+
test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => {
57+
const pageloadTxnPromise = waitForTransaction('angular-17', async transactionEvent => {
58+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
59+
});
60+
61+
const navigationTxnPromise = waitForTransaction('angular-17', async transactionEvent => {
62+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
63+
});
64+
65+
await page.goto(`/`);
66+
67+
// immediately navigate to a different route
68+
const [_, pageloadTxn, navigationTxn] = await Promise.all([
69+
page.locator('#navLink').click(),
70+
pageloadTxnPromise,
71+
navigationTxnPromise,
72+
]);
73+
74+
expect(pageloadTxn).toMatchObject({
75+
contexts: {
76+
trace: {
77+
op: 'pageload',
78+
origin: 'auto.pageload.angular',
79+
},
80+
},
81+
transaction: '/home/',
82+
transaction_info: {
83+
source: 'route',
84+
},
85+
});
86+
87+
expect(navigationTxn).toMatchObject({
88+
contexts: {
89+
trace: {
90+
op: 'navigation',
91+
origin: 'auto.navigation.angular',
92+
},
93+
},
94+
transaction: '/users/:id/',
95+
transaction_info: {
96+
source: 'route',
97+
},
98+
});
99+
});
100+
101+
test('groups redirects within one navigation root span', async ({ page }) => {
102+
const navigationTxnPromise = waitForTransaction('angular-17', async transactionEvent => {
103+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
104+
});
105+
106+
await page.goto(`/`);
107+
108+
// immediately navigate to a different route
109+
const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]);
110+
111+
expect(navigationTxn).toMatchObject({
112+
contexts: {
113+
trace: {
114+
op: 'navigation',
115+
origin: 'auto.navigation.angular',
116+
},
117+
},
118+
transaction: '/users/:id/',
119+
transaction_info: {
120+
source: 'route',
121+
},
122+
});
123+
124+
const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing');
125+
126+
expect(routingSpan).toBeDefined();
127+
expect(routingSpan?.description).toBe('/redirect1');
128+
});

packages/angular/src/tracing.ts

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,14 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router
88
import { NavigationCancel, NavigationError, Router } from '@angular/router';
99
import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router';
1010
import {
11+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
12+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1113
WINDOW,
1214
browserTracingIntegration as originalBrowserTracingIntegration,
1315
getCurrentScope,
1416
startBrowserTracingNavigationSpan,
1517
} from '@sentry/browser';
16-
import {
17-
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
18-
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
19-
getActiveSpan,
20-
getClient,
21-
spanToJSON,
22-
startInactiveSpan,
23-
} from '@sentry/core';
18+
import { getActiveSpan, getClient, getRootSpan, spanToJSON, startInactiveSpan } from '@sentry/core';
2419
import type { Integration, Span, Transaction, TransactionContext } from '@sentry/types';
2520
import { logger, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/utils';
2621
import type { Observable } from 'rxjs';
@@ -126,24 +121,31 @@ export class TraceService implements OnDestroy {
126121
const strippedUrl = stripUrlQueryAndFragment(navigationEvent.url);
127122

128123
if (client && hooksBasedInstrumentation) {
129-
if (!getActiveSpan()) {
124+
// see comment in `_isPageloadOngoing` for rationale
125+
if (!this._isPageloadOngoing()) {
130126
startBrowserTracingNavigationSpan(client, {
131127
name: strippedUrl,
132-
origin: 'auto.navigation.angular',
133128
attributes: {
129+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular',
134130
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
135131
},
136132
});
133+
} else {
134+
// The first time we end up here, we set the pageload flag to false
135+
// Subsequent navigations are going to get their own navigation root span
136+
// even if the pageload root span is still ongoing.
137+
this._pageloadOngoing = false;
137138
}
138139

139-
// eslint-disable-next-line deprecation/deprecation
140140
this._routingSpan =
141141
startInactiveSpan({
142142
name: `${navigationEvent.url}`,
143143
op: ANGULAR_ROUTING_OP,
144-
origin: 'auto.ui.angular',
144+
attributes: {
145+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular',
146+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
147+
},
145148
tags: {
146-
'routing.instrumentation': '@sentry/angular',
147149
url: strippedUrl,
148150
...(navigationEvent.navigationTrigger && {
149151
navigationTrigger: navigationEvent.navigationTrigger,
@@ -232,8 +234,15 @@ export class TraceService implements OnDestroy {
232234

233235
private _subscription: Subscription;
234236

237+
/**
238+
* @see _isPageloadOngoing()
239+
*/
240+
private _pageloadOngoing: boolean;
241+
235242
public constructor(private readonly _router: Router) {
236243
this._routingSpan = null;
244+
this._pageloadOngoing = true;
245+
237246
this._subscription = new Subscription();
238247

239248
this._subscription.add(this.navStart$.subscribe());
@@ -248,6 +257,49 @@ export class TraceService implements OnDestroy {
248257
public ngOnDestroy(): void {
249258
this._subscription.unsubscribe();
250259
}
260+
261+
/**
262+
* We only _avoid_ creating a navigation root span in one case:
263+
*
264+
* There is an ongoing pageload span AND the router didn't yet emit the first navigation start event
265+
*
266+
* The first navigation start event will create the child routing span
267+
* and update the pageload root span name on ResolveEnd.
268+
*
269+
* There's an edge case we need to avoid here: If the router fires the first navigation start event
270+
* _after_ the pageload root span finished. This is why we check for the pageload root span.
271+
* Possible real-world scenario: Angular application and/or router is bootstrapped after the pageload
272+
* idle root span finished
273+
*
274+
* The overall rationale is:
275+
* - if we already avoided creating a navigation root span once, we don't avoid it again
276+
* (i.e. set `_pageloadOngoing` to `false`)
277+
* - if `_pageloadOngoing` is already `false`, create a navigation root span
278+
* - if there's no active/pageload root span, create a navigation root span
279+
* - only if there's an ongoing pageload root span AND `_pageloadOngoing` is still `true,
280+
* con't create a navigation root span
281+
*/
282+
private _isPageloadOngoing(): boolean {
283+
if (!this._pageloadOngoing) {
284+
// pageload is already finished, no need to update
285+
return false;
286+
}
287+
288+
const activeSpan = getActiveSpan();
289+
if (!activeSpan) {
290+
this._pageloadOngoing = false;
291+
return false;
292+
}
293+
294+
const rootSpan = getRootSpan(activeSpan);
295+
if (!rootSpan) {
296+
this._pageloadOngoing = false;
297+
return false;
298+
}
299+
300+
this._pageloadOngoing = spanToJSON(rootSpan).op === 'pageload';
301+
return this._pageloadOngoing;
302+
}
251303
}
252304

253305
const UNKNOWN_COMPONENT = 'unknown';

0 commit comments

Comments
 (0)