Skip to content

Commit f0f288d

Browse files
committed
feat: Create IdleTransaction class (#2720)
* feat: Adjust hub for idle transaction * feat: Add IdleTransaction class * test: IdleTransaction * ref: Some uneeded code * ref: Declare class variables in constructor * chore: Cleanup set() comments
1 parent 8baeea5 commit f0f288d

File tree

3 files changed

+573
-45
lines changed

3 files changed

+573
-45
lines changed

packages/tracing/src/hubextensions.ts

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { getMainCarrier, Hub } from '@sentry/hub';
2-
import { SpanContext, TransactionContext } from '@sentry/types';
3-
import { logger } from '@sentry/utils';
2+
import { TransactionContext } from '@sentry/types';
43

5-
import { Span } from './span';
4+
import { IdleTransaction } from './idletransaction';
65
import { Transaction } from './transaction';
76

87
/** Returns all trace headers that are currently on the top scope. */
@@ -20,13 +19,10 @@ function traceHeaders(this: Hub): { [key: string]: string } {
2019
}
2120

2221
/**
23-
* {@see Hub.startTransaction}
22+
* Use RNG to generate sampling decision, which all child spans inherit.
2423
*/
25-
function startTransaction(this: Hub, context: TransactionContext): Transaction {
26-
const transaction = new Transaction(context, this);
27-
28-
const client = this.getClient();
29-
// Roll the dice for sampling transaction, all child spans inherit the sampling decision.
24+
function sample<T extends Transaction>(hub: Hub, transaction: T): T {
25+
const client = hub.getClient();
3026
if (transaction.sampled === undefined) {
3127
const sampleRate = (client && client.getOptions().tracesSampleRate) || 0;
3228
// if true = we want to have the transaction
@@ -46,41 +42,24 @@ function startTransaction(this: Hub, context: TransactionContext): Transaction {
4642
}
4743

4844
/**
49-
* {@see Hub.startSpan}
45+
* {@see Hub.startTransaction}
5046
*/
51-
function startSpan(this: Hub, context: SpanContext): Transaction | Span {
52-
/**
53-
* @deprecated
54-
* TODO: consider removing this in a future release.
55-
*
56-
* This is for backwards compatibility with releases before startTransaction
57-
* existed, to allow for a smoother transition.
58-
*/
59-
{
60-
// The `TransactionContext.name` field used to be called `transaction`.
61-
const transactionContext = context as Partial<TransactionContext & { transaction: string }>;
62-
if (transactionContext.transaction !== undefined) {
63-
transactionContext.name = transactionContext.transaction;
64-
}
65-
// Check for not undefined since we defined it's ok to start a transaction
66-
// with an empty name.
67-
if (transactionContext.name !== undefined) {
68-
logger.warn('Deprecated: Use startTransaction to start transactions and Transaction.startChild to start spans.');
69-
return this.startTransaction(transactionContext as TransactionContext);
70-
}
71-
}
72-
73-
const scope = this.getScope();
74-
if (scope) {
75-
// If there is a Span on the Scope we start a child and return that instead
76-
const parentSpan = scope.getSpan();
77-
if (parentSpan) {
78-
return parentSpan.startChild(context);
79-
}
80-
}
47+
function startTransaction(this: Hub, context: TransactionContext): Transaction {
48+
const transaction = new Transaction(context, this);
49+
return sample(this, transaction);
50+
}
8151

82-
// Otherwise we return a new Span
83-
return new Span(context);
52+
/**
53+
* Create new idle transaction.
54+
*/
55+
export function startIdleTransaction(
56+
this: Hub,
57+
context: TransactionContext,
58+
idleTimeout?: number,
59+
onScope?: boolean,
60+
): IdleTransaction {
61+
const transaction = new IdleTransaction(context, this, idleTimeout, onScope);
62+
return sample(this, transaction);
8463
}
8564

8665
/**
@@ -93,9 +72,6 @@ export function addExtensionMethods(): void {
9372
if (!carrier.__SENTRY__.extensions.startTransaction) {
9473
carrier.__SENTRY__.extensions.startTransaction = startTransaction;
9574
}
96-
if (!carrier.__SENTRY__.extensions.startSpan) {
97-
carrier.__SENTRY__.extensions.startSpan = startSpan;
98-
}
9975
if (!carrier.__SENTRY__.extensions.traceHeaders) {
10076
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
10177
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// tslint:disable: max-classes-per-file
2+
import { Hub } from '@sentry/hub';
3+
import { TransactionContext } from '@sentry/types';
4+
import { logger, timestampWithMs } from '@sentry/utils';
5+
6+
import { Span } from './span';
7+
import { SpanStatus } from './spanstatus';
8+
import { SpanRecorder, Transaction } from './transaction';
9+
10+
const DEFAULT_IDLE_TIMEOUT = 1000;
11+
12+
/**
13+
* @inheritDoc
14+
*/
15+
export class IdleTransactionSpanRecorder extends SpanRecorder {
16+
public constructor(
17+
private readonly _pushActivity: (id: string) => void,
18+
private readonly _popActivity: (id: string) => void,
19+
public transactionSpanId: string = '',
20+
maxlen?: number,
21+
) {
22+
super(maxlen);
23+
}
24+
25+
/**
26+
* @inheritDoc
27+
*/
28+
public add(span: Span): void {
29+
// We should make sure we do not push and pop activities for
30+
// the transaction that this span recorder belongs to.
31+
if (span.spanId !== this.transactionSpanId) {
32+
// We patch span.finish() to pop an activity after setting an endTimestamp.
33+
span.finish = (endTimestamp?: number) => {
34+
span.endTimestamp = typeof endTimestamp === 'number' ? endTimestamp : timestampWithMs();
35+
this._popActivity(span.spanId);
36+
};
37+
38+
// We should only push new activities if the span does not have an end timestamp.
39+
if (span.endTimestamp === undefined) {
40+
this._pushActivity(span.spanId);
41+
}
42+
}
43+
44+
super.add(span);
45+
}
46+
}
47+
48+
/**
49+
* An IdleTransaction is a transaction that automatically finishes. It does this by tracking child spans as activities.
50+
* You can have multiple IdleTransactions active, but if the `onScope` option is specified, the idle transaction will
51+
* put itself on the scope on creation.
52+
*/
53+
export class IdleTransaction extends Transaction {
54+
// Activities store a list of active spans
55+
public activities: Record<string, boolean> = {};
56+
57+
// Stores reference to the timeout that calls _beat().
58+
private _heartbeatTimer: number = 0;
59+
60+
// Track state of activities in previous heartbeat
61+
private _prevHeartbeatString: string | undefined;
62+
63+
// Amount of times heartbeat has counted. Will cause transaction to finish after 3 beats.
64+
private _heartbeatCounter: number = 1;
65+
66+
// We should not use heartbeat if we finished a transaction
67+
private _finished: boolean = false;
68+
69+
private _finishCallback?: (transactionSpan: IdleTransaction) => void;
70+
71+
public constructor(
72+
transactionContext: TransactionContext,
73+
private readonly _idleHub?: Hub,
74+
// The time to wait in ms until the idle transaction will be finished. Default: 1000
75+
private readonly _idleTimeout: number = DEFAULT_IDLE_TIMEOUT,
76+
// If an idle transaction should be put itself on and off the scope automatically.
77+
private readonly _onScope: boolean = false,
78+
) {
79+
super(transactionContext, _idleHub);
80+
81+
if (_idleHub && _onScope) {
82+
// There should only be one active transaction on the scope
83+
clearActiveTransaction(_idleHub);
84+
85+
// We set the transaction here on the scope so error events pick up the trace
86+
// context and attach it to the error.
87+
logger.log(`Setting idle transaction on scope. Span ID: ${this.spanId}`);
88+
_idleHub.configureScope(scope => scope.setSpan(this));
89+
}
90+
}
91+
92+
/**
93+
* Checks when entries of this.activities are not changing for 3 beats.
94+
* If this occurs we finish the transaction.
95+
*/
96+
private _beat(): void {
97+
clearTimeout(this._heartbeatTimer);
98+
// We should not be running heartbeat if the idle transaction is finished.
99+
if (this._finished) {
100+
return;
101+
}
102+
103+
const keys = Object.keys(this.activities);
104+
const heartbeatString = keys.length ? keys.reduce((prev: string, current: string) => prev + current) : '';
105+
106+
if (heartbeatString === this._prevHeartbeatString) {
107+
this._heartbeatCounter++;
108+
} else {
109+
this._heartbeatCounter = 1;
110+
}
111+
112+
this._prevHeartbeatString = heartbeatString;
113+
114+
if (this._heartbeatCounter >= 3) {
115+
logger.log(
116+
`[Tracing] Transaction: ${
117+
SpanStatus.Cancelled
118+
} -> Heartbeat safeguard kicked in since content hasn't changed for 3 beats`,
119+
);
120+
this.setStatus(SpanStatus.DeadlineExceeded);
121+
this.setTag('heartbeat', 'failed');
122+
this.finishIdleTransaction(timestampWithMs());
123+
} else {
124+
this._pingHeartbeat();
125+
}
126+
}
127+
128+
/**
129+
* Pings the heartbeat
130+
*/
131+
private _pingHeartbeat(): void {
132+
logger.log(`pinging Heartbeat -> current counter: ${this._heartbeatCounter}`);
133+
this._heartbeatTimer = (setTimeout(() => {
134+
this._beat();
135+
}, 5000) as any) as number;
136+
}
137+
138+
/**
139+
* Finish the current active idle transaction
140+
*/
141+
public finishIdleTransaction(endTimestamp: number): void {
142+
if (this.spanRecorder) {
143+
logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op);
144+
145+
if (this._finishCallback) {
146+
this._finishCallback(this);
147+
}
148+
149+
this.spanRecorder.spans = this.spanRecorder.spans.filter((span: Span) => {
150+
// If we are dealing with the transaction itself, we just return it
151+
if (span.spanId === this.spanId) {
152+
return true;
153+
}
154+
155+
// We cancel all pending spans with status "cancelled" to indicate the idle transaction was finished early
156+
if (!span.endTimestamp) {
157+
span.endTimestamp = endTimestamp;
158+
span.setStatus(SpanStatus.Cancelled);
159+
logger.log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2));
160+
}
161+
162+
const keepSpan = span.startTimestamp < endTimestamp;
163+
if (!keepSpan) {
164+
logger.log(
165+
'[Tracing] discarding Span since it happened after Transaction was finished',
166+
JSON.stringify(span, undefined, 2),
167+
);
168+
}
169+
return keepSpan;
170+
});
171+
172+
this._finished = true;
173+
this.activities = {};
174+
// this._onScope is true if the transaction was previously on the scope.
175+
if (this._onScope) {
176+
clearActiveTransaction(this._idleHub);
177+
}
178+
179+
logger.log('[Tracing] flushing IdleTransaction');
180+
this.finish(endTimestamp);
181+
} else {
182+
logger.log('[Tracing] No active IdleTransaction');
183+
}
184+
}
185+
186+
/**
187+
* Start tracking a specific activity.
188+
* @param spanId The span id that represents the activity
189+
*/
190+
private _pushActivity(spanId: string): void {
191+
logger.log(`[Tracing] pushActivity: ${spanId}`);
192+
this.activities[spanId] = true;
193+
logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
194+
}
195+
196+
/**
197+
* Remove an activity from usage
198+
* @param spanId The span id that represents the activity
199+
*/
200+
private _popActivity(spanId: string): void {
201+
if (this.activities[spanId]) {
202+
logger.log(`[Tracing] popActivity ${spanId}`);
203+
// tslint:disable-next-line: no-dynamic-delete
204+
delete this.activities[spanId];
205+
logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
206+
}
207+
208+
if (Object.keys(this.activities).length === 0) {
209+
const timeout = this._idleTimeout;
210+
// We need to add the timeout here to have the real endtimestamp of the transaction
211+
// Remember timestampWithMs is in seconds, timeout is in ms
212+
const end = timestampWithMs() + timeout / 1000;
213+
214+
setTimeout(() => {
215+
this.finishIdleTransaction(end);
216+
}, timeout);
217+
}
218+
}
219+
220+
/**
221+
* Register a callback function that gets excecuted before the transaction finishes.
222+
* Useful for cleanup or if you want to add any additional spans based on current context.
223+
*
224+
* This is exposed because users have no other way of running something before an idle transaction
225+
* finishes.
226+
*/
227+
public beforeFinish(callback: (transactionSpan: IdleTransaction) => void): void {
228+
this._finishCallback = callback;
229+
}
230+
231+
/**
232+
* @inheritDoc
233+
*/
234+
public initSpanRecorder(maxlen?: number): void {
235+
if (!this.spanRecorder) {
236+
const pushActivity = (id: string) => {
237+
this._pushActivity(id);
238+
};
239+
const popActivity = (id: string) => {
240+
this._popActivity(id);
241+
};
242+
this.spanRecorder = new IdleTransactionSpanRecorder(pushActivity, popActivity, this.spanId, maxlen);
243+
244+
// Start heartbeat so that transactions do not run forever.
245+
logger.log('Starting heartbeat');
246+
this._pingHeartbeat();
247+
}
248+
this.spanRecorder.add(this);
249+
}
250+
}
251+
252+
/**
253+
* Reset active transaction on scope
254+
*/
255+
function clearActiveTransaction(hub?: Hub): void {
256+
if (hub) {
257+
const scope = hub.getScope();
258+
if (scope) {
259+
const transaction = scope.getTransaction();
260+
if (transaction) {
261+
scope.setSpan(undefined);
262+
}
263+
}
264+
}
265+
}

0 commit comments

Comments
 (0)