mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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:
1 parent
6bc4e31719
commit
659cee76b6
4 files changed
+197
-21
No files matched your search
+12
-7
@@ -7,12 +7,6 @@
|
|||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
|
||||||
"splash": {
|
|
||||||
"image": "./assets/splash-icon.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#1a1a2e"
|
|
||||||
},
|
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
@@ -223,6 +217,14 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"./plugins/withFixHoisting.js",
|
"./plugins/withFixHoisting.js",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#1a1a2e"
|
||||||
|
}
|
||||||
|
],
|
||||||
"@react-native-firebase/app",
|
"@react-native-firebase/app",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
@@ -320,7 +322,10 @@
|
|||||||
"origin": false
|
"origin": false
|
||||||
},
|
},
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724"
|
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724",
|
||||||
|
"observe": {
|
||||||
|
"endpointUrl": "https://ota.apps.jbrunton.co.uk/observe/9a66f3c2-fda7-416f-87e2-6db833b32e9d"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "jjbrunton-apps"
|
"owner": "jjbrunton-apps"
|
||||||
|
|||||||
+20
-14
@@ -5,6 +5,11 @@ import "../src/polyfills/web";
|
|||||||
import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics";
|
import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics";
|
||||||
initCrashlytics();
|
initCrashlytics();
|
||||||
|
|
||||||
|
// Install the Observe global JS error handler after Crashlytics so it wraps
|
||||||
|
// (and always calls) whatever handler is already registered.
|
||||||
|
import { installObserveGlobalErrorHandler, logAppStarted } from "../src/lib/observe";
|
||||||
|
installObserveGlobalErrorHandler();
|
||||||
|
|
||||||
// Analytics imports (initialization deferred until app mounts)
|
// Analytics imports (initialization deferred until app mounts)
|
||||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ export default function RootLayout() {
|
|||||||
analyticsInitialized.current = true;
|
analyticsInitialized.current = true;
|
||||||
initAnalytics();
|
initAnalytics();
|
||||||
Events.appOpened("cold");
|
Events.appOpened("cold");
|
||||||
|
logAppStarted();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -115,15 +121,15 @@ export default function RootLayout() {
|
|||||||
// Fetch user on mount
|
// Fetch user on mount
|
||||||
fetchUser();
|
fetchUser();
|
||||||
|
|
||||||
// Handle initial URL if app was opened via deep link
|
// Handle initial URL if app was opened via deep link
|
||||||
const handleDeepLink = async (url: string | null) => {
|
const handleDeepLink = async (url: string | null) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
|
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
|
||||||
// are handled separately from the Supabase auth deep link flow below.
|
// are handled separately from the Supabase auth deep link flow below.
|
||||||
const inviteLink = parseInviteLinkCode(url);
|
const inviteLink = parseInviteLinkCode(url);
|
||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
Events.inviteLinkOpened(inviteLink.source);
|
Events.inviteLinkOpened(inviteLink.source);
|
||||||
await stashPendingInviteCode(inviteLink.code);
|
await stashPendingInviteCode(inviteLink.code);
|
||||||
|
|
||||||
const { isAuthenticated, user } = useAuthStore.getState();
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
@@ -200,15 +206,15 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get initial URL
|
// Get initial URL
|
||||||
Linking.getInitialURL().then((url) => {
|
Linking.getInitialURL().then((url) => {
|
||||||
handleDeepLink(url);
|
handleDeepLink(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for new deep links while app is open
|
// Listen for new deep links while app is open
|
||||||
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
||||||
handleDeepLink(event.url);
|
handleDeepLink(event.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up push notification response listener (for taps while app is running)
|
// Set up push notification response listener (for taps while app is running)
|
||||||
const notificationSubscription = setupNotificationResponseListener();
|
const notificationSubscription = setupNotificationResponseListener();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user