Skip to content

Commit 7992d25

Browse files
author
Luca Forstner
authored
fix(tracing-internal): Delay pageload transaction finish until document is interactive (#10215)
1 parent 68f26cc commit 7992d25

File tree

6 files changed

+107
-9
lines changed

6 files changed

+107
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { startSpanManual } from '@sentry/browser';
3+
import { Integrations } from '@sentry/tracing';
4+
5+
window.Sentry = Sentry;
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
integrations: [new Integrations.BrowserTracing()],
10+
tracesSampleRate: 1,
11+
});
12+
13+
setTimeout(() => {
14+
startSpanManual({ name: 'pageload-child-span' }, () => {});
15+
}, 200);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
// This tests asserts that the pageload transaction will finish itself after about 15 seconds (3x5s of heartbeats) if it
8+
// has a child span without adding any additional ones or finishing any of them finishing. All of the child spans that
9+
// are still running should have the status "cancelled".
10+
sentryTest(
11+
'should send a pageload transaction terminated via heartbeat timeout',
12+
async ({ getLocalTestPath, page }) => {
13+
if (shouldSkipTracingTest()) {
14+
sentryTest.skip();
15+
}
16+
17+
const url = await getLocalTestPath({ testDir: __dirname });
18+
19+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
20+
21+
expect(eventData.contexts?.trace?.op).toBe('pageload');
22+
expect(
23+
// eslint-disable-next-line deprecation/deprecation
24+
eventData.spans?.find(span => span.description === 'pageload-child-span' && span.status === 'cancelled'),
25+
).toBeDefined();
26+
},
27+
);

packages/core/src/tracing/hubextensions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,22 @@ export function startIdleTransaction(
8989
onScope?: boolean,
9090
customSamplingContext?: CustomSamplingContext,
9191
heartbeatInterval?: number,
92+
delayAutoFinishUntilSignal: boolean = false,
9293
): IdleTransaction {
9394
// eslint-disable-next-line deprecation/deprecation
9495
const client = hub.getClient();
9596
const options: Partial<ClientOptions> = (client && client.getOptions()) || {};
9697

9798
// eslint-disable-next-line deprecation/deprecation
98-
let transaction = new IdleTransaction(transactionContext, hub, idleTimeout, finalTimeout, heartbeatInterval, onScope);
99+
let transaction = new IdleTransaction(
100+
transactionContext,
101+
hub,
102+
idleTimeout,
103+
finalTimeout,
104+
heartbeatInterval,
105+
onScope,
106+
delayAutoFinishUntilSignal,
107+
);
99108
transaction = sampleTransaction(transaction, options, {
100109
parentSampled: transactionContext.parentSampled,
101110
transactionContext,

packages/core/src/tracing/idletransaction.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export class IdleTransaction extends Transaction {
9595

9696
private _finishReason: (typeof IDLE_TRANSACTION_FINISH_REASONS)[number];
9797

98+
private _autoFinishAllowed: boolean;
99+
98100
/**
99101
* @deprecated Transactions will be removed in v8. Use spans instead.
100102
*/
@@ -113,6 +115,15 @@ export class IdleTransaction extends Transaction {
113115
private readonly _heartbeatInterval: number = TRACING_DEFAULTS.heartbeatInterval,
114116
// Whether or not the transaction should put itself on the scope when it starts and pop itself off when it ends
115117
private readonly _onScope: boolean = false,
118+
/**
119+
* When set to `true`, will disable the idle timeout (`_idleTimeout` option) and heartbeat mechanisms (`_heartbeatInterval`
120+
* option) until the `sendAutoFinishSignal()` method is called. The final timeout mechanism (`_finalTimeout` option)
121+
* will not be affected by this option, meaning the transaction will definitely be finished when the final timeout is
122+
* reached, no matter what this option is configured to.
123+
*
124+
* Defaults to `false`.
125+
*/
126+
delayAutoFinishUntilSignal: boolean = false,
116127
) {
117128
super(transactionContext, _idleHub);
118129

@@ -122,6 +133,7 @@ export class IdleTransaction extends Transaction {
122133
this._idleTimeoutCanceledPermanently = false;
123134
this._beforeFinishCallbacks = [];
124135
this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[4];
136+
this._autoFinishAllowed = !delayAutoFinishUntilSignal;
125137

126138
if (_onScope) {
127139
// We set the transaction here on the scope so error events pick up the trace
@@ -131,7 +143,10 @@ export class IdleTransaction extends Transaction {
131143
_idleHub.getScope().setSpan(this);
132144
}
133145

134-
this._restartIdleTimeout();
146+
if (!delayAutoFinishUntilSignal) {
147+
this._restartIdleTimeout();
148+
}
149+
135150
setTimeout(() => {
136151
if (!this._finished) {
137152
this.setStatus('deadline_exceeded');
@@ -217,7 +232,7 @@ export class IdleTransaction extends Transaction {
217232
}
218233

219234
/**
220-
* Register a callback function that gets excecuted before the transaction finishes.
235+
* Register a callback function that gets executed before the transaction finishes.
221236
* Useful for cleanup or if you want to add any additional spans based on current context.
222237
*
223238
* This is exposed because users have no other way of running something before an idle transaction
@@ -298,6 +313,17 @@ export class IdleTransaction extends Transaction {
298313
this._finishReason = reason;
299314
}
300315

316+
/**
317+
* Permits the IdleTransaction to automatically end itself via the idle timeout and heartbeat mechanisms when the `delayAutoFinishUntilSignal` option was set to `true`.
318+
*/
319+
public sendAutoFinishSignal(): void {
320+
if (!this._autoFinishAllowed) {
321+
DEBUG_BUILD && logger.log('[Tracing] Received finish signal for idle transaction.');
322+
this._restartIdleTimeout();
323+
this._autoFinishAllowed = true;
324+
}
325+
}
326+
301327
/**
302328
* Restarts idle timeout, if there is no running idle timeout it will start one.
303329
*/
@@ -337,8 +363,10 @@ export class IdleTransaction extends Transaction {
337363
if (Object.keys(this.activities).length === 0) {
338364
const endTimestamp = timestampInSeconds();
339365
if (this._idleTimeoutCanceledPermanently) {
340-
this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[5];
341-
this.end(endTimestamp);
366+
if (this._autoFinishAllowed) {
367+
this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[5];
368+
this.end(endTimestamp);
369+
}
342370
} else {
343371
// We need to add the timeout here to have the real endtimestamp of the transaction
344372
// Remember timestampInSeconds is in seconds, timeout is in ms
@@ -368,10 +396,12 @@ export class IdleTransaction extends Transaction {
368396
this._prevHeartbeatString = heartbeatString;
369397

370398
if (this._heartbeatCounter >= 3) {
371-
DEBUG_BUILD && logger.log('[Tracing] Transaction finished because of no change for 3 heart beats');
372-
this.setStatus('deadline_exceeded');
373-
this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[0];
374-
this.end();
399+
if (this._autoFinishAllowed) {
400+
DEBUG_BUILD && logger.log('[Tracing] Transaction finished because of no change for 3 heart beats');
401+
this.setStatus('deadline_exceeded');
402+
this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[0];
403+
this.end();
404+
}
375405
} else {
376406
this._pingHeartbeat();
377407
}

packages/tracing-internal/src/browser/browsertracing.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,21 @@ export class BrowserTracing implements Integration {
368368
true,
369369
{ location }, // for use in the tracesSampler
370370
heartbeatInterval,
371+
isPageloadTransaction, // should wait for finish signal if it's a pageload transaction
371372
);
372373

374+
if (isPageloadTransaction) {
375+
WINDOW.document.addEventListener('readystatechange', () => {
376+
if (['interactive', 'complete'].includes(WINDOW.document.readyState)) {
377+
idleTransaction.sendAutoFinishSignal();
378+
}
379+
});
380+
381+
if (['interactive', 'complete'].includes(WINDOW.document.readyState)) {
382+
idleTransaction.sendAutoFinishSignal();
383+
}
384+
}
385+
373386
// eslint-disable-next-line deprecation/deprecation
374387
const scope = hub.getScope();
375388

packages/tracing-internal/test/browser/browsertracing.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
411411
expect.any(Boolean),
412412
expect.any(Object),
413413
expect.any(Number),
414+
true,
414415
);
415416
});
416417

@@ -419,6 +420,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
419420
createBrowserTracing(true, { routingInstrumentation: customInstrumentRouting });
420421
const mockFinish = jest.fn();
421422
const transaction = getActiveTransaction(hub) as IdleTransaction;
423+
transaction.sendAutoFinishSignal();
422424
transaction.end = mockFinish;
423425

424426
const span = transaction.startChild(); // activities = 1
@@ -433,6 +435,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
433435
createBrowserTracing(true, { idleTimeout: 2000, routingInstrumentation: customInstrumentRouting });
434436
const mockFinish = jest.fn();
435437
const transaction = getActiveTransaction(hub) as IdleTransaction;
438+
transaction.sendAutoFinishSignal();
436439
transaction.end = mockFinish;
437440

438441
const span = transaction.startChild(); // activities = 1
@@ -461,6 +464,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
461464
createBrowserTracing(true, { heartbeatInterval: interval, routingInstrumentation: customInstrumentRouting });
462465
const mockFinish = jest.fn();
463466
const transaction = getActiveTransaction(hub) as IdleTransaction;
467+
transaction.sendAutoFinishSignal();
464468
transaction.end = mockFinish;
465469

466470
const span = transaction.startChild(); // activities = 1

0 commit comments

Comments
 (0)