Skip to content

Commit 6543d7f

Browse files
authored
Merge branch 'main' into antonis/bump-tmp
2 parents cec7986 + 44ee752 commit 6543d7f

13 files changed

Lines changed: 490 additions & 197 deletions

File tree

.github/workflows/changelog-preview.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@ permissions:
1414

1515
jobs:
1616
changelog-preview:
17-
uses: getsentry/craft/.github/workflows/changelog-preview.yml@41defb379de52e5f0e3943944fa5575b22fb9f92 # V2
17+
uses: getsentry/craft/.github/workflows/changelog-preview.yml@d4cfac9d25d1fc72c9241e5d22aff559a114e4e9 # V2
1818
secrets: inherit

CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
### Fixes
1212

1313
- Resolve relative `SOURCEMAP_FILE` paths against the project root in the Xcode build script ([#5730](https://github.com/getsentry/sentry-react-native/pull/5730))
14+
- Fixes the issue with unit mismatch in `adjustTransactionDuration` ([#5740](https://github.com/getsentry/sentry-react-native/pull/5740))
15+
- Handle `inactive` state for spans ([#5742](https://github.com/getsentry/sentry-react-native/pull/5742))
1416

1517
### Dependencies
1618

17-
- Bump JavaScript SDK from v10.39.0 to v10.40.0 ([#5715](https://github.com/getsentry/sentry-react-native/pull/5715))
18-
- [changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md#10400)
19-
- [diff](https://github.com/getsentry/sentry-javascript/compare/10.39.0...10.40.0)
19+
- Bump JavaScript SDK from v10.39.0 to v10.41.0 ([#5715](https://github.com/getsentry/sentry-react-native/pull/5715), [#5744](https://github.com/getsentry/sentry-react-native/pull/5744))
20+
- [changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md#10410)
21+
- [diff](https://github.com/getsentry/sentry-javascript/compare/10.39.0...10.41.0)
2022
- Bump Bundler Plugins from v4.9.1 to v5.1.1 ([#5700](https://github.com/getsentry/sentry-react-native/pull/5700))
2123
- [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#511)
2224
- [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/4.9.1...5.1.1)
25+
- Bump CLI from v3.2.2 to v3.2.3 ([#5743](https://github.com/getsentry/sentry-react-native/pull/5743))
26+
- [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#323)
27+
- [diff](https://github.com/getsentry/sentry-cli/compare/3.2.2...3.2.3)
2328

2429
## 8.2.0
2530

dev-packages/e2e-tests/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"devDependencies": {
1414
"@babel/preset-env": "^7.25.3",
1515
"@babel/preset-typescript": "^7.18.6",
16-
"@sentry/core": "10.40.0",
16+
"@sentry/core": "10.41.0",
1717
"@sentry/react-native": "8.2.0",
1818
"@types/node": "^20.9.3",
1919
"@types/react": "^18.2.64",

package.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
},
3030
"devDependencies": {
3131
"@naturalcycles/ktlint": "^1.13.0",
32-
"@sentry/cli": "3.2.2",
32+
"@sentry/cli": "3.2.3",
3333
"downlevel-dts": "^0.11.0",
3434
"google-java-format": "^1.4.0",
3535
"lerna": "^8.1.8",
@@ -60,6 +60,20 @@
6060
],
6161
"resolutions": {
6262
"appium-chromedriver@npm:5.6.73/@xmldom/xmldom": "0.8.10",
63+
"@istanbuljs/load-nyc-config@npm:1.1.0/js-yaml": "^3.14.2",
64+
"@yarnpkg/parsers@npm:3.0.0-rc.46/js-yaml": "^3.14.2",
65+
"cosmiconfig@npm:5.2.1/js-yaml": "^3.14.2",
66+
"front-matter@npm:4.0.2/js-yaml": "^3.14.2",
67+
"js-yaml": "^4.1.1",
68+
"ajv-formats@npm:2.1.1/ajv": "^8.18.0",
69+
"appium@npm:2.4.1/ajv": "^8.18.0",
70+
"detox@npm:20.46.0/ajv": "^8.18.0",
71+
"expo-dev-launcher@npm:6.0.20/ajv": "^8.18.0",
72+
"@eslint/eslintrc@npm:2.1.4/ajv": "^6.14.0",
73+
"@eslint/eslintrc@npm:3.3.3/ajv": "^6.14.0",
74+
"eslint@npm:8.57.0/ajv": "^6.14.0",
75+
"eslint@npm:8.57.1/ajv": "^6.14.0",
76+
"eslint@npm:9.39.2/ajv": "^6.14.0",
6377
"express@npm:4.19.2/path-to-regexp": "0.1.12",
6478
"axios": "^1.13.5",
6579
"fast-xml-parser": "^5.3.6",
@@ -69,7 +83,7 @@
6983
"tar-fs": "^3.1.1",
7084
"on-headers": "^1.1.0",
7185
"diff": "^5.2.2",
72-
"tar": "^7.5.7"
86+
"tar": "^7.5.8"
7387
"tmp": "^0.2.4"
7488
},
7589
"version": "0.0.0",

packages/core/package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,21 @@
6969
},
7070
"dependencies": {
7171
"@sentry/babel-plugin-component-annotate": "5.1.1",
72-
"@sentry/browser": "10.40.0",
73-
"@sentry/cli": "3.2.2",
74-
"@sentry/core": "10.40.0",
75-
"@sentry/react": "10.40.0",
76-
"@sentry/types": "10.40.0"
72+
"@sentry/browser": "10.41.0",
73+
"@sentry/cli": "3.2.3",
74+
"@sentry/core": "10.41.0",
75+
"@sentry/react": "10.41.0",
76+
"@sentry/types": "10.41.0"
7777
},
7878
"devDependencies": {
7979
"@babel/core": "^7.26.7",
8080
"@expo/metro-config": "~0.20.0",
8181
"@mswjs/interceptors": "^0.25.15",
8282
"@react-native/babel-preset": "0.80.0",
83-
"@sentry-internal/eslint-config-sdk": "10.40.0",
84-
"@sentry-internal/eslint-plugin-sdk": "10.40.0",
85-
"@sentry-internal/typescript": "10.40.0",
86-
"@sentry/wizard": "6.11.0",
83+
"@sentry-internal/eslint-config-sdk": "10.41.0",
84+
"@sentry-internal/eslint-plugin-sdk": "10.41.0",
85+
"@sentry-internal/typescript": "10.41.0",
86+
"@sentry/wizard": "6.12.0",
8787
"@testing-library/react-native": "^13.2.2",
8888
"@types/jest": "^29.5.13",
8989
"@types/node": "^20.9.3",

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

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import type { Client, Span } from '@sentry/core';
22
import { debug, getSpanDescendants, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core';
33
import type { AppStateStatus } from 'react-native';
4-
import { AppState } from 'react-native';
4+
import { AppState, Platform } from 'react-native';
55
import { isRootSpan, isSentrySpan } from '../utils/span';
66

7+
/**
8+
* The time to wait after the app enters the `inactive` state on iOS before
9+
* cancelling the span.
10+
*/
11+
const IOS_INACTIVE_CANCEL_DELAY_MS = 5_000;
12+
713
/**
814
* Hooks on span end event to execute a callback when the span ends.
915
*/
@@ -33,8 +39,9 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio
3339
return;
3440
}
3541

36-
const diff = endTimestamp - startTimestamp;
37-
const isOutdatedTransaction = endTimestamp && (diff > maxDurationMs || diff < 0);
42+
const diff = endTimestamp - startTimestamp; // a diff in *seconds*
43+
const isOutdatedTransaction = diff > maxDurationMs / 1000 || diff < 0;
44+
3845
if (isOutdatedTransaction) {
3946
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' });
4047
// TODO: check where was used, might be possible to delete
@@ -174,20 +181,54 @@ export const onlySampleIfChildSpans = (client: Client, span: Span): void => {
174181

175182
/**
176183
* Hooks on AppState change to cancel the span if the app goes background.
184+
*
185+
* On iOS the JS thread can be suspended between the `inactive` and
186+
* `background` transitions, which means the `background` event may never
187+
* reach JS in time. To handle this we schedule a deferred cancellation when
188+
* the app becomes `inactive`. If the app returns to `active` before the
189+
* timeout fires, the cancellation is cleared. If it transitions to
190+
* `background` first, we cancel immediately and clear the timeout.
177191
*/
178192
export const cancelInBackground = (client: Client, span: Span): void => {
193+
let inactiveTimeout: ReturnType<typeof setTimeout> | undefined;
194+
195+
const cancelSpan = (): void => {
196+
if (inactiveTimeout !== undefined) {
197+
clearTimeout(inactiveTimeout);
198+
inactiveTimeout = undefined;
199+
}
200+
debug.log(`Setting ${spanToJSON(span).op} transaction to cancelled because the app is in the background.`);
201+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' });
202+
span.end();
203+
};
204+
179205
const subscription = AppState.addEventListener('change', (newState: AppStateStatus) => {
180206
if (newState === 'background') {
181-
debug.log(`Setting ${spanToJSON(span).op} transaction to cancelled because the app is in the background.`);
182-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' });
183-
span.end();
207+
cancelSpan();
208+
} else if (Platform.OS === 'ios' && newState === 'inactive') {
209+
// Schedule a deferred cancellation — if the JS thread is suspended
210+
// before the 'background' event fires, this timer will execute when
211+
// the app is eventually resumed and end the span.
212+
if (inactiveTimeout === undefined) {
213+
inactiveTimeout = setTimeout(cancelSpan, IOS_INACTIVE_CANCEL_DELAY_MS);
214+
}
215+
} else if (newState === 'active') {
216+
// App returned to foreground — clear any pending inactive cancellation.
217+
if (inactiveTimeout !== undefined) {
218+
clearTimeout(inactiveTimeout);
219+
inactiveTimeout = undefined;
220+
}
184221
}
185222
});
186223

187224
subscription &&
188225
client.on('spanEnd', (endedSpan: Span) => {
189226
if (endedSpan === span) {
190227
debug.log(`Removing AppState listener for ${spanToJSON(span).op} transaction.`);
228+
if (inactiveTimeout !== undefined) {
229+
clearTimeout(inactiveTimeout);
230+
inactiveTimeout = undefined;
231+
}
191232
subscription?.remove?.();
192233
}
193234
});

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
spanToJSON,
1313
startIdleSpan as coreStartIdleSpan,
1414
} from '@sentry/core';
15-
import { AppState } from 'react-native';
15+
import { AppState, Platform } from 'react-native';
1616
import { isRootSpan } from '../utils/span';
1717
import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils';
1818
import {
@@ -30,18 +30,18 @@ export const defaultIdleOptions: {
3030
*
3131
* @default 1_000 (ms)
3232
*/
33-
finalTimeout: number;
33+
idleTimeout: number;
3434

3535
/**
3636
* The max. time an idle span may run.
3737
* If this time is exceeded, the idle span will finish no matter what.
3838
*
39-
* @default 60_0000 (ms)
39+
* @default 600_000 (ms)
4040
*/
41-
idleTimeout: number;
41+
finalTimeout: number;
4242
} = {
4343
idleTimeout: 1_000,
44-
finalTimeout: 60_0000,
44+
finalTimeout: 600_000,
4545
};
4646

4747
export const startIdleNavigationSpan = (
@@ -118,8 +118,10 @@ export const startIdleSpan = (
118118
}
119119

120120
const currentAppState = AppState.currentState;
121-
if (currentAppState === 'background') {
122-
debug.log(`[startIdleSpan] App is already in background, not starting span for ${startSpanOption.name}`);
121+
if (currentAppState === 'background' || (Platform.OS === 'ios' && currentAppState === 'inactive')) {
122+
debug.log(
123+
`[startIdleSpan] App is already in '${currentAppState}' state, not starting span for ${startSpanOption.name}`,
124+
);
123125
return new SentryNonRecordingSpan();
124126
}
125127

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { getClient, spanToJSON, startSpanManual } from '@sentry/core';
2+
import { adjustTransactionDuration } from '../../src/js/tracing/onSpanEndUtils';
3+
import { setupTestClient } from '../mocks/client';
4+
5+
jest.mock('react-native', () => {
6+
return {
7+
AppState: {
8+
isAvailable: true,
9+
currentState: 'active',
10+
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
11+
},
12+
Platform: { OS: 'ios' },
13+
NativeModules: {
14+
RNSentry: {},
15+
},
16+
};
17+
});
18+
19+
describe('adjustTransactionDuration', () => {
20+
beforeEach(() => {
21+
setupTestClient();
22+
});
23+
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
it('marks span as deadline_exceeded when duration exceeds maxDurationMs', () => {
29+
const client = getClient()!;
30+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
31+
32+
const maxDurationMs = 60_000; // 60 seconds
33+
adjustTransactionDuration(client, span, maxDurationMs);
34+
35+
// End the span 120 seconds after it started (exceeds 60s max)
36+
const startTimestamp = spanToJSON(span).start_timestamp;
37+
span.end(startTimestamp + 120);
38+
39+
expect(spanToJSON(span).status).toBe('deadline_exceeded');
40+
expect(spanToJSON(span).data).toMatchObject({ maxTransactionDurationExceeded: 'true' });
41+
});
42+
43+
it('does not mark span as deadline_exceeded when duration is within maxDurationMs', () => {
44+
const client = getClient()!;
45+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
46+
47+
const maxDurationMs = 60_000; // 60 seconds
48+
adjustTransactionDuration(client, span, maxDurationMs);
49+
50+
// End the span 30 seconds after it started (within 60s max)
51+
const startTimestamp = spanToJSON(span).start_timestamp;
52+
span.end(startTimestamp + 30);
53+
54+
expect(spanToJSON(span).status).not.toBe('deadline_exceeded');
55+
expect(spanToJSON(span).data).not.toMatchObject({ maxTransactionDurationExceeded: 'true' });
56+
});
57+
58+
it('does not mark span as deadline_exceeded when duration equals maxDurationMs exactly', () => {
59+
const client = getClient()!;
60+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
61+
62+
const maxDurationMs = 60_000; // 60 seconds
63+
adjustTransactionDuration(client, span, maxDurationMs);
64+
65+
// End the span exactly 60 seconds after it started
66+
const startTimestamp = spanToJSON(span).start_timestamp;
67+
span.end(startTimestamp + 60);
68+
69+
expect(spanToJSON(span).status).not.toBe('deadline_exceeded');
70+
expect(spanToJSON(span).data).not.toMatchObject({ maxTransactionDurationExceeded: 'true' });
71+
});
72+
73+
it('marks span as deadline_exceeded when duration is negative', () => {
74+
const client = getClient()!;
75+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
76+
77+
const maxDurationMs = 60_000;
78+
adjustTransactionDuration(client, span, maxDurationMs);
79+
80+
// End the span before it started (negative duration)
81+
const startTimestamp = spanToJSON(span).start_timestamp;
82+
span.end(startTimestamp - 10);
83+
84+
expect(spanToJSON(span).status).toBe('deadline_exceeded');
85+
expect(spanToJSON(span).data).toMatchObject({ maxTransactionDurationExceeded: 'true' });
86+
});
87+
88+
it('correctly handles maxDurationMs in milliseconds not seconds', () => {
89+
const client = getClient()!;
90+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
91+
92+
// maxDurationMs = 600_000 ms = 600 seconds = 10 minutes
93+
// A span lasting 601 seconds should exceed this limit
94+
const maxDurationMs = 600_000;
95+
adjustTransactionDuration(client, span, maxDurationMs);
96+
97+
const startTimestamp = spanToJSON(span).start_timestamp;
98+
span.end(startTimestamp + 601);
99+
100+
expect(spanToJSON(span).status).toBe('deadline_exceeded');
101+
expect(spanToJSON(span).data).toMatchObject({ maxTransactionDurationExceeded: 'true' });
102+
});
103+
104+
it('does not mark span when duration is 599 seconds with 600_000ms max', () => {
105+
const client = getClient()!;
106+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
107+
108+
// maxDurationMs = 600_000 ms = 600 seconds
109+
// A span lasting 599 seconds should NOT exceed this limit
110+
const maxDurationMs = 600_000;
111+
adjustTransactionDuration(client, span, maxDurationMs);
112+
113+
const startTimestamp = spanToJSON(span).start_timestamp;
114+
span.end(startTimestamp + 599);
115+
116+
expect(spanToJSON(span).status).not.toBe('deadline_exceeded');
117+
expect(spanToJSON(span).data).not.toMatchObject({ maxTransactionDurationExceeded: 'true' });
118+
});
119+
120+
it('does not affect spans from other transactions', () => {
121+
const client = getClient()!;
122+
const trackedSpan = startSpanManual({ name: 'Tracked Transaction', forceTransaction: true }, span => span);
123+
const otherSpan = startSpanManual({ name: 'Other Transaction', forceTransaction: true }, span => span);
124+
125+
const maxDurationMs = 60_000;
126+
adjustTransactionDuration(client, trackedSpan, maxDurationMs);
127+
128+
// End the other span with a duration that exceeds the limit
129+
const otherStartTimestamp = spanToJSON(otherSpan).start_timestamp;
130+
otherSpan.end(otherStartTimestamp + 120);
131+
132+
// Other span should not be affected by adjustTransactionDuration
133+
expect(spanToJSON(otherSpan).status).not.toBe('deadline_exceeded');
134+
135+
// End tracked span within limits
136+
const trackedStartTimestamp = spanToJSON(trackedSpan).start_timestamp;
137+
trackedSpan.end(trackedStartTimestamp + 30);
138+
139+
expect(spanToJSON(trackedSpan).status).not.toBe('deadline_exceeded');
140+
});
141+
142+
it('handles very short maxDurationMs values', () => {
143+
const client = getClient()!;
144+
const span = startSpanManual({ name: 'Test Transaction', forceTransaction: true }, span => span);
145+
146+
// 100ms max duration
147+
const maxDurationMs = 100;
148+
adjustTransactionDuration(client, span, maxDurationMs);
149+
150+
// End after 1 second (1000ms > 100ms)
151+
const startTimestamp = spanToJSON(span).start_timestamp;
152+
span.end(startTimestamp + 1);
153+
154+
expect(spanToJSON(span).status).toBe('deadline_exceeded');
155+
expect(spanToJSON(span).data).toMatchObject({ maxTransactionDurationExceeded: 'true' });
156+
});
157+
});

0 commit comments

Comments
 (0)