Skip to content

feat(tracing): Reset IdleTimeout based on activities count #4531

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 1, 2022
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ sentryTest('should finish a custom transaction when the page goes background', a
expect(id_before).toBe(id_after);
expect(name_after).toBe(name_before);
expect(status_after).toBe('cancelled');
expect(tags_after).toStrictEqual({ finishReason: 'documentHidden', visibilitychange: 'document.hidden' });
expect(tags_after).toStrictEqual({ visibilitychange: 'document.hidden' });
});
4 changes: 2 additions & 2 deletions packages/tracing/src/browser/backgroundtab.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getGlobalObject, logger } from '@sentry/utils';

import { FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS } from '../constants';
import { IDLE_TRANSACTION_FINISH_REASONS } from '../constants';
import { IdleTransaction } from '../idletransaction';
import { SpanStatusType } from '../span';
import { getActiveTransaction } from '../utils';
Expand All @@ -27,7 +27,7 @@ export function registerBackgroundTabDetection(): void {
activeTransaction.setStatus(statusType);
}
activeTransaction.setTag('visibilitychange', 'document.hidden');
activeTransaction.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[2]);
activeTransaction.finishReason = IDLE_TRANSACTION_FINISH_REASONS[2]; /* 'documentHidden' */
activeTransaction.finish();
}
});
Expand Down
14 changes: 13 additions & 1 deletion packages/tracing/src/browser/browsertracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EventProcessor, Integration, Transaction, TransactionContext } from '@s
import { getGlobalObject, logger } from '@sentry/utils';

import { startIdleTransaction } from '../hubextensions';
import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction';
import { DEFAULT_FINAL_TIMEOUT, DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction';
import { extractTraceparentData, secToMs } from '../utils';
import { registerBackgroundTabDetection } from './backgroundtab';
import { MetricsInstrumentation } from './metrics';
Expand All @@ -21,12 +21,23 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
/**
* The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of
* the last finished span as the endtime for the transaction.
*
* Time is in ms.
*
* Default: 1000
*/
idleTimeout: number;

/**
* The max duration for a transaction. If a transaction duration hits the `finalTimeout` value, it
* will be finished.
*
* Time is in ms.
*
* Default: 30000
*/
finalTimeout: number;

/**
* Flag to enable/disable creation of `navigation` transaction on history changes.
*
Expand Down Expand Up @@ -93,6 +104,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {

const DEFAULT_BROWSER_TRACING_OPTIONS = {
idleTimeout: DEFAULT_IDLE_TIMEOUT,
finalTimeout: DEFAULT_FINAL_TIMEOUT,
markBackgroundTransactions: true,
maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS,
routingInstrumentation: instrumentRoutingWithDefaults,
Expand Down
10 changes: 8 additions & 2 deletions packages/tracing/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// Store finish reasons in tuple to save on bundle size
// Readonly type should enforce that this is not mutated.
export const FINISH_REASON_TAG = 'finishReason';
export const FINISH_REASON_TAG = 'finishReason' as const;

export const IDLE_TRANSACTION_FINISH_REASONS = ['heartbeatFailed', 'idleTimeout', 'documentHidden'] as const;
export const IDLE_TRANSACTION_FINISH_REASONS = [
'heartbeatFailed',
'idleTimeout',
'documentHidden',
'finalTimeout',
Copy link
Contributor

@snoozbuster snoozbuster Feb 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given that this shows up in the UI, and I as a user of sentry would probably not understand what this means if I hadn't already found this issue and read this PR, does it make more sense to name this something that indicates it's a forced timeout due to thrashing? hard to come up with a concise name but maybe something like thrashedSpanTimeout or timeoutInfinitePoll? (idleTimeout was a similarly confusing name to me, it's not obvious that's the sort of "intended finish reason" for normal transactions, but realize that probably can't be changed anymore)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch.

Its behaviour seems like as the maxTransactionDuration configuration.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to re-use that configuration option here then (as opposed to creating a new one), and similarly re-use the "deadline_exceeded" finishReason? or is there value in having them both be separate? I guess a default timeout of 600 seconds is pretty excessive for this particular use case...

'externalFinish',
] as const;
77 changes: 59 additions & 18 deletions packages/tracing/src/idletransaction.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/* eslint-disable max-lines */
import { Hub } from '@sentry/hub';
import { TransactionContext } from '@sentry/types';
import { logger, timestampWithMs } from '@sentry/utils';
import { getGlobalObject, logger, timestampWithMs } from '@sentry/utils';

import { FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS } from './constants';
import { Span, SpanRecorder } from './span';
import { Transaction } from './transaction';

export const DEFAULT_IDLE_TIMEOUT = 1000;
export const DEFAULT_FINAL_TIMEOUT = 30000;
export const HEARTBEAT_INTERVAL = 5000;

const global = getGlobalObject<Window>();

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -56,6 +60,14 @@ export class IdleTransaction extends Transaction {
// Activities store a list of active spans
public activities: Record<string, boolean> = {};

/**
*
* Defaults to `externalFinish`, which means transaction.finish() was called outside of the idle transaction (for example,
* by a navigation transaction ending the previous pageload/navigation in some routing instrumentation).
* @default 'externalFinish'
*/
public finishReason: typeof IDLE_TRANSACTION_FINISH_REASONS[number] = IDLE_TRANSACTION_FINISH_REASONS[4];

// Track state of activities in previous heartbeat
private _prevHeartbeatString: string | undefined;

Expand All @@ -71,18 +83,29 @@ export class IdleTransaction extends Transaction {
* If a transaction is created and no activities are added, we want to make sure that
* it times out properly. This is cleared and not used when activities are added.
*/
private _initTimeout: ReturnType<typeof setTimeout> | undefined;
private _idleTimeoutID: ReturnType<typeof global.setTimeout> | undefined;

public constructor(
transactionContext: TransactionContext,
private readonly _idleHub?: Hub,
/**
* The time to wait in ms until the idle transaction will be finished.
* @default 1000
*
* TODO: Make _idleTimeout and _finalTimeout required to reduce duplication when setting the options
* in `BrowserTracing`. This is considered a breaking change to the IdleTransaction API,
* so we need to make sure we communicate it with react native.
*/
private readonly _idleTimeout: number = DEFAULT_IDLE_TIMEOUT,
// Whether or not the transaction should put itself on the scope when it starts and pop itself off when it ends
private readonly _onScope: boolean = false,
/**
* The final value that a transaction cannot exceed
* @default 30000
* @experimental
* @internal
*/
private readonly _finalTimeout: number = DEFAULT_FINAL_TIMEOUT,
) {
super(transactionContext, _idleHub);

Expand All @@ -96,18 +119,22 @@ export class IdleTransaction extends Transaction {
_idleHub.configureScope(scope => scope.setSpan(this));
}

this._initTimeout = setTimeout(() => {
this._startIdleTimeout();
global.setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be called on global?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it less likely to fail in the future - so I set it this way.

if (!this._finished) {
this.finishReason = IDLE_TRANSACTION_FINISH_REASONS[3]; /* 'finalTimeout' */
this.finish();
}
}, this._idleTimeout);
}, this._finalTimeout);
}

/** {@inheritDoc} */
public finish(endTimestamp: number = timestampWithMs()): string | undefined {
this._finished = true;
this.activities = {};

this.setTag(FINISH_REASON_TAG, this.finishReason);

if (this.spanRecorder) {
logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op);

Expand Down Expand Up @@ -189,15 +216,35 @@ export class IdleTransaction extends Transaction {
this.spanRecorder.add(this);
}

/**
* Cancels the existing idletimeout, if there is one
*/
private _cancelIdleTimeout(): void {
if (this._idleTimeoutID) {
global.clearTimeout(this._idleTimeoutID);
this._idleTimeoutID = undefined;
}
}

/**
* Creates an idletimeout
*/
private _startIdleTimeout(endTimestamp?: Parameters<IdleTransaction['finish']>[0]): void {
this._cancelIdleTimeout();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this._idleTimeoutID = global.setTimeout(() => {
if (!this._finished && Object.keys(this.activities).length === 0) {
this.finishReason = IDLE_TRANSACTION_FINISH_REASONS[1]; /* 'idleTimeout' */
this.finish(endTimestamp);
}
}, this._idleTimeout);
}

/**
* Start tracking a specific activity.
* @param spanId The span id that represents the activity
*/
private _pushActivity(spanId: string): void {
if (this._initTimeout) {
clearTimeout(this._initTimeout);
this._initTimeout = undefined;
}
this._cancelIdleTimeout();
logger.log(`[Tracing] pushActivity: ${spanId}`);
this.activities[spanId] = true;
logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
Expand All @@ -219,14 +266,8 @@ export class IdleTransaction extends Transaction {
const timeout = this._idleTimeout;
// We need to add the timeout here to have the real endtimestamp of the transaction
// Remember timestampWithMs is in seconds, timeout is in ms
const end = timestampWithMs() + timeout / 1000;

setTimeout(() => {
if (!this._finished) {
this.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[1]);
this.finish(end);
}
}, timeout);
const endTimestamp = timestampWithMs() + timeout / 1000;
this._startIdleTimeout(endTimestamp);
}
}

Expand All @@ -253,7 +294,7 @@ export class IdleTransaction extends Transaction {
if (this._heartbeatCounter >= 3) {
logger.log(`[Tracing] Transaction finished because of no change for 3 heart beats`);
this.setStatus('deadline_exceeded');
this.setTag(FINISH_REASON_TAG, IDLE_TRANSACTION_FINISH_REASONS[0]);
this.finishReason = IDLE_TRANSACTION_FINISH_REASONS[0]; /* 'heartbeatFailed' */
this.finish();
} else {
this._pingHeartbeat();
Expand All @@ -265,7 +306,7 @@ export class IdleTransaction extends Transaction {
*/
private _pingHeartbeat(): void {
logger.log(`pinging Heartbeat -> current counter: ${this._heartbeatCounter}`);
setTimeout(() => {
global.setTimeout(() => {
this._beat();
}, HEARTBEAT_INTERVAL);
}
Expand Down
9 changes: 4 additions & 5 deletions packages/tracing/test/browser/browsertracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MetricsInstrumentation } from '../../src/browser/metrics';
import { defaultRequestInstrumentationOptions } from '../../src/browser/request';
import { instrumentRoutingWithDefaults } from '../../src/browser/router';
import * as hubExtensions from '../../src/hubextensions';
import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../../src/idletransaction';
import { DEFAULT_FINAL_TIMEOUT, DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../../src/idletransaction';
import { getActiveTransaction, secToMs } from '../../src/utils';

let mockChangeHistory: ({ to, from }: { to: string; from?: string }) => void = () => undefined;
Expand Down Expand Up @@ -83,6 +83,7 @@ describe('BrowserTracing', () => {

expect(browserTracing.options).toEqual({
idleTimeout: DEFAULT_IDLE_TIMEOUT,
finalTimeout: DEFAULT_FINAL_TIMEOUT,
markBackgroundTransactions: true,
maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS,
routingInstrumentation: instrumentRoutingWithDefaults,
Expand Down Expand Up @@ -235,9 +236,8 @@ describe('BrowserTracing', () => {
describe('idleTimeout', () => {
it('is created by default', () => {
createBrowserTracing(true, { routingInstrumentation: customInstrumentRouting });
const mockFinish = jest.fn();
const transaction = getActiveTransaction(hub) as IdleTransaction;
transaction.finish = mockFinish;
const mockFinish = jest.spyOn(transaction, 'finish');

const span = transaction.startChild(); // activities = 1
span.finish(); // activities = 0
Expand All @@ -251,9 +251,8 @@ describe('BrowserTracing', () => {

it('can be a custom value', () => {
createBrowserTracing(true, { idleTimeout: 2000, routingInstrumentation: customInstrumentRouting });
const mockFinish = jest.fn();
const transaction = getActiveTransaction(hub) as IdleTransaction;
transaction.finish = mockFinish;
const mockFinish = jest.spyOn(transaction, 'finish');

const span = transaction.startChild(); // activities = 1
span.finish(); // activities = 0
Expand Down
37 changes: 23 additions & 14 deletions packages/tracing/test/idletransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ import {
} from '../src/idletransaction';
import { Span } from '../src/span';

export class SimpleTransport extends Transports.BaseTransport {}
type TransportSendRequest = Transports.BaseTransport['_sendRequest'];

export class SimpleTransport extends Transports.BaseTransport {
protected _sendRequest(
_req: Parameters<TransportSendRequest>[0],
_payload: Parameters<TransportSendRequest>[1],
): ReturnType<TransportSendRequest> {
throw new Error('Method not implemented.');
}
}

const dsn = 'https://[email protected]/42';
let hub: Hub;
Expand Down Expand Up @@ -103,7 +112,7 @@ describe('IdleTransaction', () => {

expect(transaction.activities).toMatchObject({ [span.spanId]: true, [childSpan.spanId]: true });
span.finish();
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT + 1);

expect(mockFinish).toHaveBeenCalledTimes(0);
expect(transaction.activities).toMatchObject({ [childSpan.spanId]: true });
Expand Down Expand Up @@ -229,63 +238,63 @@ describe('IdleTransaction', () => {
transaction.startChild({});

// Beat 1
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 2
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 3
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(1);
});

it('resets after new activities are added', () => {
const transaction = new IdleTransaction({ name: 'foo' }, hub, DEFAULT_IDLE_TIMEOUT);
const transaction = new IdleTransaction({ name: 'foo' }, hub, DEFAULT_IDLE_TIMEOUT, false, 50000);
const mockFinish = jest.spyOn(transaction, 'finish');
transaction.initSpanRecorder(10);

expect(mockFinish).toHaveBeenCalledTimes(0);
transaction.startChild({});

// Beat 1
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

const span = transaction.startChild(); // push activity

// Beat 1
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 2
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

transaction.startChild(); // push activity
transaction.startChild(); // push activity

// Beat 1
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 2
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

span.finish(); // pop activity

// Beat 1
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 2
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(0);

// Beat 3
jest.runOnlyPendingTimers();
jest.advanceTimersByTime(HEARTBEAT_INTERVAL);
expect(mockFinish).toHaveBeenCalledTimes(1);

// Heartbeat does not keep going after finish has been called
Expand Down