Skip to content

Commit 995d553

Browse files
authored
Merge branch 'main' into dependabot/bundler/performance-tests/json-2.17.1.2
2 parents e4defcf + c3ac6b1 commit 995d553

2 files changed

Lines changed: 234 additions & 13 deletions

File tree

packages/core/src/js/tracing/onSpanEndUtils.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ const IOS_INACTIVE_CANCEL_DELAY_MS = 5_000;
1414
* Hooks on span end event to execute a callback when the span ends.
1515
*/
1616
export function onThisSpanEnd(client: Client, span: Span, callback: (span: Span) => void): void {
17-
client.on('spanEnd', (endedSpan: Span) => {
17+
const unsubscribe = client.on('spanEnd', (endedSpan: Span) => {
1818
if (span !== endedSpan) {
1919
return;
2020
}
21+
unsubscribe();
2122
callback(endedSpan);
2223
});
2324
}
@@ -28,10 +29,11 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio
2829
return;
2930
}
3031

31-
client.on('spanEnd', (endedSpan: Span) => {
32+
const unsubscribe = client.on('spanEnd', (endedSpan: Span) => {
3233
if (endedSpan !== span) {
3334
return;
3435
}
36+
unsubscribe();
3537

3638
const endTimestamp = spanToJSON(span).timestamp;
3739
const startTimestamp = spanToJSON(span).start_timestamp;
@@ -87,10 +89,11 @@ function discardEmptyNavigationSpan(
8789
return;
8890
}
8991

90-
client.on('spanEnd', (endedSpan: Span) => {
92+
const unsubscribe = client.on('spanEnd', (endedSpan: Span) => {
9193
if (endedSpan !== span) {
9294
return;
9395
}
96+
unsubscribe();
9497

9598
if (!shouldDiscardFn(span)) {
9699
return;
@@ -164,10 +167,11 @@ export const onlySampleIfChildSpans = (client: Client, span: Span): void => {
164167
return;
165168
}
166169

167-
client.on('spanEnd', (endedSpan: Span) => {
170+
const unsubscribe = client.on('spanEnd', (endedSpan: Span) => {
168171
if (endedSpan !== span) {
169172
return;
170173
}
174+
unsubscribe();
171175

172176
const children = getSpanDescendants(span);
173177

@@ -221,15 +225,18 @@ export const cancelInBackground = (client: Client, span: Span): void => {
221225
}
222226
});
223227

224-
subscription &&
225-
client.on('spanEnd', (endedSpan: Span) => {
226-
if (endedSpan === span) {
227-
debug.log(`Removing AppState listener for ${spanToJSON(span).op} transaction.`);
228-
if (inactiveTimeout !== undefined) {
229-
clearTimeout(inactiveTimeout);
230-
inactiveTimeout = undefined;
231-
}
232-
subscription?.remove?.();
228+
if (subscription) {
229+
const unsubscribe = client.on('spanEnd', (endedSpan: Span) => {
230+
if (endedSpan !== span) {
231+
return;
233232
}
233+
unsubscribe();
234+
debug.log(`Removing AppState listener for ${spanToJSON(span).op} transaction.`);
235+
if (inactiveTimeout !== undefined) {
236+
clearTimeout(inactiveTimeout);
237+
inactiveTimeout = undefined;
238+
}
239+
subscription.remove?.();
234240
});
241+
}
235242
};
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { Client, Span } from '@sentry/core';
2+
import { getClient, startSpanManual } from '@sentry/core';
3+
import {
4+
adjustTransactionDuration,
5+
cancelInBackground,
6+
ignoreEmptyBackNavigation,
7+
ignoreEmptyRouteChangeTransactions,
8+
onlySampleIfChildSpans,
9+
onThisSpanEnd,
10+
} from '../../src/js/tracing/onSpanEndUtils';
11+
import { setupTestClient } from '../mocks/client';
12+
13+
jest.mock('react-native', () => ({
14+
AppState: {
15+
isAvailable: true,
16+
currentState: 'active',
17+
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
18+
},
19+
Platform: { OS: 'android' },
20+
NativeModules: { RNSentry: {} },
21+
}));
22+
23+
/**
24+
* Wraps client.on to intercept the unsubscribe functions returned by each call.
25+
* Returns a getter for how many times any of those unsubscribe functions were called.
26+
*/
27+
function trackUnsubscribes(client: Client): () => number {
28+
let count = 0;
29+
const originalOn = client.on.bind(client);
30+
jest
31+
.spyOn(client, 'on')
32+
.mockImplementation((hook: Parameters<Client['on']>[0], callback: Parameters<Client['on']>[1]) => {
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
const realUnsubscribe = (originalOn as any)(hook, callback);
35+
return () => {
36+
count++;
37+
realUnsubscribe();
38+
};
39+
});
40+
return () => count;
41+
}
42+
43+
function createRootSpan(name: string): Span {
44+
return startSpanManual({ name, forceTransaction: true }, span => span);
45+
}
46+
47+
describe('onSpanEndUtils', () => {
48+
beforeEach(() => {
49+
setupTestClient();
50+
});
51+
52+
afterEach(() => {
53+
jest.clearAllMocks();
54+
});
55+
56+
describe('onThisSpanEnd', () => {
57+
it('calls callback when the target span ends', () => {
58+
const client = getClient()!;
59+
const callback = jest.fn();
60+
const span = createRootSpan('target');
61+
62+
onThisSpanEnd(client, span, callback);
63+
span.end();
64+
65+
expect(callback).toHaveBeenCalledTimes(1);
66+
expect(callback).toHaveBeenCalledWith(span);
67+
});
68+
69+
it('does not call callback when a different span ends', () => {
70+
const client = getClient()!;
71+
const callback = jest.fn();
72+
const targetSpan = createRootSpan('target');
73+
const otherSpan = createRootSpan('other');
74+
75+
onThisSpanEnd(client, targetSpan, callback);
76+
otherSpan.end();
77+
78+
expect(callback).not.toHaveBeenCalled();
79+
});
80+
81+
it('unsubscribes the listener after the target span ends', () => {
82+
const client = getClient()!;
83+
const getUnsubscribeCount = trackUnsubscribes(client);
84+
const span = createRootSpan('target');
85+
86+
onThisSpanEnd(client, span, jest.fn());
87+
expect(getUnsubscribeCount()).toBe(0);
88+
89+
span.end();
90+
expect(getUnsubscribeCount()).toBe(1);
91+
});
92+
93+
it('does not call callback for spans ending after the target span', () => {
94+
const client = getClient()!;
95+
const callback = jest.fn();
96+
const targetSpan = createRootSpan('target');
97+
const laterSpan = createRootSpan('later');
98+
99+
onThisSpanEnd(client, targetSpan, callback);
100+
targetSpan.end(); // fires callback, listener unsubscribes
101+
laterSpan.end(); // listener is gone — callback must not fire again
102+
103+
expect(callback).toHaveBeenCalledTimes(1);
104+
});
105+
});
106+
107+
describe('adjustTransactionDuration', () => {
108+
it('unsubscribes the listener after the span ends', () => {
109+
const client = getClient()!;
110+
const getUnsubscribeCount = trackUnsubscribes(client);
111+
const span = createRootSpan('target');
112+
113+
adjustTransactionDuration(client, span, 60_000);
114+
expect(getUnsubscribeCount()).toBe(0);
115+
116+
span.end();
117+
expect(getUnsubscribeCount()).toBe(1);
118+
});
119+
});
120+
121+
describe('ignoreEmptyBackNavigation', () => {
122+
it('unsubscribes the listener after the span ends', () => {
123+
const client = getClient()!;
124+
const getUnsubscribeCount = trackUnsubscribes(client);
125+
const span = createRootSpan('target');
126+
127+
ignoreEmptyBackNavigation(client, span);
128+
expect(getUnsubscribeCount()).toBe(0);
129+
130+
span.end();
131+
expect(getUnsubscribeCount()).toBe(1);
132+
});
133+
});
134+
135+
describe('ignoreEmptyRouteChangeTransactions', () => {
136+
it('unsubscribes the listener after the span ends', () => {
137+
const client = getClient()!;
138+
const getUnsubscribeCount = trackUnsubscribes(client);
139+
const span = createRootSpan('Route Change');
140+
141+
ignoreEmptyRouteChangeTransactions(client, span, 'Route Change', () => true);
142+
expect(getUnsubscribeCount()).toBe(0);
143+
144+
span.end();
145+
expect(getUnsubscribeCount()).toBe(1);
146+
});
147+
});
148+
149+
describe('onlySampleIfChildSpans', () => {
150+
it('unsubscribes the listener after the span ends', () => {
151+
const client = getClient()!;
152+
const getUnsubscribeCount = trackUnsubscribes(client);
153+
const span = createRootSpan('target');
154+
155+
onlySampleIfChildSpans(client, span);
156+
expect(getUnsubscribeCount()).toBe(0);
157+
158+
span.end();
159+
expect(getUnsubscribeCount()).toBe(1);
160+
});
161+
});
162+
163+
describe('cancelInBackground', () => {
164+
it('removes the AppState subscription when the span ends normally', () => {
165+
const { AppState } = jest.requireMock('react-native');
166+
const removeMock = jest.fn();
167+
(AppState.addEventListener as jest.Mock).mockReturnValueOnce({ remove: removeMock });
168+
169+
const client = getClient()!;
170+
const span = createRootSpan('target');
171+
172+
cancelInBackground(client, span);
173+
expect(removeMock).not.toHaveBeenCalled();
174+
175+
span.end();
176+
expect(removeMock).toHaveBeenCalledTimes(1);
177+
});
178+
179+
it('unsubscribes the spanEnd listener after the span ends', () => {
180+
const client = getClient()!;
181+
const getUnsubscribeCount = trackUnsubscribes(client);
182+
const span = createRootSpan('target');
183+
184+
cancelInBackground(client, span);
185+
expect(getUnsubscribeCount()).toBe(0);
186+
187+
span.end();
188+
expect(getUnsubscribeCount()).toBe(1);
189+
});
190+
});
191+
192+
describe('listener accumulation', () => {
193+
it('does not accumulate listeners across multiple spans', () => {
194+
const client = getClient()!;
195+
const getUnsubscribeCount = trackUnsubscribes(client);
196+
const callbacks = Array.from({ length: 5 }, () => jest.fn());
197+
198+
for (let i = 0; i < 5; i++) {
199+
const span = createRootSpan(`span-${i}`);
200+
onThisSpanEnd(client, span, callbacks[i]);
201+
span.end();
202+
}
203+
204+
// Every registered listener must have unsubscribed itself
205+
expect(getUnsubscribeCount()).toBe(5);
206+
207+
// Ending a new span must not trigger any of the already-fired callbacks
208+
const newSpan = createRootSpan('new');
209+
newSpan.end();
210+
211+
callbacks.forEach(cb => expect(cb).toHaveBeenCalledTimes(1));
212+
});
213+
});
214+
});

0 commit comments

Comments
 (0)