Skip to content

Commit 8b16378

Browse files
authored
ref(tracing): Reset IdleTimeout based on activities count (#5044)
Previously, when the activities count of an idle transaction hit 0, it would trigger a timeout for `idleTimeout` ms, and then always end the transaction. This means that if a span (like fetch/xhr request) started right after the activities count went to 0 (1 -> 0 -> 1), the transaction would always finish after `idleTimeout` ms, instead of waiting for the newest activity to finish. This was primarily done to prevent polling conditions and to not artificially inflate duration. Nowadays though, web vitals like LCP are a lot more important as a measurement in transactions than the strict duration (as with activities, they are a bit arbitrary). By making `idleTimeout` be strict about finish after activities go to 0, we often times would miss the LCP value that the browser would record, leading to many transactions not having proper LCPs. To get the LCP value close to browser quality as possible, we now reset the `idleTimeout` timeout if there are still existing activities. The concern here is with polling. To prevent polling issues, we now also have an additional `finalTimeout` that is started when the idle transaction is created. This double timeout system (alongside the heartbeat), should always enforce that the transaction is finished.
1 parent c9e3602 commit 8b16378

File tree

10 files changed

+149
-151
lines changed

10 files changed

+149
-151
lines changed

MIGRATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ For our efforts to reduce bundle size of the SDK we had to remove and refactor p
291291
- Rename `UserAgent` integration to `HttpContext`. (see [#5027](https://github.com/getsentry/sentry-javascript/pull/5027))
292292
- Remove `SDK_NAME` export from `@sentry/browser`, `@sentry/node`, `@sentry/tracing` and `@sentry/vue` packages.
293293
- Removed `eventStatusFromHttpCode` to save on bundle size.
294+
- Replace `BrowserTracing` `maxTransactionDuration` option with `finalTimeout` option
294295

295296
## Sentry Angular SDK Changes
296297

packages/integration-tests/suites/tracing/browsertracing/backgroundtab-custom/test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,5 @@ sentryTest('should finish a custom transaction when the page goes background', a
3636
expect(id_before).toBe(id_after);
3737
expect(name_after).toBe(name_before);
3838
expect(status_after).toBe('cancelled');
39-
expect(tags_after).toStrictEqual({ finishReason: 'documentHidden', visibilitychange: 'document.hidden' });
39+
expect(tags_after).toStrictEqual({ visibilitychange: 'document.hidden' });
4040
});

packages/integration-tests/suites/tracing/browsertracing/backgroundtab-pageload/test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,5 @@ sentryTest('should finish pageload transaction when the page goes background', a
1717
expect(pageloadTransaction.contexts?.trace.status).toBe('cancelled');
1818
expect(pageloadTransaction.contexts?.trace.tags).toMatchObject({
1919
visibilitychange: 'document.hidden',
20-
finishReason: 'documentHidden',
2120
});
2221
});

packages/tracing/src/browser/backgroundtab.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { getGlobalObject, logger } from '@sentry/utils';
22

3-
import { FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS } from '../constants';
43
import { IS_DEBUG_BUILD } from '../flags';
54
import { IdleTransaction } from '../idletransaction';
65
import { SpanStatusType } from '../span';
@@ -29,7 +28,6 @@ export function registerBackgroundTabDetection(): void {
2928
activeTransaction.setStatus(statusType);
3029
}
3130
activeTransaction.setTag('visibilitychange', 'document.hidden');
32-
activeTransaction.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[2]);
3331
activeTransaction.finish();
3432
}
3533
});

packages/tracing/src/browser/browsertracing.ts

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { getGlobalObject, logger } from '@sentry/utils';
44

55
import { IS_DEBUG_BUILD } from '../flags';
66
import { startIdleTransaction } from '../hubextensions';
7-
import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction';
8-
import { extractTraceparentData, secToMs } from '../utils';
7+
import { DEFAULT_FINAL_TIMEOUT, DEFAULT_IDLE_TIMEOUT } from '../idletransaction';
8+
import { extractTraceparentData } from '../utils';
99
import { registerBackgroundTabDetection } from './backgroundtab';
1010
import { MetricsInstrumentation } from './metrics';
1111
import {
@@ -15,19 +15,29 @@ import {
1515
} from './request';
1616
import { instrumentRoutingWithDefaults } from './router';
1717

18-
export const DEFAULT_MAX_TRANSACTION_DURATION_SECONDS = 600;
19-
2018
/** Options for Browser Tracing integration */
2119
export interface BrowserTracingOptions extends RequestInstrumentationOptions {
2220
/**
23-
* The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of
24-
* the last finished span as the endtime for the transaction.
21+
* The time to wait in ms until the transaction will be finished during an idle state. An idle state is defined
22+
* by a moment where there are no in-progress spans.
23+
*
24+
* The transaction will use the end timestamp of the last finished span as the endtime for the transaction.
25+
* If there are still active spans when this the `idleTimeout` is set, the `idleTimeout` will get reset.
2526
* Time is in ms.
2627
*
2728
* Default: 1000
2829
*/
2930
idleTimeout: number;
3031

32+
/**
33+
* The max duration for a transaction. If a transaction duration hits the `finalTimeout` value, it
34+
* will be finished.
35+
* Time is in ms.
36+
*
37+
* Default: 30000
38+
*/
39+
finalTimeout: number;
40+
3141
/**
3242
* Flag to enable/disable creation of `navigation` transaction on history changes.
3343
*
@@ -42,15 +52,6 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
4252
*/
4353
startTransactionOnPageLoad: boolean;
4454

45-
/**
46-
* The maximum duration of a transaction before it will be marked as "deadline_exceeded".
47-
* If you never want to mark a transaction set it to 0.
48-
* Time is in seconds.
49-
*
50-
* Default: 600
51-
*/
52-
maxTransactionDuration: number;
53-
5455
/**
5556
* Flag Transactions where tabs moved to background with "cancelled". Browser background tab timing is
5657
* not suited towards doing precise measurements of operations. By default, we recommend that this option
@@ -94,8 +95,8 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
9495

9596
const DEFAULT_BROWSER_TRACING_OPTIONS = {
9697
idleTimeout: DEFAULT_IDLE_TIMEOUT,
98+
finalTimeout: DEFAULT_FINAL_TIMEOUT,
9799
markBackgroundTransactions: true,
98-
maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS,
99100
routingInstrumentation: instrumentRoutingWithDefaults,
100101
startTransactionOnLocationChange: true,
101102
startTransactionOnPageLoad: true,
@@ -129,14 +130,10 @@ export class BrowserTracing implements Integration {
129130

130131
private readonly _emitOptionsWarning?: boolean;
131132

132-
/** Store configured idle timeout so that it can be added as a tag to transactions */
133-
private _configuredIdleTimeout: BrowserTracingOptions['idleTimeout'] | undefined = undefined;
134-
135133
public constructor(_options?: Partial<BrowserTracingOptions>) {
136134
let tracingOrigins = defaultRequestInstrumentationOptions.tracingOrigins;
137135
// NOTE: Logger doesn't work in constructors, as it's initialized after integrations instances
138136
if (_options) {
139-
this._configuredIdleTimeout = _options.idleTimeout;
140137
if (_options.tracingOrigins && Array.isArray(_options.tracingOrigins) && _options.tracingOrigins.length !== 0) {
141138
tracingOrigins = _options.tracingOrigins;
142139
} else {
@@ -205,7 +202,7 @@ export class BrowserTracing implements Integration {
205202
}
206203

207204
// eslint-disable-next-line @typescript-eslint/unbound-method
208-
const { beforeNavigate, idleTimeout, maxTransactionDuration } = this.options;
205+
const { beforeNavigate, idleTimeout, finalTimeout } = this.options;
209206

210207
const parentContextFromHeader = context.op === 'pageload' ? getHeaderContext() : undefined;
211208

@@ -233,16 +230,14 @@ export class BrowserTracing implements Integration {
233230
hub,
234231
finalContext,
235232
idleTimeout,
233+
finalTimeout,
236234
true,
237235
{ location }, // for use in the tracesSampler
238236
);
239-
idleTransaction.registerBeforeFinishCallback((transaction, endTimestamp) => {
237+
idleTransaction.registerBeforeFinishCallback(transaction => {
240238
this._metrics.addPerformanceEntries(transaction);
241-
adjustTransactionDuration(secToMs(maxTransactionDuration), transaction, endTimestamp);
242239
});
243240

244-
idleTransaction.setTag('idleTimeout', this._configuredIdleTimeout);
245-
246241
return idleTransaction as Transaction;
247242
}
248243
}
@@ -266,13 +261,3 @@ export function getMetaContent(metaName: string): string | null {
266261
const el = getGlobalObject<Window>().document.querySelector(`meta[name=${metaName}]`);
267262
return el ? el.getAttribute('content') : null;
268263
}
269-
270-
/** Adjusts transaction value based on max transaction duration */
271-
function adjustTransactionDuration(maxDuration: number, transaction: IdleTransaction, endTimestamp: number): void {
272-
const diff = endTimestamp - transaction.startTimestamp;
273-
const isOutdatedTransaction = endTimestamp && (diff > maxDuration || diff < 0);
274-
if (isOutdatedTransaction) {
275-
transaction.setStatus('deadline_exceeded');
276-
transaction.setTag('maxTransactionDurationExceeded', 'true');
277-
}
278-
}

packages/tracing/src/constants.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

packages/tracing/src/hubextensions.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import { getMainCarrier, Hub } from '@sentry/hub';
23
import {
34
ClientOptions,
@@ -196,14 +197,15 @@ function _startTransaction(
196197
export function startIdleTransaction(
197198
hub: Hub,
198199
transactionContext: TransactionContext,
199-
idleTimeout?: number,
200+
idleTimeout: number,
201+
finalTimeout: number,
200202
onScope?: boolean,
201203
customSamplingContext?: CustomSamplingContext,
202204
): IdleTransaction {
203205
const client = hub.getClient();
204206
const options: Partial<ClientOptions> = (client && client.getOptions()) || {};
205207

206-
let transaction = new IdleTransaction(transactionContext, hub, idleTimeout, onScope);
208+
let transaction = new IdleTransaction(transactionContext, hub, idleTimeout, finalTimeout, onScope);
207209
transaction = sample(transaction, options, {
208210
parentSampled: transactionContext.parentSampled,
209211
transactionContext,

packages/tracing/src/idletransaction.ts

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
/* eslint-disable max-lines */
12
import { Hub } from '@sentry/hub';
23
import { TransactionContext } from '@sentry/types';
34
import { logger, timestampWithMs } from '@sentry/utils';
45

5-
import { FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS } from './constants';
66
import { IS_DEBUG_BUILD } from './flags';
77
import { Span, SpanRecorder } from './span';
88
import { Transaction } from './transaction';
99

1010
export const DEFAULT_IDLE_TIMEOUT = 1000;
11+
export const DEFAULT_FINAL_TIMEOUT = 30000;
1112
export const HEARTBEAT_INTERVAL = 5000;
1213

1314
/**
@@ -17,7 +18,7 @@ export class IdleTransactionSpanRecorder extends SpanRecorder {
1718
public constructor(
1819
private readonly _pushActivity: (id: string) => void,
1920
private readonly _popActivity: (id: string) => void,
20-
public transactionSpanId: string = '',
21+
public transactionSpanId: string,
2122
maxlen?: number,
2223
) {
2324
super(maxlen);
@@ -69,25 +70,28 @@ export class IdleTransaction extends Transaction {
6970
private readonly _beforeFinishCallbacks: BeforeFinishCallback[] = [];
7071

7172
/**
72-
* If a transaction is created and no activities are added, we want to make sure that
73-
* it times out properly. This is cleared and not used when activities are added.
73+
* Timer that tracks Transaction idleTimeout
7474
*/
75-
private _initTimeout: ReturnType<typeof setTimeout> | undefined;
75+
private _idleTimeoutID: ReturnType<typeof setTimeout> | undefined;
7676

7777
public constructor(
7878
transactionContext: TransactionContext,
79-
private readonly _idleHub?: Hub,
79+
private readonly _idleHub: Hub,
8080
/**
81-
* The time to wait in ms until the idle transaction will be finished.
82-
* @default 1000
81+
* The time to wait in ms until the idle transaction will be finished. This timer is started each time
82+
* there are no active spans on this transaction.
8383
*/
8484
private readonly _idleTimeout: number = DEFAULT_IDLE_TIMEOUT,
85+
/**
86+
* The final value in ms that a transaction cannot exceed
87+
*/
88+
private readonly _finalTimeout: number = DEFAULT_FINAL_TIMEOUT,
8589
// Whether or not the transaction should put itself on the scope when it starts and pop itself off when it ends
8690
private readonly _onScope: boolean = false,
8791
) {
8892
super(transactionContext, _idleHub);
8993

90-
if (_idleHub && _onScope) {
94+
if (_onScope) {
9195
// There should only be one active transaction on the scope
9296
clearActiveTransaction(_idleHub);
9397

@@ -97,11 +101,13 @@ export class IdleTransaction extends Transaction {
97101
_idleHub.configureScope(scope => scope.setSpan(this));
98102
}
99103

100-
this._initTimeout = setTimeout(() => {
104+
this._startIdleTimeout();
105+
setTimeout(() => {
101106
if (!this._finished) {
107+
this.setStatus('deadline_exceeded');
102108
this.finish();
103109
}
104-
}, this._idleTimeout);
110+
}, this._finalTimeout);
105111
}
106112

107113
/** {@inheritDoc} */
@@ -193,15 +199,34 @@ export class IdleTransaction extends Transaction {
193199
this.spanRecorder.add(this);
194200
}
195201

202+
/**
203+
* Cancels the existing idletimeout, if there is one
204+
*/
205+
private _cancelIdleTimeout(): void {
206+
if (this._idleTimeoutID) {
207+
clearTimeout(this._idleTimeoutID);
208+
this._idleTimeoutID = undefined;
209+
}
210+
}
211+
212+
/**
213+
* Creates an idletimeout
214+
*/
215+
private _startIdleTimeout(endTimestamp?: Parameters<IdleTransaction['finish']>[0]): void {
216+
this._cancelIdleTimeout();
217+
this._idleTimeoutID = setTimeout(() => {
218+
if (!this._finished && Object.keys(this.activities).length === 0) {
219+
this.finish(endTimestamp);
220+
}
221+
}, this._idleTimeout);
222+
}
223+
196224
/**
197225
* Start tracking a specific activity.
198226
* @param spanId The span id that represents the activity
199227
*/
200228
private _pushActivity(spanId: string): void {
201-
if (this._initTimeout) {
202-
clearTimeout(this._initTimeout);
203-
this._initTimeout = undefined;
204-
}
229+
this._cancelIdleTimeout();
205230
IS_DEBUG_BUILD && logger.log(`[Tracing] pushActivity: ${spanId}`);
206231
this.activities[spanId] = true;
207232
IS_DEBUG_BUILD && logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
@@ -220,17 +245,10 @@ export class IdleTransaction extends Transaction {
220245
}
221246

222247
if (Object.keys(this.activities).length === 0) {
223-
const timeout = this._idleTimeout;
224248
// We need to add the timeout here to have the real endtimestamp of the transaction
225249
// Remember timestampWithMs is in seconds, timeout is in ms
226-
const end = timestampWithMs() + timeout / 1000;
227-
228-
setTimeout(() => {
229-
if (!this._finished) {
230-
this.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[1]);
231-
this.finish(end);
232-
}
233-
}, timeout);
250+
const endTimestamp = timestampWithMs() + this._idleTimeout / 1000;
251+
this._startIdleTimeout(endTimestamp);
234252
}
235253
}
236254

@@ -257,7 +275,6 @@ export class IdleTransaction extends Transaction {
257275
if (this._heartbeatCounter >= 3) {
258276
IS_DEBUG_BUILD && logger.log('[Tracing] Transaction finished because of no change for 3 heart beats');
259277
this.setStatus('deadline_exceeded');
260-
this.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[0]);
261278
this.finish();
262279
} else {
263280
this._pingHeartbeat();
@@ -278,14 +295,12 @@ export class IdleTransaction extends Transaction {
278295
/**
279296
* Reset transaction on scope to `undefined`
280297
*/
281-
function clearActiveTransaction(hub?: Hub): void {
282-
if (hub) {
283-
const scope = hub.getScope();
284-
if (scope) {
285-
const transaction = scope.getTransaction();
286-
if (transaction) {
287-
scope.setSpan(undefined);
288-
}
298+
function clearActiveTransaction(hub: Hub): void {
299+
const scope = hub.getScope();
300+
if (scope) {
301+
const transaction = scope.getTransaction();
302+
if (transaction) {
303+
scope.setSpan(undefined);
289304
}
290305
}
291306
}

0 commit comments

Comments
 (0)