Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
102 changes: 102 additions & 0 deletions src/__tests__/appStart.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
__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');
});
});
21 changes: 18 additions & 3 deletions src/middlewareRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading