feat(mobile): add expo-observe update health tracking

Install expo-observe (56.0.29, matching the SDK 56 line) and configure
its endpoint under expo.extra.eas.observe in app.json, alongside the
existing EAS projectId. Leave dispatchInDebug and sampleRate at their
defaults.

Log an app_started custom event once per cold start from the root
layout's existing "run once" startup effect, and install a global JS
error handler that logs xprem_js_crash and then always delegates to
whatever handler was previously registered, so existing Crashlytics
reporting is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-31 11:11:43 +01:00
1 parent 6bc4e31719
commit 659cee76b6
4 files changed
+197 -21

No files matched your search

@@ -0,0 +1,99 @@
jest.mock('expo-observe', () => ({
Observe: {
logEvent: jest.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Observe } = require('expo-observe');
const logEventMock = Observe.logEvent as jest.Mock;
type ErrorHandler = (error: unknown, isFatal: boolean) => void;
function withFreshObserveModule<T>(run: (mod: typeof import('@/lib/observe')) => T): T {
let result!: T;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('@/lib/observe') as typeof import('@/lib/observe');
result = run(mod);
});
return result;
}
function installFakeErrorUtils(previousHandler: jest.Mock) {
let installedHandler: ErrorHandler = () => undefined;
const globalWithErrorUtils = global as unknown as {
ErrorUtils: { getGlobalHandler: () => unknown; setGlobalHandler: (fn: unknown) => void };
};
globalWithErrorUtils.ErrorUtils = {
getGlobalHandler: () => previousHandler,
setGlobalHandler: (fn) => {
installedHandler = fn as ErrorHandler;
},
};
return () => installedHandler;
}
describe('observe', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('logs app_started only once per module instance', () => {
withFreshObserveModule(({ logAppStarted }) => {
logAppStarted();
logAppStarted();
});
expect(logEventMock).toHaveBeenCalledTimes(1);
expect(logEventMock).toHaveBeenCalledWith('app_started');
});
it('does not throw when Observe.logEvent fails', () => {
logEventMock.mockImplementationOnce(() => {
throw new Error('native module unavailable');
});
withFreshObserveModule(({ logAppStarted }) => {
expect(() => logAppStarted()).not.toThrow();
});
});
it('wraps the previous ErrorUtils handler, logging xprem_js_crash and delegating', () => {
const previousHandler = jest.fn();
const getInstalledHandler = installFakeErrorUtils(previousHandler);
withFreshObserveModule(({ installObserveGlobalErrorHandler }) => {
installObserveGlobalErrorHandler();
});
const error = new Error('boom');
getInstalledHandler()(error, true);
expect(logEventMock).toHaveBeenCalledWith(
'xprem_js_crash',
expect.objectContaining({
severity: 'fatal',
body: 'boom',
attributes: { isFatal: true },
})
);
expect(previousHandler).toHaveBeenCalledWith(error, true);
});
it('still calls the previous handler when Observe.logEvent throws', () => {
const previousHandler = jest.fn();
const getInstalledHandler = installFakeErrorUtils(previousHandler);
logEventMock.mockImplementationOnce(() => {
throw new Error('native module unavailable');
});
withFreshObserveModule(({ installObserveGlobalErrorHandler }) => {
installObserveGlobalErrorHandler();
});
getInstalledHandler()(new Error('boom'), false);
expect(previousHandler).toHaveBeenCalledWith(expect.any(Error), false);
});
});
+66
View File
@@ -0,0 +1,66 @@
// EAS Observe wrapper. Keeps expo-observe usage in one place and fails soft
// so a metrics problem never blocks app startup or crash reporting.
import { Observe } from "expo-observe";
type ErrorHandler = (error: unknown, isFatal: boolean) => void;
interface GlobalWithErrorUtils {
ErrorUtils?: {
getGlobalHandler(): ErrorHandler;
setGlobalHandler(handler: ErrorHandler): void;
};
}
let appStartedLogged = false;
/**
* Logs the `app_started` custom event once per process lifetime. Safe to call
* more than once; only the first call has an effect.
*/
export function logAppStarted() {
if (appStartedLogged) return;
appStartedLogged = true;
try {
Observe.logEvent("app_started");
} catch (error) {
console.warn("[Observe] Failed to log app_started:", error);
}
}
let globalErrorHandlerInstalled = false;
/**
* Installs a global fatal JS error handler that records an `xprem_js_crash`
* Observe event and then always delegates to whatever handler was previously
* registered (RN's default redbox handler, or another handler such as
* Crashlytics's, if one is installed first). Existing crash reporting is
* never replaced, only observed.
*/
export function installObserveGlobalErrorHandler() {
if (globalErrorHandlerInstalled) return;
const errorUtils = (global as GlobalWithErrorUtils).ErrorUtils;
if (!errorUtils) {
console.warn("[Observe] ErrorUtils is unavailable; xprem_js_crash will not be recorded.");
return;
}
globalErrorHandlerInstalled = true;
const previousHandler = errorUtils.getGlobalHandler();
errorUtils.setGlobalHandler((error, isFatal) => {
try {
const message = error instanceof Error ? error.message : String(error);
Observe.logEvent("xprem_js_crash", {
severity: "fatal",
body: message,
attributes: { isFatal: Boolean(isFatal) },
});
} catch (observeError) {
console.warn("[Observe] Failed to log xprem_js_crash:", observeError);
}
previousHandler(error, isFatal);
});
}