Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
### Fixes

- Stop the Hermes sampling profiler on React instance teardown to prevent `pthread_kill` SIGABRT when the JS thread is torn down with profiling active ([#6035](https://github.com/getsentry/sentry-react-native/pull/6035))
- Discard invalid navigation/interaction transactions via an event processor instead of mutating the internal `_sampled` flag, removing misleading "dropped due to sampling" debug logs ([#6051](https://github.com/getsentry/sentry-react-native/pull/6051))

### Dependencies

Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../../measurements';
import { convertSpanToTransaction, isRootSpan, setEndTimeValue } from '../../utils/span';
import { NATIVE } from '../../wrapper';
import { getRootSpanDiscardReason } from '../onSpanEndUtils';
import {
APP_START_COLD as APP_START_COLD_OP,
APP_START_WARM as APP_START_WARM_OP,
Expand Down Expand Up @@ -353,14 +354,14 @@ export const appStartIntegration = ({
const recordFirstStartedActiveRootSpanId = (rootSpan: Span): void => {
if (firstStartedActiveRootSpanId) {
// Check if the previously locked span was dropped after it ended (e.g., by
// ignoreEmptyRouteChangeTransactions or ignoreEmptyBackNavigation setting
// _sampled = false during spanEnd). If so, reset and allow this new span.
// ignoreEmptyRouteChangeTransactions or ignoreEmptyBackNavigation marking
// it for discard during spanEnd). If so, reset and allow this new span.
// We check here (at the next spanStart) rather than at spanEnd because
// the discard listeners run after the app start listener in registration order,
// so _sampled is not yet false when our own spanEnd listener would fire.
if (firstStartedActiveRootSpan && !spanIsSampled(firstStartedActiveRootSpan)) {
// so the discard attribute is not yet set when our own spanEnd listener would fire.
if (firstStartedActiveRootSpan && getRootSpanDiscardReason(firstStartedActiveRootSpan) !== undefined) {
debug.log(
'[AppStart] Previously locked root span was unsampled after ending. Resetting to allow next transaction.',
'[AppStart] Previously locked root span was marked for discard after ending. Resetting to allow next transaction.',
);
resetFirstStartedActiveRootSpanId();
// Fall through to lock to this new span
Expand Down
63 changes: 55 additions & 8 deletions packages/core/src/js/tracing/onSpanEndUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
import type { Client, Span } from '@sentry/core';
import type { Client, Event, Span } from '@sentry/core';
import type { AppStateStatus } from 'react-native';

import { debug, getSpanDescendants, SPAN_STATUS_ERROR, spanToJSON, timestampInSeconds } from '@sentry/core';
import { AppState, Platform } from 'react-native';

import { isRootSpan, isSentrySpan } from '../utils/span';

/**
* Attribute used to mark root spans whose corresponding transaction event
* should be dropped by the React Native tracing event processor instead of
* being treated as sampled-out by `@sentry/core`.
*
* The value is a stable string identifier describing why the SDK chose to
* discard the transaction (e.g. an empty back-navigation, a route-change
* transaction that never received route information, or a childless idle
* transaction).
*/
export const SENTRY_DISCARD_REASON_ATTRIBUTE = 'sentry.rn.discard_reason';

export type SentryDiscardReason =
| 'empty_back_navigation'
| 'no_route_info'
| 'no_child_spans'
| 'discarded_latest_navigation';

/**
* Marks a root span so its transaction event will be filtered out by the
* tracing integration's event processor. The span itself is left with its
* original sampling decision so debug logs no longer report "dropped due to
* sampling" for SDK-side discards.
*/
export function markRootSpanForDiscard(span: Span, reason: SentryDiscardReason): void {
span.setAttribute(SENTRY_DISCARD_REASON_ATTRIBUTE, reason);
}

/**
* Returns the SDK-side discard reason recorded on a root span, if any.
*/
export function getRootSpanDiscardReason(span: Span): string | undefined {
Comment thread
alwx marked this conversation as resolved.
Outdated
const value = spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE];
return typeof value === 'string' ? value : undefined;
}

/**
* Returns the SDK-side discard reason from a transaction event, if any.
*/
export function getTransactionEventDiscardReason(event: Event): string | undefined {
const value = event.contexts?.trace?.data?.[SENTRY_DISCARD_REASON_ATTRIBUTE];
return typeof value === 'string' ? value : undefined;
}

/**
* The time to wait after the app enters the `inactive` state on iOS before
* cancelling the span.
Expand Down Expand Up @@ -104,7 +148,6 @@ function discardEmptyNavigationSpan(
const meaningfulChildren = getMeaningfulChildSpans(span);
if (meaningfulChildren.length <= 0) {
onDiscardFn(span);
span['_sampled'] = false;
}
});
}
Expand All @@ -115,11 +158,12 @@ export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span
span,
// Only discard if route has been seen before
span => spanToJSON(span).data?.['route.has_been_seen'] === true,
// Log message when discarding
() => {
// Log message and mark the span for discard via the event processor.
span => {
debug.log(
'Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.',
);
markRootSpanForDiscard(span, 'empty_back_navigation');
},
);
};
Expand Down Expand Up @@ -151,10 +195,13 @@ export const ignoreEmptyRouteChangeTransactions = (
spanJSON.description === defaultNavigationSpanName && !spanJSON.data?.['route.name'] && isSpanStillTracked()
);
},
// Log and record dropped event
_span => {
// Log and mark the span for discard. The actual `recordDroppedEvent` call
// happens in the tracing integration's event processor with the correct
// `event_processor` reason, so we no longer record it here as a sampling
// drop.
span => {
debug.log(`Discarding empty "${defaultNavigationSpanName}" transaction that never received route information.`);
client?.recordDroppedEvent('sample_rate', 'transaction');
markRootSpanForDiscard(span, 'no_route_info');
},
);
};
Expand All @@ -180,7 +227,7 @@ export const onlySampleIfChildSpans = (client: Client, span: Span): void => {
if (children.length <= 1) {
// Span always has at lest one child, itself
debug.log(`Not sampling as ${spanToJSON(span).op} transaction has no child spans.`);
span['_sampled'] = false;
markRootSpanForDiscard(span, 'no_child_spans');
}
});
};
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/js/tracing/reactnativenavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import type { EmitterSubscription } from '../utils/rnlibrariesinterface';
import type { ReactNativeTracingIntegration } from './reactnativetracing';

import { isSentrySpan } from '../utils/span';
import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils';
import {
ignoreEmptyBackNavigation,
ignoreEmptyRouteChangeTransactions,
markRootSpanForDiscard,
} from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NATIVE_NAVIGATION } from './origin';
import { getReactNativeTracingIntegration } from './reactnativetracing';
import {
Expand Down Expand Up @@ -221,7 +225,7 @@ export const reactNativeNavigationIntegration = ({
const discardLatestNavigationSpan = (): void => {
if (latestNavigationSpan) {
if (isSentrySpan(latestNavigationSpan)) {
latestNavigationSpan['_sampled'] = false;
markRootSpanForDiscard(latestNavigationSpan, 'discarded_latest_navigation');
}
// TODO: What if it's not SentrySpan?
latestNavigationSpan.end();
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/js/tracing/reactnativetracing.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Client, Event, Integration, StartSpanOptions } from '@sentry/core';

import { instrumentOutgoingRequests } from '@sentry/browser';
import { getClient } from '@sentry/core';
import { debug, getClient } from '@sentry/core';

import { isWeb } from '../utils/environment';
import { getDevServer } from './../integrations/debugsymbolicatorutils';
import { getTransactionEventDiscardReason } from './onSpanEndUtils';
import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span';

export const INTEGRATION_NAME = 'ReactNativeTracing';
Expand Down Expand Up @@ -136,7 +137,16 @@ export const reactNativeTracingIntegration = (
});
};

const processEvent = (event: Event): Event => {
const processEvent = (event: Event, _hint: unknown, client: Client): Event | null => {
Comment thread
alwx marked this conversation as resolved.
Outdated
if (event.type === 'transaction') {
const discardReason = getTransactionEventDiscardReason(event);
if (discardReason) {
debug.log(`[ReactNativeTracing] Dropping transaction marked for discard (reason: ${discardReason}).`);
client.recordDroppedEvent('event_processor', 'transaction');
return null;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

if (event.contexts && state.currentRoute) {
event.contexts.app = { view_names: [state.currentRoute], ...event.contexts.app };
}
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { getAppRegistryIntegration } from '../integrations/appRegistry';
import { isSentrySpan } from '../utils/span';
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
import { NATIVE } from '../wrapper';
import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils';
import {
ignoreEmptyBackNavigation,
ignoreEmptyRouteChangeTransactions,
markRootSpanForDiscard,
} from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
import { getReactNativeTracingIntegration } from './reactnativetracing';
import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
Expand Down Expand Up @@ -535,7 +539,7 @@ export const reactNavigationIntegration = ({
const _discardLatestTransaction = (): void => {
if (latestNavigationSpan) {
if (isSentrySpan(latestNavigationSpan)) {
latestNavigationSpan['_sampled'] = false;
markRootSpanForDiscard(latestNavigationSpan, 'discarded_latest_navigation');
}
// TODO: What if it's not SentrySpan?
latestNavigationSpan.end();
Expand Down
9 changes: 5 additions & 4 deletions packages/core/test/tracing/integrations/appStart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,11 @@ describe('App Start Integration', () => {
});

// Simulate the span being dropped (e.g., ignoreEmptyRouteChangeTransactions
// sets _sampled = false during spanEnd processing).
// Note: _sampled is a @sentry/core SentrySpan internal — this couples to the
// same mechanism used by onSpanEndUtils.ts (discardEmptyNavigationSpan).
(firstSpan as any)['_sampled'] = false;
// marking the root span via the `sentry.rn.discard_reason` attribute during
// spanEnd processing). The tracing integration's event processor drops the
// resulting transaction; appStart detects the marker on the next spanStart
// and resets the lock so the next root span can attach app start data.
firstSpan.setAttribute('sentry.rn.discard_reason', 'no_route_info');

// Second root span starts — recordFirstStartedActiveRootSpanId detects
// the previously locked span is no longer sampled and resets the lock
Expand Down
71 changes: 70 additions & 1 deletion packages/core/test/tracing/onSpanEndUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Client, Span } from '@sentry/core';

import { getClient, startSpanManual } from '@sentry/core';
import { getClient, spanToJSON, startSpan, startSpanManual } from '@sentry/core';

import {
adjustTransactionDuration,
Expand All @@ -9,6 +9,7 @@ import {
ignoreEmptyRouteChangeTransactions,
onlySampleIfChildSpans,
onThisSpanEnd,
SENTRY_DISCARD_REASON_ATTRIBUTE,
} from '../../src/js/tracing/onSpanEndUtils';
import { setupTestClient } from '../mocks/client';

Expand Down Expand Up @@ -131,6 +132,41 @@ describe('onSpanEndUtils', () => {
span.end();
expect(getUnsubscribeCount()).toBe(1);
});

it('marks the span for discard without mutating sampling', () => {
const client = getClient()!;
const span = createRootSpan('target') as Span & { _sampled?: boolean };
span.setAttribute('route.has_been_seen', true);

ignoreEmptyBackNavigation(client, span);
span.end();

expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('empty_back_navigation');
expect(span._sampled).not.toBe(false);
});

it('does not mark the span when the route has not been seen', () => {
const client = getClient()!;
const span = createRootSpan('target');

ignoreEmptyBackNavigation(client, span);
span.end();

expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined();
});

it('does not mark the span when meaningful child spans exist', () => {
const client = getClient()!;
let parent: Span | undefined;
startSpan({ name: 'parent', forceTransaction: true }, span => {
parent = span;
span.setAttribute('route.has_been_seen', true);
ignoreEmptyBackNavigation(client, span);
startSpan({ name: 'meaningful child' }, () => undefined);
});

expect(spanToJSON(parent!).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined();
});
});

describe('ignoreEmptyRouteChangeTransactions', () => {
Expand All @@ -145,6 +181,28 @@ describe('onSpanEndUtils', () => {
span.end();
expect(getUnsubscribeCount()).toBe(1);
});

it('marks the span for discard when the route name is missing', () => {
const client = getClient()!;
const span = createRootSpan('Route Change') as Span & { _sampled?: boolean };

ignoreEmptyRouteChangeTransactions(client, span, 'Route Change', () => true);
span.end();

expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('no_route_info');
expect(span._sampled).not.toBe(false);
});

it('does not mark the span when route info has been received', () => {
const client = getClient()!;
const span = createRootSpan('Route Change');
span.setAttribute('route.name', 'Home');

ignoreEmptyRouteChangeTransactions(client, span, 'Route Change', () => true);
span.end();

expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined();
});
});

describe('onlySampleIfChildSpans', () => {
Expand All @@ -159,6 +217,17 @@ describe('onSpanEndUtils', () => {
span.end();
expect(getUnsubscribeCount()).toBe(1);
});

it('marks childless root spans for discard', () => {
const client = getClient()!;
const span = createRootSpan('target') as Span & { _sampled?: boolean };

onlySampleIfChildSpans(client, span);
span.end();

expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('no_child_spans');
expect(span._sampled).not.toBe(false);
});
});

describe('cancelInBackground', () => {
Expand Down
Loading
Loading