From 09475649f21558e0dd19956ad7dd70984d90b59a Mon Sep 17 00:00:00 2001 From: Archish Date: Mon, 31 Aug 2026 11:46:23 +0530 Subject: [PATCH] :bug: fix for appstart span when getting started --- package.json | 2 +- src/__tests__/appStart.test.ts | 102 +++++++++++++++++++++++++++++++++ src/middlewareRum.ts | 21 ++++++- 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/appStart.test.ts diff --git a/package.json b/package.json index f0ce739..77bc0cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@middleware.io/middleware-react-native", - "version": "2.1.2", + "version": "2.1.3", "description": "Middleware React Native real user monitoring SDK", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/__tests__/appStart.test.ts b/src/__tests__/appStart.test.ts new file mode 100644 index 0000000..c6c0987 --- /dev/null +++ b/src/__tests__/appStart.test.ts @@ -0,0 +1,102 @@ +import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import type { AppStartInfo } from '../native'; + +/** + * Loads middlewareRum.ts with a stubbed native bridge. The app-start payload + * is resolved lazily so a test can build it relative to the session that the + * freshly loaded session module just started. + */ +function loadRum() { + const holder: { info: AppStartInfo } = { + info: { moduleStart: Date.now(), isColdStart: true }, + }; + let mod: any; + jest.isolateModules(() => { + jest.doMock('../native', () => { + // Every bridge call is a no-op under test; only the app-start payload + // and the "no native SDK" answers need to be real. + const stubs: Record = { + __esModule: true, + initializeNativeSdk: jest + .fn() + .mockImplementation(() => Promise.resolve(holder.info)), + isNativeSdkAvailable: jest.fn().mockReturnValue(false), + isNativeExporterUsable: jest.fn().mockReturnValue(false), + isNativeRecording: jest.fn().mockResolvedValue(false), + }; + return new Proxy(stubs, { + get(target, key: string) { + if (!(key in target)) { + target[key] = jest.fn(); + } + return target[key]; + }, + }); + }); + mod = { + rum: require('../middlewareRum').MiddlewareRum, + sessionStart: require('../session').getSessionStartTime() as number, + }; + }); + return { ...mod, holder }; +} + +const CONFIG = { + target: 'https://myproject.middleware.io', + accountKey: 'key', + projectName: 'proj', + serviceName: 'svc', + appStartEnabled: true, +}; + +/** Start time the AppStart span was actually created with, in epoch ms. */ +function appStartMs(rum: any): number { + return hrTimeToMilliseconds((rum.appStartSpan as any).startTime); +} + +describe('AppStart span start time', () => { + // The XHR instrumentation patches XMLHttpRequest.prototype on construction; + // the node test environment has no such global. + beforeAll(() => { + (global as any).XMLHttpRequest = class { + open() {} + send() {} + setRequestHeader() {} + addEventListener() {} + }; + }); + + it('keeps the native app start when it falls inside the current session', async () => { + const { rum, sessionStart, holder } = loadRum(); + const appStart = sessionStart + 5; + holder.info = { appStart, moduleStart: appStart, isColdStart: true }; + + rum.init(CONFIG); + await new Promise((r) => setImmediate(r)); + + expect(rum.appStartSpan).toBeDefined(); + expect(appStartMs(rum)).toBe(appStart); + expect((rum.appStartSpan as any).attributes['start.type']).toBe('cold'); + }); + + it('does not backdate AppStart to a process that outlived an earlier session', async () => { + const { rum, sessionStart, holder } = loadRum(); + // A native process alive since the previous day — what a surviving + // process reports once the JS context has been recreated. Left unclamped + // this rewrites SessionStart, which the backend derives from + // min(span timestamp), and a 15-minute session reads as 11 hours long. + const staleStart = sessionStart - 11 * 60 * 60 * 1000; + holder.info = { + appStart: staleStart, + moduleStart: staleStart, + isColdStart: true, + }; + + rum.init(CONFIG); + await new Promise((r) => setImmediate(r)); + + expect(rum.appStartSpan).toBeDefined(); + expect(appStartMs(rum)).toBe(sessionStart); + expect((rum.appStartSpan as any).attributes['start.type']).toBe('warm'); + }); +}); diff --git a/src/middlewareRum.ts b/src/middlewareRum.ts index 5adfd91..2cf7d9b 100644 --- a/src/middlewareRum.ts +++ b/src/middlewareRum.ts @@ -556,11 +556,26 @@ export const MiddlewareRum: MiddlewareRumType = { initializeNativeSdk(nativeSdkConf) .then((nativeAppStart) => { appStartInfo = nativeAppStart; - appStartInfo.isColdStart = appStartInfo.isColdStart || true; - appStartInfo.appStart = - appStartInfo.appStart || appStartInfo.moduleStart; + appStartInfo.isColdStart = appStartInfo.isColdStart ?? false; setNativeSessionId(getSessionId(), getSessionStartTime()); + // `appStart` and `moduleStart` are both latched in the native process + // (a static field set at class load / native module construction), so + // a process that outlives its JS context reports a start time from an + // earlier session. Backdating the AppStart span to it rewrites the + // session's start on the backend, which derives SessionStart from + // min(span timestamp) — a 15-minute session then reads as however long + // the process has been alive. Never let AppStart precede its session. + const sessionStart = getSessionStartTime(); + const nativeAppStartTime = + appStartInfo.appStart || appStartInfo.moduleStart; + if (nativeAppStartTime < sessionStart) { + appStartInfo.appStart = sessionStart; + appStartInfo.isColdStart = false; + } else { + appStartInfo.appStart = nativeAppStartTime; + } + if (config.appStartEnabled) { const tracer = provider.getTracer('AppStart'); const nativeInitEnd = Date.now();