Skip to content
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

feat: Create IdleTransaction class #2720

Merged
merged 6 commits into from
Jul 6, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 21 additions & 45 deletions packages/tracing/src/hubextensions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { getMainCarrier, Hub } from '@sentry/hub';
import { SpanContext, TransactionContext } from '@sentry/types';
import { logger } from '@sentry/utils';
import { TransactionContext } from '@sentry/types';

import { Span } from './span';
import { IdleTransaction } from './idletransaction';
import { Transaction } from './transaction';

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

/**
* {@see Hub.startTransaction}
* Use RNG to generate sampling decision, which all child spans inherit.
*/
function startTransaction(this: Hub, context: TransactionContext): Transaction {
const transaction = new Transaction(context, this);

const client = this.getClient();
// Roll the dice for sampling transaction, all child spans inherit the sampling decision.
function sample<T extends Transaction>(hub: Hub, transaction: T): T {
const client = hub.getClient();
if (transaction.sampled === undefined) {
const sampleRate = (client && client.getOptions().tracesSampleRate) || 0;
// if true = we want to have the transaction
Expand All @@ -46,41 +42,24 @@ function startTransaction(this: Hub, context: TransactionContext): Transaction {
}

/**
* {@see Hub.startSpan}
* {@see Hub.startTransaction}
*/
function startSpan(this: Hub, context: SpanContext): Transaction | Span {
/**
* @deprecated
* TODO: consider removing this in a future release.
*
* This is for backwards compatibility with releases before startTransaction
* existed, to allow for a smoother transition.
*/
{
// The `TransactionContext.name` field used to be called `transaction`.
const transactionContext = context as Partial<TransactionContext & { transaction: string }>;
if (transactionContext.transaction !== undefined) {
transactionContext.name = transactionContext.transaction;
}
// Check for not undefined since we defined it's ok to start a transaction
// with an empty name.
if (transactionContext.name !== undefined) {
logger.warn('Deprecated: Use startTransaction to start transactions and Transaction.startChild to start spans.');
return this.startTransaction(transactionContext as TransactionContext);
}
}

const scope = this.getScope();
if (scope) {
// If there is a Span on the Scope we start a child and return that instead
const parentSpan = scope.getSpan();
if (parentSpan) {
return parentSpan.startChild(context);
}
}
function startTransaction(this: Hub, context: TransactionContext): Transaction {
const transaction = new Transaction(context, this);
return sample(this, transaction);
}

// Otherwise we return a new Span
return new Span(context);
/**
* Create new idle transaction.
*/
export function startIdleTransaction(
this: Hub,
context: TransactionContext,
idleTimeout?: number,
onScope?: boolean,
): IdleTransaction {
const transaction = new IdleTransaction(context, this, idleTimeout, onScope);
return sample(this, transaction);
}

/**
Expand All @@ -93,9 +72,6 @@ export function addExtensionMethods(): void {
if (!carrier.__SENTRY__.extensions.startTransaction) {
carrier.__SENTRY__.extensions.startTransaction = startTransaction;
}
if (!carrier.__SENTRY__.extensions.startSpan) {
carrier.__SENTRY__.extensions.startSpan = startSpan;
}
if (!carrier.__SENTRY__.extensions.traceHeaders) {
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
}
Expand Down
284 changes: 284 additions & 0 deletions packages/tracing/src/idletransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// tslint:disable: max-classes-per-file
import { Hub } from '@sentry/hub';
import { TransactionContext } from '@sentry/types';
import { logger, timestampWithMs } from '@sentry/utils';

import { Span } from './span';
import { SpanStatus } from './spanstatus';
import { SpanRecorder, Transaction } from './transaction';

const DEFAULT_IDLE_TIMEOUT = 1000;

/**
* @inheritDoc
*/
export class IdleTransactionSpanRecorder extends SpanRecorder {
private readonly _pushActivity: (id: string) => void;
private readonly _popActivity: (id: string) => void;
public transactionSpanId: string = '';

public constructor(
pushActivity: (id: string) => void,
popActivity: (id: string) => void,
transactionSpanId: string = '',
maxlen?: number,
) {
AbhiPrasad marked this conversation as resolved.
Show resolved Hide resolved
super(maxlen);
this._pushActivity = pushActivity;
this._popActivity = popActivity;
this.transactionSpanId = transactionSpanId;
}

/**
* @inheritDoc
*/
public add(span: Span): void {
// We should make sure we do not push and pop activities for
// the transaction that this span recorder belongs to.
if (span.spanId !== this.transactionSpanId) {
// We patch span.finish() to pop an activity after setting an endTimestamp.
span.finish = (endTimestamp?: number) => {
span.endTimestamp = typeof endTimestamp === 'number' ? endTimestamp : timestampWithMs();
this._popActivity(span.spanId);
};

// We should only push new activities if the span does not have an end timestamp.
if (span.endTimestamp === undefined) {
this._pushActivity(span.spanId);
}
}

super.add(span);
}
}

/**
* An IdleTransaction is a transaction that automatically finishes. It does this by tracking child spans as activities.
* You can have multiple IdleTransactions active, but if the `onScope` option is specified, the idle transaction will
* put itself on the scope on creation.
*/
export class IdleTransaction extends Transaction {
// Activities store a list of active spans
// TODO: Can we use `Set()` here?
public activities: Record<string, boolean> = {};
Copy link
Member Author

Choose a reason for hiding this comment

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

@kamilogorek can we use es6 Set() in this codebase? - or do you think we should stay away from this and just use regular JS objects.

Copy link
Member

Choose a reason for hiding this comment

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

Let's use object, Set is only support IE 11


// Stores reference to the timeout that calls _beat().
private _heartbeatTimer: number = 0;

// Track state of activities in previous heartbeat
// TODO: If we use sets, this can just be a set, then we can do
private _prevHeartbeatString: string | undefined;

// Amount of times heartbeat has counted. Will cause transaction to finish after 3 beats.
private _heartbeatCounter: number = 1;

// The time to wait in ms until the idle transaction will be finished. Default: 1000
private readonly _idleTimeout: number = DEFAULT_IDLE_TIMEOUT;

// If an idle transaction should be put itself on and off the scope automatically.
private readonly _onScope: boolean = false;

private readonly _idleHub?: Hub;

// We should not use heartbeat if we finished a transaction
private _finished: boolean = false;

private _finishCallback?: (transactionSpan: IdleTransaction) => void;

public constructor(
transactionContext: TransactionContext,
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here for some of parameters

hub?: Hub,
idleTimeout: number = DEFAULT_IDLE_TIMEOUT,
onScope: boolean = false,
) {
super(transactionContext, hub);

this._idleTimeout = idleTimeout;
this._idleHub = hub;
this._onScope = onScope;

if (hub && onScope) {
// There should only be one active transaction on the scope
clearActiveTransaction(hub);

// We set the transaction here on the scope so error events pick up the trace
// context and attach it to the error.
logger.log(`Setting idle transaction on scope. Span ID: ${this.spanId}`);
hub.configureScope(scope => scope.setSpan(this));
}
}

/**
* Checks when entries of this.activities are not changing for 3 beats.
* If this occurs we finish the transaction.
*/
private _beat(): void {
clearTimeout(this._heartbeatTimer);
// We should not be running heartbeat if the idle transaction is finished.
if (this._finished) {
return;
}

const keys = Object.keys(this.activities);
const heartbeatString = keys.length ? keys.reduce((prev: string, current: string) => prev + current) : '';

if (heartbeatString === this._prevHeartbeatString) {
this._heartbeatCounter++;
} else {
this._heartbeatCounter = 1;
}

this._prevHeartbeatString = heartbeatString;

if (this._heartbeatCounter >= 3) {
logger.log(
`[Tracing] Transaction: ${
SpanStatus.Cancelled
} -> Heartbeat safeguard kicked in since content hasn't changed for 3 beats`,
);
this.setStatus(SpanStatus.DeadlineExceeded);
this.setTag('heartbeat', 'failed');
this.finishIdleTransaction(timestampWithMs());
} else {
this._pingHeartbeat();
}
}

/**
* Pings the heartbeat
*/
private _pingHeartbeat(): void {
logger.log(`pinging Heartbeat -> current counter: ${this._heartbeatCounter}`);
this._heartbeatTimer = (setTimeout(() => {
this._beat();
}, 5000) as any) as number;
}

/**
* Finish the current active idle transaction
*/
public finishIdleTransaction(endTimestamp: number): void {
if (this.spanRecorder) {
logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op);

if (this._finishCallback) {
this._finishCallback(this);
}

this.spanRecorder.spans = this.spanRecorder.spans.filter((span: Span) => {
// If we are dealing with the transaction itself, we just return it
if (span.spanId === this.spanId) {
return true;
}

// We cancel all pending spans with status "cancelled" to indicate the idle transaction was finished early
if (!span.endTimestamp) {
span.endTimestamp = endTimestamp;
span.setStatus(SpanStatus.Cancelled);
logger.log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2));
}

const keepSpan = span.startTimestamp < endTimestamp;
if (!keepSpan) {
logger.log(
'[Tracing] discarding Span since it happened after Transaction was finished',
JSON.stringify(span, undefined, 2),
);
}
return keepSpan;
});

this._finished = true;
this.activities = {};
// this._onScope is true if the transaction was previously on the scope.
if (this._onScope) {
clearActiveTransaction(this._idleHub);
}

logger.log('[Tracing] flushing IdleTransaction');
this.finish(endTimestamp);
} else {
logger.log('[Tracing] No active IdleTransaction');
}
}

/**
* Start tracking a specific activity.
* @param spanId The span id that represents the activity
*/
private _pushActivity(spanId: string): void {
logger.log(`[Tracing] pushActivity: ${spanId}`);
this.activities[spanId] = true;
logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
}

/**
* Remove an activity from usage
* @param spanId The span id that represents the activity
*/
private _popActivity(spanId: string): void {
if (this.activities[spanId]) {
logger.log(`[Tracing] popActivity ${spanId}`);
// tslint:disable-next-line: no-dynamic-delete
delete this.activities[spanId];
logger.log('[Tracing] new activities count', Object.keys(this.activities).length);
}

if (Object.keys(this.activities).length === 0) {
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(() => {
this.finishIdleTransaction(end);
}, timeout);
}
}

/**
* Register a callback function that gets excecuted before the transaction finishes.
* Useful for cleanup or if you want to add any additional spans based on current context.
*
* This is exposed because users have no other way of running something before an idle transaction
* finishes.
*/
public beforeFinish(callback: (transactionSpan: IdleTransaction) => void): void {
this._finishCallback = callback;
}

/**
* @inheritDoc
*/
public initSpanRecorder(maxlen?: number): void {
if (!this.spanRecorder) {
const pushActivity = (id: string) => {
this._pushActivity(id);
};
const popActivity = (id: string) => {
this._popActivity(id);
};
this.spanRecorder = new IdleTransactionSpanRecorder(pushActivity, popActivity, this.spanId, maxlen);
Copy link
Contributor

Choose a reason for hiding this comment

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

Passing these methods is a bit overkill for my taste, but they help to test stuff, so it's fine

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes I couldn't find a better way around this, I'm not the biggest fan of it either.


// Start heartbeat so that transactions do not run forever.
logger.log('Starting heartbeat');
this._pingHeartbeat();
}
this.spanRecorder.add(this);
}
}

/**
* Reset active transaction on scope
*/
function clearActiveTransaction(hub?: Hub): void {
if (hub) {
const scope = hub.getScope();
if (scope) {
const transaction = scope.getTransaction();
if (transaction) {
scope.setSpan(undefined);
}
}
}
}
Loading