diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/init.js new file mode 100644 index 000000000000..98e297d13625 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/init.js @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + // To avoid having this test run for 15s + childSpanTimeout: 4000, + }), + ], + defaultIntegrations: false, + tracesSampleRate: 1, +}); + +const activeSpan = Sentry.getActiveSpan(); +if (activeSpan) { + Sentry.startInactiveSpan({ name: 'pageload-child-span' }); +} else { + setTimeout(() => { + Sentry.startInactiveSpan({ name: 'pageload-child-span' }); + }, 200); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/test.ts new file mode 100644 index 000000000000..5987061c741f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithChildSpanTimeout/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../../utils/helpers'; + +// This tests asserts that the pageload span will finish itself after the child span timeout if it +// has a child span without adding any additional ones or finishing any of them finishing. All of the child spans that +// are still running should have the status "cancelled". +sentryTest('should send a pageload span terminated via child span timeout', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForTransactionRequestOnUrl(page, url); + + const eventData = envelopeRequestParser(req); + + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.spans?.length).toBeGreaterThanOrEqual(1); + const testSpan = eventData.spans?.find(span => span.description === 'pageload-child-span'); + expect(testSpan).toBeDefined(); + expect(testSpan?.status).toBe('cancelled'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/init.js deleted file mode 100644 index 8b12fe807d7b..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/init.js +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from '@sentry/browser'; -import { startSpanManual } from '@sentry/browser'; - -window.Sentry = Sentry; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], - tracesSampleRate: 1, -}); - -setTimeout(() => { - startSpanManual({ name: 'pageload-child-span' }, () => {}); -}, 200); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/test.ts deleted file mode 100644 index f96495a69925..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageloadWithHeartbeatTimeout/test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect } from '@playwright/test'; -import type { Event } from '@sentry/types'; - -import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; - -// This tests asserts that the pageload transaction will finish itself after about 15 seconds (3x5s of heartbeats) if it -// has a child span without adding any additional ones or finishing any of them finishing. All of the child spans that -// are still running should have the status "cancelled". -sentryTest( - 'should send a pageload transaction terminated via heartbeat timeout', - async ({ getLocalTestPath, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestPath({ testDir: __dirname }); - - const eventData = await getFirstSentryEnvelopeRequest(page, url); - - expect(eventData.contexts?.trace?.op).toBe('pageload'); - expect( - eventData.spans?.find(span => span.description === 'pageload-child-span' && span.status === 'cancelled'), - ).toBeDefined(); - }, -); diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index afd0d123090f..67ad231e7c10 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -19,3 +19,6 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_OP = 'sentry.op'; * Use this attribute to represent the origin of a span. */ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; + +/** The reason why an idle span finished. */ +export const SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON = 'sentry.idle_span_finish_reason'; diff --git a/packages/core/src/tracing/hubextensions.ts b/packages/core/src/tracing/hubextensions.ts index 7c9e3843f3bc..d3674b61f341 100644 --- a/packages/core/src/tracing/hubextensions.ts +++ b/packages/core/src/tracing/hubextensions.ts @@ -2,7 +2,6 @@ import type { ClientOptions, CustomSamplingContext, Hub, TransactionContext } fr import { getMainCarrier } from '../asyncContext'; import { registerErrorInstrumentation } from './errors'; -import { IdleTransaction } from './idletransaction'; import { sampleTransaction } from './sampling'; import { Transaction } from './transaction'; @@ -53,54 +52,6 @@ function _startTransaction( return transaction; } -/** - * Create new idle transaction. - */ -export function startIdleTransaction( - hub: Hub, - transactionContext: TransactionContext, - idleTimeout: number, - finalTimeout: number, - onScope?: boolean, - customSamplingContext?: CustomSamplingContext, - heartbeatInterval?: number, - delayAutoFinishUntilSignal: boolean = false, -): IdleTransaction { - // eslint-disable-next-line deprecation/deprecation - const client = hub.getClient(); - const options: Partial = (client && client.getOptions()) || {}; - - // eslint-disable-next-line deprecation/deprecation - let transaction = new IdleTransaction( - transactionContext, - hub, - idleTimeout, - finalTimeout, - heartbeatInterval, - onScope, - delayAutoFinishUntilSignal, - ); - transaction = sampleTransaction(transaction, options, { - name: transactionContext.name, - parentSampled: transactionContext.parentSampled, - transactionContext, - attributes: { - // eslint-disable-next-line deprecation/deprecation - ...transactionContext.data, - ...transactionContext.attributes, - }, - ...customSamplingContext, - }); - if (transaction.isRecording()) { - transaction.initSpanRecorder(); - } - if (client) { - client.emit('startTransaction', transaction); - client.emit('spanStart', transaction); - } - return transaction; -} - /** * Adds tracing extensions to the global hub. */ diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index a72a7f8c596f..c081d8a22b85 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -1,10 +1,11 @@ -import type { Span, StartSpanOptions } from '@sentry/types'; +import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/types'; import { logger, timestampInSeconds } from '@sentry/utils'; import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '../semanticAttributes'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; -import { getSpanDescendants, removeChildSpanFromSpan, spanToJSON } from '../utils/spanUtils'; +import { getSpanDescendants, removeChildSpanFromSpan, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { startInactiveSpan } from './trace'; @@ -16,8 +17,6 @@ export const TRACING_DEFAULTS = { childSpanTimeout: 15_000, }; -const FINISH_REASON_TAG = 'finishReason'; - const FINISH_REASON_HEARTBEAT_FAILED = 'heartbeatFailed'; const FINISH_REASON_IDLE_TIMEOUT = 'idleTimeout'; const FINISH_REASON_FINAL_TIMEOUT = 'finalTimeout'; @@ -108,6 +107,36 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti const previousActiveSpan = getActiveSpan(); const span = _startIdleSpan(startSpanOptions); + function _endSpan(timestamp: number = timestampInSeconds()): void { + // Ensure we end with the last span timestamp, if possible + const spans = getSpanDescendants(span).filter(child => child !== span); + + // If we have no spans, we just end, nothing else to do here + if (!spans.length) { + span.end(timestamp); + return; + } + + const childEndTimestamps = spans + .map(span => spanToJSON(span).timestamp) + .filter(timestamp => !!timestamp) as number[]; + const latestSpanEndTimestamp = childEndTimestamps.length ? Math.max(...childEndTimestamps) : undefined; + + const spanEndTimestamp = spanTimeInputToSeconds(timestamp); + const spanStartTimestamp = spanToJSON(span).start_timestamp; + + // The final endTimestamp should: + // * Never be before the span start timestamp + // * Be the latestSpanEndTimestamp, if there is one, and it is smaller than the passed span end timestamp + // * Otherwise be the passed end timestamp + const endTimestamp = Math.max( + spanStartTimestamp || -Infinity, + Math.min(spanEndTimestamp, latestSpanEndTimestamp || Infinity), + ); + + span.end(endTimestamp); + } + /** * Cancels the existing idle timeout, if there is one. */ @@ -136,7 +165,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti _idleTimeoutID = setTimeout(() => { if (!_finished && activities.size === 0 && _autoFinishAllowed) { _finishReason = FINISH_REASON_IDLE_TIMEOUT; - span.end(endTimestamp); + _endSpan(endTimestamp); } }, idleTimeout); } @@ -149,7 +178,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti _idleTimeoutID = setTimeout(() => { if (!_finished && _autoFinishAllowed) { _finishReason = FINISH_REASON_HEARTBEAT_FAILED; - span.end(endTimestamp); + _endSpan(endTimestamp); } }, childSpanTimeout); } @@ -190,7 +219,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti } } - function endIdleSpan(): void { + function onIdleSpanEnded(): void { _finished = true; activities.clear(); @@ -209,9 +238,9 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti return; } - const attributes = spanJSON.data || {}; - if (spanJSON.op === 'ui.action.click' && !attributes[FINISH_REASON_TAG]) { - span.setAttribute(FINISH_REASON_TAG, _finishReason); + const attributes: SpanAttributes = spanJSON.data || {}; + if (spanJSON.op === 'ui.action.click' && !attributes[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); } DEBUG_BUILD && @@ -279,7 +308,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti _popActivity(endedSpan.spanContext().spanId); if (endedSpan === span) { - endIdleSpan(); + onIdleSpanEnded(); } }); @@ -303,7 +332,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti if (!_finished) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }); _finishReason = FINISH_REASON_FINAL_TIMEOUT; - span.end(); + _endSpan(); } }, finalTimeout); diff --git a/packages/core/src/tracing/idletransaction.ts b/packages/core/src/tracing/idletransaction.ts deleted file mode 100644 index 0aad33ca6836..000000000000 --- a/packages/core/src/tracing/idletransaction.ts +++ /dev/null @@ -1,418 +0,0 @@ -import type { Hub, SpanTimeInput, TransactionContext } from '@sentry/types'; -import { logger, timestampInSeconds } from '@sentry/utils'; - -import { DEBUG_BUILD } from '../debug-build'; -import { spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; -import type { SentrySpan } from './sentrySpan'; -import { SpanRecorder } from './sentrySpan'; -import { SPAN_STATUS_ERROR } from './spanstatus'; -import { Transaction } from './transaction'; - -export const TRACING_DEFAULTS = { - idleTimeout: 1000, - finalTimeout: 30000, - heartbeatInterval: 5000, -}; - -const FINISH_REASON_TAG = 'finishReason'; - -const IDLE_TRANSACTION_FINISH_REASONS = [ - 'heartbeatFailed', - 'idleTimeout', - 'documentHidden', - 'finalTimeout', - 'externalFinish', - 'cancelled', -]; - -/** - * @inheritDoc - */ -export class IdleTransactionSpanRecorder extends SpanRecorder { - public constructor( - private readonly _pushActivity: (id: string) => void, - private readonly _popActivity: (id: string) => void, - public transactionSpanId: string, - maxlen?: number, - ) { - super(maxlen); - } - - /** - * @inheritDoc - */ - public add(span: SentrySpan): void { - // We should make sure we do not push and pop activities for - // the transaction that this span recorder belongs to. - if (span.spanContext().spanId !== this.transactionSpanId) { - // We patch span.end() to pop an activity after setting an endTimestamp. - // eslint-disable-next-line @typescript-eslint/unbound-method - const originalEnd = span.end; - span.end = (...rest: unknown[]) => { - this._popActivity(span.spanContext().spanId); - return originalEnd.apply(span, rest); - }; - - // We should only push new activities if the span does not have an end timestamp. - if (spanToJSON(span).timestamp === undefined) { - this._pushActivity(span.spanContext().spanId); - } - } - - super.add(span); - } -} - -export type BeforeFinishCallback = (transactionSpan: IdleTransaction, endTimestamp: number) => void; - -/** - * 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 - public activities: Record; - // Track state of activities in previous heartbeat - private _prevHeartbeatString: string | undefined; - - // Amount of times heartbeat has counted. Will cause transaction to finish after 3 beats. - private _heartbeatCounter: number; - - // We should not use heartbeat if we finished a transaction - private _finished: boolean; - - // Idle timeout was canceled and we should finish the transaction with the last span end. - private _idleTimeoutCanceledPermanently: boolean; - - private readonly _beforeFinishCallbacks: BeforeFinishCallback[]; - - /** - * Timer that tracks Transaction idleTimeout - */ - private _idleTimeoutID: ReturnType | undefined; - - private _finishReason: (typeof IDLE_TRANSACTION_FINISH_REASONS)[number]; - - private _autoFinishAllowed: boolean; - - /** - * @deprecated Transactions will be removed in v8. Use spans instead. - */ - public constructor( - transactionContext: TransactionContext, - private readonly _idleHub: Hub, - /** - * The time to wait in ms until the idle transaction will be finished. This timer is started each time - * there are no active spans on this transaction. - */ - private readonly _idleTimeout: number = TRACING_DEFAULTS.idleTimeout, - /** - * The final value in ms that a transaction cannot exceed - */ - private readonly _finalTimeout: number = TRACING_DEFAULTS.finalTimeout, - private readonly _heartbeatInterval: number = TRACING_DEFAULTS.heartbeatInterval, - // 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, - /** - * When set to `true`, will disable the idle timeout (`_idleTimeout` option) and heartbeat mechanisms (`_heartbeatInterval` - * option) until the `sendAutoFinishSignal()` method is called. The final timeout mechanism (`_finalTimeout` option) - * will not be affected by this option, meaning the transaction will definitely be finished when the final timeout is - * reached, no matter what this option is configured to. - * - * Defaults to `false`. - */ - delayAutoFinishUntilSignal: boolean = false, - ) { - super(transactionContext, _idleHub); - - this.activities = {}; - this._heartbeatCounter = 0; - this._finished = false; - this._idleTimeoutCanceledPermanently = false; - this._beforeFinishCallbacks = []; - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[4]; - this._autoFinishAllowed = !delayAutoFinishUntilSignal; - - if (_onScope) { - // We set the transaction here on the scope so error events pick up the trace - // context and attach it to the error. - DEBUG_BUILD && logger.log(`Setting idle transaction on scope. Span ID: ${this.spanContext().spanId}`); - // eslint-disable-next-line deprecation/deprecation - _idleHub.getScope().setSpan(this); - } - - if (!delayAutoFinishUntilSignal) { - this._restartIdleTimeout(); - } - - setTimeout(() => { - if (!this._finished) { - this.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }); - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[3]; - this.end(); - } - }, this._finalTimeout); - } - - /** {@inheritDoc} */ - public end(endTimestamp?: SpanTimeInput): string | undefined { - const endTimestampInS = spanTimeInputToSeconds(endTimestamp); - - this._finished = true; - this.activities = {}; - - const op = spanToJSON(this).op; - - if (op === 'ui.action.click') { - this.setAttribute(FINISH_REASON_TAG, this._finishReason); - } - - // eslint-disable-next-line deprecation/deprecation - if (this.spanRecorder) { - DEBUG_BUILD && - logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestampInS * 1000).toISOString(), op); - - for (const callback of this._beforeFinishCallbacks) { - callback(this, endTimestampInS); - } - - // eslint-disable-next-line deprecation/deprecation - this.spanRecorder.spans = this.spanRecorder.spans.filter((span: SentrySpan) => { - // If we are dealing with the transaction itself, we just return it - if (span.spanContext().spanId === this.spanContext().spanId) { - return true; - } - - // We cancel all pending spans with status "cancelled" to indicate the idle transaction was finished early - if (!spanToJSON(span).timestamp) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); - span.end(endTimestampInS); - DEBUG_BUILD && - logger.log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2)); - } - - const { start_timestamp: startTime, timestamp: endTime } = spanToJSON(span); - const spanStartedBeforeTransactionFinish = startTime && startTime < endTimestampInS; - - // Add a delta with idle timeout so that we prevent false positives - const timeoutWithMarginOfError = (this._finalTimeout + this._idleTimeout) / 1000; - const spanEndedBeforeFinalTimeout = endTime && startTime && endTime - startTime < timeoutWithMarginOfError; - - if (DEBUG_BUILD) { - const stringifiedSpan = JSON.stringify(span, undefined, 2); - if (!spanStartedBeforeTransactionFinish) { - logger.log('[Tracing] discarding Span since it happened after Transaction was finished', stringifiedSpan); - } else if (!spanEndedBeforeFinalTimeout) { - logger.log('[Tracing] discarding Span since it finished after Transaction final timeout', stringifiedSpan); - } - } - - return spanStartedBeforeTransactionFinish && spanEndedBeforeFinalTimeout; - }); - - DEBUG_BUILD && logger.log('[Tracing] flushing IdleTransaction'); - } else { - DEBUG_BUILD && logger.log('[Tracing] No active IdleTransaction'); - } - - // if `this._onScope` is `true`, the transaction put itself on the scope when it started - if (this._onScope) { - // eslint-disable-next-line deprecation/deprecation - const scope = this._idleHub.getScope(); - // eslint-disable-next-line deprecation/deprecation - if (scope.getTransaction() === this) { - // eslint-disable-next-line deprecation/deprecation - scope.setSpan(undefined); - } - } - - return super.end(endTimestamp); - } - - /** - * Register a callback function that gets executed 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 registerBeforeFinishCallback(callback: BeforeFinishCallback): void { - this._beforeFinishCallbacks.push(callback); - } - - /** - * @inheritDoc - */ - public initSpanRecorder(maxlen?: number): void { - // eslint-disable-next-line deprecation/deprecation - if (!this.spanRecorder) { - const pushActivity = (id: string): void => { - if (this._finished) { - return; - } - this._pushActivity(id); - }; - const popActivity = (id: string): void => { - if (this._finished) { - return; - } - this._popActivity(id); - }; - - // eslint-disable-next-line deprecation/deprecation - this.spanRecorder = new IdleTransactionSpanRecorder(pushActivity, popActivity, this.spanContext().spanId, maxlen); - - // Start heartbeat so that transactions do not run forever. - DEBUG_BUILD && logger.log('Starting heartbeat'); - this._pingHeartbeat(); - } - // eslint-disable-next-line deprecation/deprecation - this.spanRecorder.add(this); - } - - /** - * Cancels the existing idle timeout, if there is one. - * @param restartOnChildSpanChange Default is `true`. - * If set to false the transaction will end - * with the last child span. - */ - public cancelIdleTimeout( - endTimestamp?: Parameters[0], - { - restartOnChildSpanChange, - }: { - restartOnChildSpanChange?: boolean; - } = { - restartOnChildSpanChange: true, - }, - ): void { - this._idleTimeoutCanceledPermanently = restartOnChildSpanChange === false; - if (this._idleTimeoutID) { - clearTimeout(this._idleTimeoutID); - this._idleTimeoutID = undefined; - - if (Object.keys(this.activities).length === 0 && this._idleTimeoutCanceledPermanently) { - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[5]; - this.end(endTimestamp); - } - } - } - - /** - * Temporary method used to externally set the transaction's `finishReason` - * - * ** WARNING** - * This is for the purpose of experimentation only and will be removed in the near future, do not use! - * - * @internal - * - */ - public setFinishReason(reason: string): void { - this._finishReason = reason; - } - - /** - * Permits the IdleTransaction to automatically end itself via the idle timeout and heartbeat mechanisms when the `delayAutoFinishUntilSignal` option was set to `true`. - */ - public sendAutoFinishSignal(): void { - if (!this._autoFinishAllowed) { - DEBUG_BUILD && logger.log('[Tracing] Received finish signal for idle transaction.'); - this._restartIdleTimeout(); - this._autoFinishAllowed = true; - } - } - - /** - * Restarts idle timeout, if there is no running idle timeout it will start one. - */ - private _restartIdleTimeout(endTimestamp?: Parameters[0]): void { - this.cancelIdleTimeout(); - this._idleTimeoutID = setTimeout(() => { - if (!this._finished && Object.keys(this.activities).length === 0) { - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[1]; - this.end(endTimestamp); - } - }, this._idleTimeout); - } - - /** - * Start tracking a specific activity. - * @param spanId The span id that represents the activity - */ - private _pushActivity(spanId: string): void { - this.cancelIdleTimeout(undefined, { restartOnChildSpanChange: !this._idleTimeoutCanceledPermanently }); - DEBUG_BUILD && logger.log(`[Tracing] pushActivity: ${spanId}`); - this.activities[spanId] = true; - DEBUG_BUILD && 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]) { - DEBUG_BUILD && logger.log(`[Tracing] popActivity ${spanId}`); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.activities[spanId]; - DEBUG_BUILD && logger.log('[Tracing] new activities count', Object.keys(this.activities).length); - } - - if (Object.keys(this.activities).length === 0) { - const endTimestamp = timestampInSeconds(); - if (this._idleTimeoutCanceledPermanently) { - if (this._autoFinishAllowed) { - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[5]; - this.end(endTimestamp); - } - } else { - // We need to add the timeout here to have the real endtimestamp of the transaction - // Remember timestampInSeconds is in seconds, timeout is in ms - this._restartIdleTimeout(endTimestamp + this._idleTimeout / 1000); - } - } - } - - /** - * Checks when entries of this.activities are not changing for 3 beats. - * If this occurs we finish the transaction. - */ - private _beat(): void { - // We should not be running heartbeat if the idle transaction is finished. - if (this._finished) { - return; - } - - const heartbeatString = Object.keys(this.activities).join(''); - - if (heartbeatString === this._prevHeartbeatString) { - this._heartbeatCounter++; - } else { - this._heartbeatCounter = 1; - } - - this._prevHeartbeatString = heartbeatString; - - if (this._heartbeatCounter >= 3) { - if (this._autoFinishAllowed) { - DEBUG_BUILD && logger.log('[Tracing] Transaction finished because of no change for 3 heart beats'); - this.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }); - this._finishReason = IDLE_TRANSACTION_FINISH_REASONS[0]; - this.end(); - } - } else { - this._pingHeartbeat(); - } - } - - /** - * Pings the heartbeat - */ - private _pingHeartbeat(): void { - DEBUG_BUILD && logger.log(`pinging Heartbeat -> current counter: ${this._heartbeatCounter}`); - setTimeout(() => { - this._beat(); - }, this._heartbeatInterval); - } -} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 59ad557cba90..7a095687c35c 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -1,6 +1,5 @@ -export { startIdleTransaction, addTracingExtensions } from './hubextensions'; -export { IdleTransaction, TRACING_DEFAULTS } from './idletransaction'; -export type { BeforeFinishCallback } from './idletransaction'; +export { addTracingExtensions } from './hubextensions'; +export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { Transaction } from './transaction'; @@ -19,5 +18,3 @@ export { } from './trace'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; export { setMeasurement } from './measurement'; - -export { startIdleSpan } from './idleSpan'; diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index fd23b9b8d041..a0d5b5bf3123 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -2,6 +2,7 @@ import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; import type { Event, Span } from '@sentry/types'; import { + SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, SentryNonRecordingSpan, SentrySpan, addTracingExtensions, @@ -275,6 +276,51 @@ describe('startIdleSpan', () => { expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'transaction'); }); + it('sets finish reason when span ends', () => { + let transaction: Event | undefined; + const beforeSendTransaction = jest.fn(event => { + transaction = event; + return null; + }); + const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1, beforeSendTransaction }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // This is only set when op === 'ui.action.click' + startIdleSpan({ name: 'foo', op: 'ui.action.click' }); + startSpan({ name: 'inner' }, () => {}); + jest.runOnlyPendingTimers(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( + 'idleTimeout', + ); + }); + + it('uses finish reason set outside when span ends', () => { + let transaction: Event | undefined; + const beforeSendTransaction = jest.fn(event => { + transaction = event; + return null; + }); + const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1, beforeSendTransaction }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + + // This is only set when op === 'ui.action.click' + const span = startIdleSpan({ name: 'foo', op: 'ui.action.click' }); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason'); + startSpan({ name: 'inner' }, () => {}); + jest.runOnlyPendingTimers(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( + 'custom reason', + ); + }); + describe('idleTimeout', () => { it('finishes if no activities are added to the idle span', () => { const idleSpan = startIdleSpan({ name: 'idle span' }); @@ -449,4 +495,47 @@ describe('startIdleSpan', () => { expect(spanToJSON(idleSpan).timestamp).toBeUndefined(); }); }); + + describe('trim end timestamp', () => { + it('trims end to highest child span end', () => { + const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 }); + expect(idleSpan).toBeDefined(); + + const span1 = startInactiveSpan({ name: 'span1', startTime: 1001 }); + span1?.end(1005); + + const span2 = startInactiveSpan({ name: 'span2', startTime: 1002 }); + span2?.end(1100); + + const span3 = startInactiveSpan({ name: 'span1', startTime: 1050 }); + span3?.end(1060); + + expect(getActiveSpan()).toBe(idleSpan); + + jest.runAllTimers(); + + expect(spanToJSON(idleSpan!).timestamp).toBe(1100); + }); + + it('keeps lower span endTime than highest child span end', () => { + const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 }); + expect(idleSpan).toBeDefined(); + + const span1 = startInactiveSpan({ name: 'span1', startTime: 999_999_999 }); + span1?.end(1005); + + const span2 = startInactiveSpan({ name: 'span2', startTime: 1002 }); + span2?.end(1100); + + const span3 = startInactiveSpan({ name: 'span1', startTime: 1050 }); + span3?.end(1060); + + expect(getActiveSpan()).toBe(idleSpan); + + jest.runAllTimers(); + + expect(spanToJSON(idleSpan!).timestamp).toBeLessThan(999_999_999); + expect(spanToJSON(idleSpan!).timestamp).toBeGreaterThan(1060); + }); + }); }); diff --git a/packages/core/test/lib/tracing/idletransaction.test.ts b/packages/core/test/lib/tracing/idletransaction.test.ts deleted file mode 100644 index ac55ed7c1c58..000000000000 --- a/packages/core/test/lib/tracing/idletransaction.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -/* eslint-disable deprecation/deprecation */ -import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; - -import { - IdleTransaction, - SentrySpan, - TRACING_DEFAULTS, - Transaction, - getClient, - getCurrentHub, - getCurrentScope, - getGlobalScope, - getIsolationScope, - setCurrentClient, - spanToJSON, - startInactiveSpan, - startSpan, - startSpanManual, -} from '../../../src'; -import { IdleTransactionSpanRecorder } from '../../../src/tracing/idletransaction'; - -const dsn = 'https://123@sentry.io/42'; -beforeEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - getGlobalScope().clear(); - - const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1 }); - const client = new TestClient(options); - setCurrentClient(client); - client.init(); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('IdleTransaction', () => { - describe('onScope', () => { - it('sets the transaction on the scope on creation if onScope is true', () => { - const transaction = new IdleTransaction( - { name: 'foo' }, - getCurrentHub(), - TRACING_DEFAULTS.idleTimeout, - TRACING_DEFAULTS.finalTimeout, - TRACING_DEFAULTS.heartbeatInterval, - true, - ); - transaction.initSpanRecorder(10); - - const scope = getCurrentScope(); - - // eslint-disable-next-line deprecation/deprecation - expect(scope.getTransaction()).toBe(transaction); - }); - - it('does not set the transaction on the scope on creation if onScope is falsey', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - transaction.initSpanRecorder(10); - - const scope = getCurrentScope(); - // eslint-disable-next-line deprecation/deprecation - expect(scope.getTransaction()).toBe(undefined); - }); - - it('removes sampled transaction from scope on finish if onScope is true', () => { - const transaction = new IdleTransaction( - { name: 'foo' }, - getCurrentHub(), - TRACING_DEFAULTS.idleTimeout, - TRACING_DEFAULTS.finalTimeout, - TRACING_DEFAULTS.heartbeatInterval, - true, - ); - transaction.initSpanRecorder(10); - - transaction.end(); - jest.runAllTimers(); - - const scope = getCurrentScope(); - // eslint-disable-next-line deprecation/deprecation - expect(scope.getTransaction()).toBe(undefined); - }); - - it('removes unsampled transaction from scope on finish if onScope is true', () => { - const transaction = new IdleTransaction( - { name: 'foo', sampled: false }, - getCurrentHub(), - TRACING_DEFAULTS.idleTimeout, - TRACING_DEFAULTS.finalTimeout, - TRACING_DEFAULTS.heartbeatInterval, - true, - ); - - transaction.end(); - jest.runAllTimers(); - - const scope = getCurrentScope(); - // eslint-disable-next-line deprecation/deprecation - expect(scope.getTransaction()).toBe(undefined); - }); - - it('does not remove transaction from scope on finish if another transaction was set there', () => { - const transaction = new IdleTransaction( - { name: 'foo' }, - getCurrentHub(), - TRACING_DEFAULTS.idleTimeout, - TRACING_DEFAULTS.finalTimeout, - TRACING_DEFAULTS.heartbeatInterval, - true, - ); - transaction.initSpanRecorder(10); - - const otherTransaction = new Transaction({ name: 'bar' }, getCurrentHub()); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(otherTransaction); - - transaction.end(); - jest.runAllTimers(); - - const scope = getCurrentScope(); - // eslint-disable-next-line deprecation/deprecation - expect(scope.getTransaction()).toBe(otherTransaction); - }); - }); - - beforeEach(() => { - jest.useFakeTimers(); - }); - - it('push and pops activities', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - const mockFinish = jest.spyOn(transaction, 'end'); - transaction.initSpanRecorder(10); - expect(transaction.activities).toMatchObject({}); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - const span = startInactiveSpan({ name: 'inner' })!; - expect(transaction.activities).toMatchObject({ [span.spanContext().spanId]: true }); - - expect(mockFinish).toHaveBeenCalledTimes(0); - - span.end(); - expect(transaction.activities).toMatchObject({}); - - jest.runOnlyPendingTimers(); - expect(mockFinish).toHaveBeenCalledTimes(1); - }); - - it('does not push activities if a span already has an end timestamp', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - transaction.initSpanRecorder(10); - expect(transaction.activities).toMatchObject({}); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startInactiveSpan({ name: 'inner', startTimestamp: 1234, endTimestamp: 5678 }); - expect(transaction.activities).toMatchObject({}); - }); - - it('does not finish if there are still active activities', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - const mockFinish = jest.spyOn(transaction, 'end'); - transaction.initSpanRecorder(10); - expect(transaction.activities).toMatchObject({}); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startSpanManual({ name: 'inner1' }, span => { - const childSpan = startInactiveSpan({ name: 'inner2' })!; - expect(transaction.activities).toMatchObject({ - [span.spanContext().spanId]: true, - [childSpan.spanContext().spanId]: true, - }); - span.end(); - jest.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout + 1); - - expect(mockFinish).toHaveBeenCalledTimes(0); - expect(transaction.activities).toMatchObject({ [childSpan.spanContext().spanId]: true }); - }); - }); - - it('calls beforeFinish callback before finishing', () => { - const mockCallback1 = jest.fn(); - const mockCallback2 = jest.fn(); - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - transaction.initSpanRecorder(10); - transaction.registerBeforeFinishCallback(mockCallback1); - transaction.registerBeforeFinishCallback(mockCallback2); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - expect(mockCallback1).toHaveBeenCalledTimes(0); - expect(mockCallback2).toHaveBeenCalledTimes(0); - - startSpan({ name: 'inner' }, () => {}); - - jest.runOnlyPendingTimers(); - expect(mockCallback1).toHaveBeenCalledTimes(1); - expect(mockCallback1).toHaveBeenLastCalledWith(transaction, expect.any(Number)); - expect(mockCallback2).toHaveBeenCalledTimes(1); - expect(mockCallback2).toHaveBeenLastCalledWith(transaction, expect.any(Number)); - }); - - it('filters spans on finish', () => { - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub()); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - // regular child - should be kept - const regularSpan = startInactiveSpan({ - name: 'span1', - startTimestamp: spanToJSON(transaction).start_timestamp! + 2, - })!; - - // discardedSpan - startTimestamp is too large - startInactiveSpan({ name: 'span2', startTimestamp: 645345234 }); - - // Should be cancelled - will not finish - const cancelledSpan = startInactiveSpan({ - name: 'span3', - startTimestamp: spanToJSON(transaction).start_timestamp! + 4, - })!; - - regularSpan.end(spanToJSON(regularSpan).start_timestamp! + 4); - transaction.end(spanToJSON(transaction).start_timestamp! + 10); - - expect(transaction.spanRecorder).toBeDefined(); - if (transaction.spanRecorder) { - const spans = transaction.spanRecorder.spans; - expect(spans).toHaveLength(3); - expect(spans[0].spanContext().spanId).toBe(transaction.spanContext().spanId); - - // Regular SentrySpan - should not modified - expect(spans[1].spanContext().spanId).toBe(regularSpan.spanContext().spanId); - expect(spanToJSON(spans[1]).timestamp).not.toBe(spanToJSON(transaction).timestamp); - - // Cancelled SentrySpan - has endtimestamp of transaction - expect(spans[2].spanContext().spanId).toBe(cancelledSpan.spanContext().spanId); - expect(spanToJSON(spans[2]).status).toBe('cancelled'); - expect(spanToJSON(spans[2]).timestamp).toBe(spanToJSON(transaction).timestamp); - } - }); - - it('filters out spans that exceed final timeout', () => { - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), 1000, 3000); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - const span = startInactiveSpan({ name: 'span', startTimestamp: spanToJSON(transaction).start_timestamp! + 2 })!; - span.end(spanToJSON(span).start_timestamp! + 10 + 30 + 1); - - transaction.end(spanToJSON(transaction).start_timestamp! + 50); - - expect(transaction.spanRecorder).toBeDefined(); - expect(transaction.spanRecorder!.spans).toHaveLength(1); - }); - - it('should record dropped transactions', async () => { - const transaction = new IdleTransaction( - { name: 'foo', startTimestamp: 1234, sampled: false }, - getCurrentHub(), - 1000, - ); - - const client = getClient()!; - - const recordDroppedEventSpy = jest.spyOn(client, 'recordDroppedEvent'); - - transaction.initSpanRecorder(10); - transaction.end(spanToJSON(transaction).start_timestamp! + 10); - - expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'transaction'); - }); - - describe('_idleTimeout', () => { - it('finishes if no activities are added to the transaction', () => { - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub()); - transaction.initSpanRecorder(10); - - jest.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout); - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - - it('does not finish if a activity is started', () => { - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub()); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startInactiveSpan({ name: 'span' }); - - jest.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout); - expect(spanToJSON(transaction).timestamp).toBeUndefined(); - }); - - it('does not finish when idleTimeout is not exceed after last activity finished', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startSpan({ name: 'span1' }, () => {}); - - jest.advanceTimersByTime(2); - - startSpan({ name: 'span2' }, () => {}); - - jest.advanceTimersByTime(8); - - expect(spanToJSON(transaction).timestamp).toBeUndefined(); - }); - - it('finish when idleTimeout is exceeded after last activity finished', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startSpan({ name: 'span1' }, () => {}); - - jest.advanceTimersByTime(2); - - startSpan({ name: 'span2' }, () => {}); - - jest.advanceTimersByTime(10); - - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - }); - - describe('cancelIdleTimeout', () => { - it('permanent idle timeout cancel is not restarted by child span start', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - const firstSpan = startInactiveSpan({ name: 'span1' })!; - transaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false }); - const secondSpan = startInactiveSpan({ name: 'span2' })!; - firstSpan.end(); - secondSpan.end(); - - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - - it('permanent idle timeout cancel finished the transaction with the last child', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - const firstSpan = startInactiveSpan({ name: 'span1' })!; - transaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false }); - const secondSpan = startInactiveSpan({ name: 'span2' })!; - const thirdSpan = startInactiveSpan({ name: 'span3' })!; - - firstSpan.end(); - expect(spanToJSON(transaction).timestamp).toBeUndefined(); - - secondSpan.end(); - expect(spanToJSON(transaction).timestamp).toBeUndefined(); - - thirdSpan.end(); - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - - it('permanent idle timeout cancel finishes transaction if there are no activities', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startSpan({ name: 'span' }, () => {}); - - jest.advanceTimersByTime(2); - - transaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false }); - - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - - it('default idle cancel timeout is restarted by child span change', () => { - const idleTimeout = 10; - const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, getCurrentHub(), idleTimeout); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - startSpan({ name: 'span' }, () => {}); - - jest.advanceTimersByTime(2); - - transaction.cancelIdleTimeout(); - - startSpan({ name: 'span' }, () => {}); - - jest.advanceTimersByTime(8); - expect(spanToJSON(transaction).timestamp).toBeUndefined(); - - jest.advanceTimersByTime(2); - expect(spanToJSON(transaction).timestamp).toBeDefined(); - }); - }); - - describe('heartbeat', () => { - it('does not mark transaction as `DeadlineExceeded` if idle timeout has not been reached', () => { - // 20s to exceed 3 heartbeats - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub(), 20000); - const mockFinish = jest.spyOn(transaction, 'end'); - - expect(spanToJSON(transaction).status).not.toEqual('deadline_exceeded'); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(spanToJSON(transaction).status).not.toEqual('deadline_exceeded'); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 2 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(spanToJSON(transaction).status).not.toEqual('deadline_exceeded'); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 3 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(spanToJSON(transaction).status).not.toEqual('deadline_exceeded'); - expect(mockFinish).toHaveBeenCalledTimes(0); - }); - - it('finishes a transaction after 3 beats', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub(), TRACING_DEFAULTS.idleTimeout); - const mockFinish = jest.spyOn(transaction, 'end'); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - expect(mockFinish).toHaveBeenCalledTimes(0); - startInactiveSpan({ name: 'span' }); - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 2 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 3 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(1); - }); - - it('resets after new activities are added', () => { - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub(), TRACING_DEFAULTS.idleTimeout, 50000); - const mockFinish = jest.spyOn(transaction, 'end'); - transaction.initSpanRecorder(10); - // eslint-disable-next-line deprecation/deprecation - getCurrentScope().setSpan(transaction); - - expect(mockFinish).toHaveBeenCalledTimes(0); - startInactiveSpan({ name: 'span' }); - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - const span = startInactiveSpan({ name: 'span' })!; // push activity - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 2 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - startInactiveSpan({ name: 'span' }); // push activity - startInactiveSpan({ name: 'span' }); // push activity - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 2 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - span.end(); // pop activity - - // Beat 1 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 2 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(0); - - // Beat 3 - jest.advanceTimersByTime(TRACING_DEFAULTS.heartbeatInterval); - expect(mockFinish).toHaveBeenCalledTimes(1); - - // Heartbeat does not keep going after finish has been called - jest.runAllTimers(); - expect(mockFinish).toHaveBeenCalledTimes(1); - }); - }); -}); - -describe('IdleTransactionSpanRecorder', () => { - it('pushes and pops activities', () => { - const mockPushActivity = jest.fn(); - const mockPopActivity = jest.fn(); - const spanRecorder = new IdleTransactionSpanRecorder(mockPushActivity, mockPopActivity, '', 10); - expect(mockPushActivity).toHaveBeenCalledTimes(0); - expect(mockPopActivity).toHaveBeenCalledTimes(0); - - const span = new SentrySpan({ sampled: true }); - - expect(spanRecorder.spans).toHaveLength(0); - spanRecorder.add(span); - expect(spanRecorder.spans).toHaveLength(1); - - expect(mockPushActivity).toHaveBeenCalledTimes(1); - expect(mockPushActivity).toHaveBeenLastCalledWith(span.spanContext().spanId); - expect(mockPopActivity).toHaveBeenCalledTimes(0); - - span.end(); - expect(mockPushActivity).toHaveBeenCalledTimes(1); - expect(mockPopActivity).toHaveBeenCalledTimes(1); - expect(mockPushActivity).toHaveBeenLastCalledWith(span.spanContext().spanId); - }); - - it('does not push activities if a span has a timestamp', () => { - const mockPushActivity = jest.fn(); - const mockPopActivity = jest.fn(); - const spanRecorder = new IdleTransactionSpanRecorder(mockPushActivity, mockPopActivity, '', 10); - - const span = new SentrySpan({ sampled: true, startTimestamp: 765, endTimestamp: 345 }); - spanRecorder.add(span); - - expect(mockPushActivity).toHaveBeenCalledTimes(0); - }); - - it('does not push or pop transaction spans', () => { - const mockPushActivity = jest.fn(); - const mockPopActivity = jest.fn(); - - const transaction = new IdleTransaction({ name: 'foo' }, getCurrentHub()); - const spanRecorder = new IdleTransactionSpanRecorder( - mockPushActivity, - mockPopActivity, - transaction.spanContext().spanId, - 10, - ); - - spanRecorder.add(transaction); - expect(mockPushActivity).toHaveBeenCalledTimes(0); - expect(mockPopActivity).toHaveBeenCalledTimes(0); - }); -}); diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index fba359e75e79..dc0881318af1 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -12,7 +12,7 @@ import { getRootSpan, spanToJSON, } from '@sentry/core'; -import type { Integration, Span, StartSpanOptions, Transaction, TransactionSource } from '@sentry/types'; +import type { Integration, Span, StartSpanOptions, TransactionSource } from '@sentry/types'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; @@ -43,8 +43,6 @@ interface ReactRouterOptions { matchPath?: MatchPath; } -let activeTransaction: Transaction | undefined; - /** * A browser tracing integration that uses React Router v4 to instrument navigations. * Expects `history` (and optionally `routes` and `matchPath`) to be passed as options. @@ -168,7 +166,7 @@ function createReactRouterInstrumentation( if (startTransactionOnPageLoad && initPathName) { const [name, source] = normalizeTransactionName(initPathName); - activeTransaction = customStartTransaction({ + customStartTransaction({ name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', @@ -181,12 +179,8 @@ function createReactRouterInstrumentation( if (startTransactionOnLocationChange && history.listen) { history.listen((location, action) => { if (action && (action === 'PUSH' || action === 'POP')) { - if (activeTransaction) { - activeTransaction.end(); - } - const [name, source] = normalizeTransactionName(location.pathname); - activeTransaction = customStartTransaction({ + customStartTransaction({ name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -263,11 +257,6 @@ export function withSentryRouting

, R extends React /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ function getActiveRootSpan(): Span | undefined { - // Legacy behavior for "old" react router instrumentation - if (activeTransaction) { - return activeTransaction; - } - const span = getActiveSpan(); const rootSpan = span ? getRootSpan(span) : undefined; diff --git a/packages/react/test/reactrouterv3.test.tsx b/packages/react/test/reactrouterv3.test.tsx index 964fe7e47b3d..413c9566e71a 100644 --- a/packages/react/test/reactrouterv3.test.tsx +++ b/packages/react/test/reactrouterv3.test.tsx @@ -84,9 +84,9 @@ describe('browserTracingReactRouterV3', () => { function createMockBrowserClient(): BrowserClient { return new BrowserClient({ integrations: [], + tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], - debug: true, }); } diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index 1028131eb0b3..5e6a142c11cd 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -55,9 +55,9 @@ describe('browserTracingReactRouterV4', () => { function createMockBrowserClient(): BrowserClient { return new BrowserClient({ integrations: [], + tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], - debug: true, }); } diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index 0678f65652e4..7d4939cce522 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -55,9 +55,9 @@ describe('browserTracingReactRouterV5', () => { function createMockBrowserClient(): BrowserClient { return new BrowserClient({ integrations: [], + tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], - debug: true, }); } diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx index 2013524d5185..34fe85b6bfc9 100644 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ b/packages/react/test/reactrouterv6.4.test.tsx @@ -69,9 +69,9 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { function createMockBrowserClient(): BrowserClient { return new BrowserClient({ integrations: [], + tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], - debug: true, }); } diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 65355099476b..c86f55ccbd73 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -68,9 +68,9 @@ describe('reactRouterV6BrowserTracingIntegration', () => { function createMockBrowserClient(): BrowserClient { return new BrowserClient({ integrations: [], + tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], - debug: true, }); } diff --git a/packages/sveltekit/src/client/browserTracingIntegration.ts b/packages/sveltekit/src/client/browserTracingIntegration.ts index 10df93b20524..716223a17c5c 100644 --- a/packages/sveltekit/src/client/browserTracingIntegration.ts +++ b/packages/sveltekit/src/client/browserTracingIntegration.ts @@ -78,7 +78,7 @@ function _instrumentNavigations(client: Client): void { navigating.subscribe(navigation => { if (!navigation) { // `navigating` emits a 'null' value when the navigation is completed. - // So in this case, we can finish the routing span. If the transaction was an IdleTransaction, + // So in this case, we can finish the routing span. If the span was an idle span, // it will finish automatically and if it was user-created users also need to finish it. if (routingSpan) { routingSpan.end(); diff --git a/packages/tracing-internal/src/browser/browserTracingIntegration.ts b/packages/tracing-internal/src/browser/browserTracingIntegration.ts index 9c0877045084..eccaee9b27cc 100644 --- a/packages/tracing-internal/src/browser/browserTracingIntegration.ts +++ b/packages/tracing-internal/src/browser/browserTracingIntegration.ts @@ -1,31 +1,21 @@ -import type { IdleTransaction } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { getActiveSpan } from '@sentry/core'; -import { getCurrentHub } from '@sentry/core'; import { + SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, TRACING_DEFAULTS, addTracingExtensions, - getActiveTransaction, + continueTrace, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, spanToJSON, - startIdleTransaction, + startIdleSpan, + withScope, } from '@sentry/core'; -import type { - Client, - IntegrationFn, - StartSpanOptions, - Transaction, - TransactionContext, - TransactionSource, -} from '@sentry/types'; +import type { Client, IntegrationFn, StartSpanOptions, TransactionSource } from '@sentry/types'; import type { Span } from '@sentry/types'; -import { - addHistoryInstrumentationHandler, - browserPerformanceTimeOrigin, - getDomElement, - logger, - propagationContextFromHeaders, -} from '@sentry/utils'; +import { addHistoryInstrumentationHandler, browserPerformanceTimeOrigin, getDomElement, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../common/debug-build'; import { registerBackgroundTabDetection } from './backgroundtab'; @@ -43,34 +33,28 @@ export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; /** Options for Browser Tracing integration */ export interface BrowserTracingOptions { /** - * The time to wait in ms until the transaction will be finished during an idle state. An idle state is defined - * by a moment where there are no in-progress spans. - * - * The transaction will use the end timestamp of the last finished span as the endtime for the transaction. - * If there are still active spans when this the `idleTimeout` is set, the `idleTimeout` will get reset. - * Time is in ms. + * The time that has to pass without any span being created. + * If this time is exceeded, the idle span will finish. * - * Default: 1000 + * Default: 1000 (ms) */ idleTimeout: number; /** - * The max duration for a transaction. If a transaction duration hits the `finalTimeout` value, it - * will be finished. - * Time is in ms. + * The max. time an idle span may run. + * If this time is exceeded, the idle span will finish no matter what. * - * Default: 30000 + * Default: 30000 (ms) */ finalTimeout: number; /** - * The heartbeat interval. If no new spans are started or open spans are finished within 3 heartbeats, - * the transaction will be finished. - * Time is in ms. + The max. time an idle span may run. + * If this time is exceeded, the idle span will finish no matter what. * - * Default: 5000 + * Default: 15000 (ms) */ - heartbeatInterval: number; + childSpanTimeout: number; /** * If a span should be created on page load. @@ -176,7 +160,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * actions as transactions, and captures requests, metrics and errors as spans. * * The integration can be configured with a variety of options, and can be extended to use - * any routing library. This integration uses {@see IdleTransaction} to create transactions. + * any routing library. * * We explicitly export the proper type here, as this has to be extended in some cases. */ @@ -201,88 +185,53 @@ export const browserTracingIntegration = ((_options: Partial { + _collectWebVitals(); + addPerformanceEntries(span); + }, + }); if (isPageloadTransaction && WINDOW.document) { WINDOW.document.addEventListener('readystatechange', () => { if (['interactive', 'complete'].includes(WINDOW.document.readyState)) { - idleTransaction.sendAutoFinishSignal(); + client.emit('idleSpanEnableAutoFinish', idleSpan); } }); if (['interactive', 'complete'].includes(WINDOW.document.readyState)) { - idleTransaction.sendAutoFinishSignal(); + client.emit('idleSpanEnableAutoFinish', idleSpan); } } - idleTransaction.registerBeforeFinishCallback(transaction => { - _collectWebVitals(); - addPerformanceEntries(transaction); - }); - - return idleTransaction as Transaction; + return idleSpan; } return { @@ -296,32 +245,57 @@ export const browserTracingIntegration = ((_options: Partial { + client.on('startNavigationSpan', (startSpanOptions: StartSpanOptions) => { + if (getClient() !== client) { + return; + } + if (activeSpan) { DEBUG_BUILD && logger.log(`[Tracing] Finishing current transaction with op: ${spanToJSON(activeSpan).op}`); // If there's an open transaction on the scope, we need to finish it before creating an new one. activeSpan.end(); } - activeSpan = _createRouteTransaction({ + activeSpan = _createRouteSpan(client, { op: 'navigation', - ...context, + ...startSpanOptions, }); }); - client.on('startPageLoadSpan', (context: StartSpanOptions) => { + client.on('startPageLoadSpan', (startSpanOptions: StartSpanOptions) => { + if (getClient() !== client) { + return; + } + if (activeSpan) { DEBUG_BUILD && logger.log(`[Tracing] Finishing current transaction with op: ${spanToJSON(activeSpan).op}`); // If there's an open transaction on the scope, we need to finish it before creating an new one. activeSpan.end(); } - activeSpan = _createRouteTransaction({ - op: 'pageload', - ...context, + + const sentryTrace = getMetaContent('sentry-trace'); + const baggage = getMetaContent('baggage'); + + // Continue trace updates the _current_ scope, but we want to break out of it again... + // This is a bit hacky, because we want to get the span to use both the correct scope _and_ the correct propagation context + // but afterwards, we want to reset it to avoid this also applying to other spans + const scope = getCurrentScope(); + const propagationContextBefore = scope.getPropagationContext(); + + activeSpan = continueTrace({ sentryTrace, baggage }, () => { + // Ensure we are on the original current scope again, so the span is set as active on it + return withScope(scope, () => { + return _createRouteSpan(client, { + op: 'pageload', + ...startSpanOptions, + }); + }); }); + + scope.setPropagationContext(propagationContextBefore); }); if (options.instrumentPageLoad && WINDOW.location) { - const context: StartSpanOptions = { + const startSpanOptions: StartSpanOptions = { name: WINDOW.location.pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) startTime: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined, @@ -330,7 +304,7 @@ export const browserTracingIntegration = ((_options: Partial { - const { idleTimeout, finalTimeout, heartbeatInterval } = options; + const { idleTimeout, finalTimeout, childSpanTimeout } = options; const op = 'ui.action.click'; - // eslint-disable-next-line deprecation/deprecation - const currentTransaction = getActiveTransaction(); - if (currentTransaction) { - const currentTransactionOp = spanToJSON(currentTransaction).op; - if (currentTransactionOp && ['navigation', 'pageload'].includes(currentTransactionOp)) { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + if (rootSpan) { + const currentRootSpanOp = spanToJSON(rootSpan).op; + if (['navigation', 'pageload'].includes(currentRootSpanOp as string)) { DEBUG_BUILD && - logger.warn( - `[Tracing] Did not create ${op} transaction because a pageload or navigation transaction is in progress.`, - ); + logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`); return undefined; } } - if (inflightInteractionTransaction) { - inflightInteractionTransaction.setFinishReason('interactionInterrupted'); - inflightInteractionTransaction.end(); - inflightInteractionTransaction = undefined; + if (inflightInteractionSpan) { + inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'interactionInterrupted'); + inflightInteractionSpan.end(); + inflightInteractionSpan = undefined; } if (!latestRouteName) { @@ -452,26 +424,20 @@ function registerInteractionListener( return undefined; } - const { location } = WINDOW; - - const context: TransactionContext = { - name: latestRouteName, - op, - trimEnd: true, - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRouteSource || 'url', + inflightInteractionSpan = startIdleSpan( + { + name: latestRouteName, + op, + trimEnd: true, + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRouteSource || 'url', + }, + }, + { + idleTimeout, + finalTimeout, + childSpanTimeout, }, - }; - - inflightInteractionTransaction = startIdleTransaction( - // eslint-disable-next-line deprecation/deprecation - getCurrentHub(), - context, - idleTimeout, - finalTimeout, - true, - { location }, // for use in the tracesSampler - heartbeatInterval, ); }; diff --git a/packages/tracing-internal/src/exports/index.ts b/packages/tracing-internal/src/exports/index.ts index 284f16edbf9d..48106a40abf9 100644 --- a/packages/tracing-internal/src/exports/index.ts +++ b/packages/tracing-internal/src/exports/index.ts @@ -2,8 +2,6 @@ export { // eslint-disable-next-line deprecation/deprecation getActiveTransaction, hasTracingEnabled, - IdleTransaction, - startIdleTransaction, Transaction, } from '@sentry/core'; export { stripUrlQueryAndFragment, TRACEPARENT_REGEXP } from '@sentry/utils'; diff --git a/packages/tracing-internal/test/browser/browserTracingIntegration.test.ts b/packages/tracing-internal/test/browser/browserTracingIntegration.test.ts index cc0d96a9c98f..9df7e23d797b 100644 --- a/packages/tracing-internal/test/browser/browserTracingIntegration.test.ts +++ b/packages/tracing-internal/test/browser/browserTracingIntegration.test.ts @@ -1,4 +1,3 @@ -import type { IdleTransaction } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -6,15 +5,15 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, TRACING_DEFAULTS, getActiveSpan, - getActiveTransaction, getCurrentScope, + getDynamicSamplingContextFromSpan, + getIsolationScope, setCurrentClient, spanIsSampled, spanToJSON, startInactiveSpan, } from '@sentry/core'; -import * as hubExtensions from '@sentry/core'; -import type { StartSpanOptions } from '@sentry/types'; +import type { Span, StartSpanOptions } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; import { JSDOM } from 'jsdom'; import { browserTracingIntegration, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '../..'; @@ -41,9 +40,15 @@ afterAll(() => { }); describe('browserTracingIntegration', () => { - afterEach(() => { + beforeEach(() => { getCurrentScope().clear(); + getIsolationScope().clear(); getCurrentScope().setClient(undefined); + document.head.innerHTML = ''; + }); + + afterEach(() => { + getActiveSpan()?.end(); }); it('works with tracing enabled', () => { @@ -85,8 +90,7 @@ describe('browserTracingIntegration', () => { client.init(); const span = getActiveSpan(); - expect(span).toBeDefined(); - expect(spanIsSampled(span!)).toBe(false); + expect(span).toBeUndefined(); }); it("doesn't create a pageload span when instrumentPageLoad is false", () => { @@ -201,59 +205,11 @@ describe('browserTracingIntegration', () => { }); }); - it('extracts window.location/self.location for sampling context in pageload transactions', () => { - // this is what is used to get the span name - JSDOM does not update this on it's own! - const dom = new JSDOM(undefined, { url: 'https://example.com/test' }); - Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); - - const tracesSampler = jest.fn(); + it("trims pageload transactions to the max duration of the transaction's children", async () => { const client = new TestClient( getDefaultClientOptions({ tracesSampleRate: 1, - integrations: [browserTracingIntegration()], - tracesSampler, - }), - ); - setCurrentClient(client); - client.init(); - - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - location: dom.window.document.location, - }), - ); - }); - - it('extracts window.location/self.location for sampling context in navigation transactions', () => { - const tracesSampler = jest.fn(); - const client = new TestClient( - getDefaultClientOptions({ - tracesSampleRate: 1, - integrations: [browserTracingIntegration({ instrumentPageLoad: false })], - tracesSampler, - }), - ); - setCurrentClient(client); - client.init(); - - // this is what is used to get the span name - JSDOM does not update this on it's own! - const dom = new JSDOM(undefined, { url: 'https://example.com/test' }); - Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); - - WINDOW.history.pushState({}, '', '/test'); - - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - location: dom.window.document.location, - }), - ); - }); - - it("trims pageload transactions to the max duration of the transaction's children", () => { - const client = new TestClient( - getDefaultClientOptions({ - tracesSampleRate: 1, - integrations: [browserTracingIntegration()], + integrations: [browserTracingIntegration({ idleTimeout: 10 })], }), ); @@ -265,7 +221,9 @@ describe('browserTracingIntegration', () => { const timestamp = timestampInSeconds(); childSpan.end(timestamp); - pageloadSpan?.end(timestamp + 12345); + + // Wait for 10ms for idle timeout + await new Promise(resolve => setTimeout(resolve, 10)); expect(spanToJSON(pageloadSpan!).timestamp).toBe(timestamp); }); @@ -628,43 +586,8 @@ describe('browserTracingIntegration', () => { }); }); - it('sets transaction context from sentry-trace header for pageload transactions', () => { - const name = 'sentry-trace'; - const content = '126de09502ae4e0fb26c6967190756a4-b6e54397b12a2a0f-1'; - document.head.innerHTML = - `` + ''; - const startIdleTransaction = jest.spyOn(hubExtensions, 'startIdleTransaction'); - - const client = new TestClient( - getDefaultClientOptions({ - tracesSampleRate: 1, - integrations: [browserTracingIntegration()], - }), - ); - setCurrentClient(client); - client.init(); - - expect(startIdleTransaction).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - traceId: '126de09502ae4e0fb26c6967190756a4', - parentSpanId: 'b6e54397b12a2a0f', - parentSampled: true, - metadata: { - dynamicSamplingContext: { release: '2.1.14' }, - }, - }), - expect.any(Number), - expect.any(Number), - expect.any(Boolean), - expect.any(Object), - expect.any(Number), - true, - ); - }); - describe('using the tag data', () => { - it('uses the tracing data for pageload transactions', () => { + it('uses the tracing data for pageload span', () => { // make sampled false here, so we can see that it's being used rather than the tracesSampleRate-dictated one document.head.innerHTML = '' + @@ -672,6 +595,7 @@ describe('browserTracingIntegration', () => { const client = new TestClient( getDefaultClientOptions({ + tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), ); @@ -680,24 +604,27 @@ describe('browserTracingIntegration', () => { // pageload transactions are created as part of the browserTracingIntegration's initialization client.init(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - // eslint-disable-next-line deprecation/deprecation - const dynamicSamplingContext = transaction.getDynamicSamplingContext()!; - - expect(transaction).toBeDefined(); - expect(spanToJSON(transaction).op).toBe('pageload'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.traceId).toEqual('12312012123120121231201212312012'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.parentSpanId).toEqual('1121201211212012'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.sampled).toBe(false); + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan!); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span is correct + expect(spanToJSON(idleSpan).op).toBe('pageload'); + expect(spanToJSON(idleSpan).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(idleSpan).parent_span_id).toEqual('1121201211212012'); + expect(spanIsSampled(idleSpan)).toBe(false); + expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({ release: '2.1.14' }); + + // Propagation context is reset and does not contain the meta tag data + expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); }); - it('puts frozen Dynamic Sampling Context on pageload transactions if sentry-trace data and only 3rd party baggage is present', () => { + it('puts frozen Dynamic Sampling Context on pageload span if sentry-trace data and only 3rd party baggage is present', () => { // make sampled false here, so we can see that it's being used rather than the tracesSampleRate-dictated one document.head.innerHTML = '' + @@ -705,6 +632,7 @@ describe('browserTracingIntegration', () => { const client = new TestClient( getDefaultClientOptions({ + tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), ); @@ -713,29 +641,34 @@ describe('browserTracingIntegration', () => { // pageload transactions are created as part of the browserTracingIntegration's initialization client.init(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - // eslint-disable-next-line deprecation/deprecation - const dynamicSamplingContext = transaction.getDynamicSamplingContext()!; - - expect(transaction).toBeDefined(); - expect(spanToJSON(transaction).op).toBe('pageload'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.traceId).toEqual('12312012123120121231201212312012'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.parentSpanId).toEqual('1121201211212012'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.sampled).toBe(false); + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span is correct + expect(spanToJSON(idleSpan).op).toBe('pageload'); + expect(spanToJSON(idleSpan).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(idleSpan).parent_span_id).toEqual('1121201211212012'); + expect(spanIsSampled(idleSpan)).toBe(false); + + expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({}); + + // Propagation context is reset and does not contain the meta tag data + expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); }); - it('ignores the meta tag data for navigation transactions', () => { + it('ignores the meta tag data for navigation spans', () => { document.head.innerHTML = '' + ''; const client = new TestClient( getDefaultClientOptions({ + tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), ); @@ -750,21 +683,30 @@ describe('browserTracingIntegration', () => { WINDOW.history.pushState({}, '', '/navigation-test'); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - // eslint-disable-next-line deprecation/deprecation - const dynamicSamplingContext = transaction.getDynamicSamplingContext()!; - - expect(transaction).toBeDefined(); - expect(spanToJSON(transaction).op).toBe('navigation'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.traceId).not.toEqual('12312012123120121231201212312012'); - // eslint-disable-next-line deprecation/deprecation - expect(transaction.parentSpanId).toBeUndefined(); - expect(dynamicSamplingContext).toMatchObject({ - trace_id: expect.not.stringMatching('12312012123120121231201212312012'), + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span is correct + expect(spanToJSON(idleSpan).op).toBe('navigation'); + expect(spanToJSON(idleSpan).trace_id).not.toEqual('12312012123120121231201212312012'); + expect(spanToJSON(idleSpan).parent_span_id).not.toEqual('1121201211212012'); + expect(spanIsSampled(idleSpan)).toBe(true); + + expect(dynamicSamplingContext).toBeDefined(); + expect(dynamicSamplingContext).toStrictEqual({ + environment: 'production', + public_key: 'username', + sample_rate: '1', + sampled: 'true', + trace_id: expect.not.stringContaining('12312012123120121231201212312012'), }); - transaction.end(); + + // Propagation context is correct + expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); }); }); @@ -780,19 +722,27 @@ describe('browserTracingIntegration', () => { setCurrentClient(client); client.init(); - const mockFinish = jest.fn(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - transaction.sendAutoFinishSignal(); - transaction.end = mockFinish; + const spans: Span[] = []; + client.on('spanEnd', span => { + spans.push(span); + }); + + const idleSpan = getActiveSpan(); + expect(idleSpan).toBeDefined(); + + client.emit('idleSpanEnableAutoFinish', idleSpan!); - // eslint-disable-next-line deprecation/deprecation - const span = transaction.startChild(); // activities = 1 - span.end(); // activities = 0 + const span = startInactiveSpan({ name: 'inner1' }); + span?.end(); // activities = 0 + + // inner1 is now ended, all good + expect(spans).toHaveLength(1); - expect(mockFinish).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout); - expect(mockFinish).toHaveBeenCalledTimes(1); + + // idle span itself is now ended + expect(spans).toHaveLength(2); + expect(spans[1]).toBe(idleSpan); }); it('can be a custom value', () => { @@ -807,19 +757,27 @@ describe('browserTracingIntegration', () => { setCurrentClient(client); client.init(); - const mockFinish = jest.fn(); - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction() as IdleTransaction; - transaction.sendAutoFinishSignal(); - transaction.end = mockFinish; + const spans: Span[] = []; + client.on('spanEnd', span => { + spans.push(span); + }); + + const idleSpan = getActiveSpan(); + expect(idleSpan).toBeDefined(); + + client.emit('idleSpanEnableAutoFinish', idleSpan!); - // eslint-disable-next-line deprecation/deprecation - const span = transaction.startChild(); // activities = 1 - span.end(); // activities = 0 + const span = startInactiveSpan({ name: 'inner1' }); + span?.end(); // activities = 0 + + // inner1 is now ended, all good + expect(spans).toHaveLength(1); - expect(mockFinish).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(2000); - expect(mockFinish).toHaveBeenCalledTimes(1); + + // idle span itself is now ended + expect(spans).toHaveLength(2); + expect(spans[1]).toBe(idleSpan); }); });