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
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY=
EXPO_PUBLIC_API_URL=
EXPO_PUBLIC_BUGSNAG_API_KEY=
EXPO_PUBLIC_AMPLITUDE_API_KEY=

# PostHog, mobile (magic-observability/expo). All of these are OPTIONAL:
# with no key the shared client is a silent no-op β€” every method present,
# every method does nothing, nothing written to the console β€” so a clone
# without a .env boots and runs.
#
# EXPO_PUBLIC_POSTHOG_KEY is the shared GSTJ variable name.
# EXPO_PUBLIC_POSTHOG_API_KEY is the older name this project's EAS
# "production" environment still stores the token under; both are read, the
# new one first (apps/mobile/src/services/config.ts).
# Host defaults to https://us.i.posthog.com.
EXPO_PUBLIC_POSTHOG_KEY=
EXPO_PUBLIC_POSTHOG_API_KEY=
EXPO_PUBLIC_POSTHOG_HOST=
EXPO_PUBLIC_REVENUE_CAT_IOS_API_KEY=
Expand Down Expand Up @@ -46,7 +58,30 @@ MAIL_NAME=

SENDGRID_API_KEY=

# PostHog, server (magic-observability/node for @pegada/api,
# magic-observability/next for the site's onRequestError hook). Optional for
# the same reason as the mobile keys above.
#
# POSTHOG_KEY is the shared GSTJ name; POSTHOG_API_KEY is what this project
# already sets. Both are read, POSTHOG_KEY first.
POSTHOG_KEY=
POSTHOG_API_KEY=
POSTHOG_HOST=

# PostHog, browser (magic-observability/web + /react in apps/nextjs). Not
# provisioned for this project yet β€” until it is, the site's analytics and
# its top-level error boundary report nowhere, which is the designed
# no-key state. Setting this is the only step needed to turn them on.
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=

# PostHog sourcemap upload (scripts/upload-posthog-sourcemaps-ota.sh and the
# posthog-react-native/expo config plugin). A PERSONAL API key with write
# access to error tracking β€” not the project token above. Stored in the EAS
# "production" environment; the upload is skipped entirely when it is unset.
POSTHOG_CLI_API_KEY=
POSTHOG_CLI_HOST=
POSTHOG_CLI_PROJECT_ID=

APPLE_MAGIC_EMAIL=
APPLE_MAGIC_CODE=
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"jwt-decode": "^4.0.0",
"lodash": "^4.18.1",
"lottie-react-native": "7.3.8",
"magic-observability": "1.0.0",
"posthog-react-native": "^4.54.4",
"posthog-react-native-session-replay": "^1.6.0",
"prop-types": "^15.8.1",
Expand Down
47 changes: 30 additions & 17 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useTrackScreens } from "@/hooks/use-track-screens";
import { config } from "@/services/config";
import { sendError } from "@/services/error-tracking";
import { useGetInitialNotifications } from "@/services/linking";
import { posthog } from "@/services/posthog";
import { getExpoPostHog } from "@/services/observability";
import { useQuickActions } from "@/services/quickActions";
import { store } from "@/store";
import { SceneName } from "@/types/scene-name";
Expand Down Expand Up @@ -76,25 +76,38 @@ const App = () => {
}
}, []);

const tree = (
<TRPCProvider>
<ThemeProvider>
<BottomSheetModalProvider>
<NetworkBoundary>
<Provider store={store}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="(auth)" />
</Stack>
</Provider>
<MagicModalPortal />
</NetworkBoundary>
</BottomSheetModalProvider>
</ThemeProvider>
</TRPCProvider>
);

// `getExpoPostHog()` is null in a build with no key, and PostHogProvider
// cannot be handed one β€” mounting it with an uninitialised client leaves
// every `usePostHog()` consumer queueing events into a socket that never
// opens. Rendering the app without the provider is the documented shape;
// the error boundary inside NetworkBoundary reports through the shared
// client either way, which no-ops just as silently.
const posthog = getExpoPostHog();
if (!posthog) return <AppContainer>{tree}</AppContainer>;

return (
<AppContainer>
<PostHogProvider client={posthog} autocapture={false}>
<TRPCProvider>
<ThemeProvider>
<BottomSheetModalProvider>
<NetworkBoundary>
<Provider store={store}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="(auth)" />
</Stack>
</Provider>
<MagicModalPortal />
</NetworkBoundary>
</BottomSheetModalProvider>
</ThemeProvider>
</TRPCProvider>
{tree}
</PostHogProvider>
</AppContainer>
);
Expand Down
15 changes: 12 additions & 3 deletions apps/mobile/src/components/NetworkBoundary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import {
QueryErrorResetBoundary,
useQueryErrorResetBoundary,
} from "@tanstack/react-query";
import { PostHogErrorBoundary } from "posthog-react-native";
import { ObservabilityBoundary } from "magic-observability/expo";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components/native";

import { Button } from "@/components/Button";
import { observability } from "@/services/observability";

import {
ContainedText,
Expand Down Expand Up @@ -139,13 +140,21 @@ const QueryAwareErrorBoundary = ({
children,
errorFallback,
}: PropsWithChildren<Pick<NetworkBoundaryProps, "errorFallback">>) => {
// `ObservabilityBoundary` rather than posthog-react-native's own: same
// reporting, but it takes the shared client, so a render error here lands as
// the same `$exception` shape as one from `sendError` or the Next app, and
// it still renders the fallback when there is no key.
const handleError = (props: QueryErrorResetBoundaryValue) => {
const ErrorComponent = errorFallback ?? DefaultErrorComponent;

return (
<PostHogErrorBoundary fallback={<ErrorComponent {...props} />}>
<ObservabilityBoundary
client={observability}
context={{ boundary: "network" }}
fallback={<ErrorComponent {...props} />}
>
{children}
</PostHogErrorBoundary>
</ObservabilityBoundary>
);
};

Expand Down
24 changes: 7 additions & 17 deletions apps/mobile/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
import "react-native-get-random-values";
import { LogBox, Text } from "react-native";

import * as Updates from "expo-updates";

import mobileAds, { MaxAdContentRating } from "react-native-google-mobile-ads";

import { config } from "@/services/config";
import { sendError } from "@/services/error-tracking";
import { posthog } from "@/services/posthog";
// Side-effect import: constructs the PostHog client (and installs its global
// uncaught-exception / unhandled-rejection handlers) before anything below
// runs. `initExpo` registers `environment` and `release` β€” the OTA update
// group that ties a stack trace to its uploaded sourcemaps β€” as super
// properties itself, which is what the hand-rolled `posthog.register(...)`
// block that used to live at the bottom of this file was doing.
import "@/services/observability";

mobileAds()
.setRequestConfiguration({
Expand All @@ -34,16 +37,3 @@ LogBox.ignoreLogs([
"Sending `onAnimatedValueUpdate` with no listeners registered.",
"Warning: Overriding previous layout animation with new one before the first began:",
]);

// Attach env + release to every event (analytics and errors), the way
// Bugsnag's releaseStage / metadata used to. codeBundleId ties errors to
// the exact OTA update group.
const { manifest } = Updates;
const metadata = "metadata" in manifest ? manifest.metadata : undefined;
const updateGroup =
metadata && "updateGroup" in metadata ? metadata.updateGroup : undefined;

posthog.register({
environment: config.ENV,
code_bundle_id: (updateGroup as string) || "",
});
37 changes: 21 additions & 16 deletions apps/mobile/src/services/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import { posthog } from "@/services/posthog";

// PostHog's property type is derived from the client so it always matches.
type EventProperties = NonNullable<Parameters<typeof posthog.capture>[1]>;
import { observability, getExpoPostHog } from "@/services/observability";

// The call sites pass loose values (Dates, nested objects, optionals) that
// Amplitude accepted as `any`. PostHog JSON-serializes properties the same
// way at send time, so we accept the loose shape here and coerce once at the
// boundary rather than editing every event object.
// Amplitude accepted as `any`. The shared client flattens nested objects to
// dotted keys and drops `undefined` on the way in, so the loose shape is
// coerced once here rather than editing every event object.
type LooseProperties = Record<string, unknown>;

const toEventProperties = (properties?: LooseProperties) =>
properties as EventProperties | undefined;

type TrackEvent = {
event_type: string;
event_properties?: LooseProperties;
Expand All @@ -22,19 +16,30 @@ type ScreenViewed = {
referringScreen?: string;
};

// Keeps the Amplitude-era call shape (`{ event_type, event_properties }`)
// so the existing call sites don't change, mapping it onto PostHog's
// capture(name, properties) / screen(name, properties) / identify(id, props).
// Keeps the Amplitude-era call shape (`{ event_type, event_properties }`) so
// the ~30 existing call sites don't change, mapping it onto the shared
// client's capture(name, properties) / identify(id, props).
const track = ({ event_type, event_properties }: TrackEvent) => {
posthog.capture(event_type, toEventProperties(event_properties));
observability.capture(event_type, event_properties);
};

/**
* Screen views go straight to `posthog-react-native`. `$screen` is a mobile-only
* event with its own SDK method, and the shared client surface deliberately
* stops at `capture`/`captureError`/`identify` rather than growing a method
* four platforms out of five cannot implement. `null` when there is no key,
* which is the same silence every other path gets.
*/
const screenViewed = ({ screen, referringScreen }: ScreenViewed) => {
posthog.screen(screen, referringScreen ? { referringScreen } : undefined);
getExpoPostHog()?.screen(
screen,
referringScreen ? { referringScreen } : undefined,
);
};

const identify = (userId?: string, properties?: LooseProperties) => {
posthog.identify(userId, toEventProperties(properties));
if (!userId) return;
observability.identify(userId, properties);
};

export const analytics = {
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { z } from "zod";

const configSchema = z.object({
ENV: z.enum(["development", "production"]),
POSTHOG_API_KEY: z.string(),
POSTHOG_HOST: z.string(),
/**
* Optional on purpose. `magic-observability` returns a silent no-op client
* without a key, so a clone with no `.env` β€” or a contributor who never set
* a token β€” boots and runs instead of throwing at import time.
*/
POSTHOG_KEY: z.string().optional(),
POSTHOG_HOST: z.string().optional(),
IOS_GOOGLE_MAPS_API_KEY: z.string(),
ANDROID_GOOGLE_MAPS_API_KEY: z.string(),
REVENUE_CAT_IOS_API_KEY: z.string(),
Expand All @@ -19,7 +24,14 @@ const configSchema = z.object({

const _config = configSchema.safeParse({
ENV: process.env.EXPO_PUBLIC_ENV,
POSTHOG_API_KEY: process.env.EXPO_PUBLIC_POSTHOG_API_KEY,
// `EXPO_PUBLIC_POSTHOG_KEY` is the shared GSTJ convention that
// `magic-observability` reads on its own; `EXPO_PUBLIC_POSTHOG_API_KEY` is
// the name this project's EAS "production" environment already stores the
// token under. Both are accepted, newest first, so the EAS entry can be
// renamed whenever without a coordinated deploy.
POSTHOG_KEY:
process.env.EXPO_PUBLIC_POSTHOG_KEY ??
process.env.EXPO_PUBLIC_POSTHOG_API_KEY,
POSTHOG_HOST: process.env.EXPO_PUBLIC_POSTHOG_HOST,
IOS_GOOGLE_MAPS_API_KEY: process.env.EXPO_PUBLIC_IOS_GOOGLE_MAPS_API_KEY,
ANDROID_GOOGLE_MAPS_API_KEY:
Expand Down
23 changes: 19 additions & 4 deletions apps/mobile/src/services/error-tracking.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { captureError } from "magic-observability/expo";

import { config } from "./config";
import { posthog } from "./posthog";
import "./observability";

/**
* The app's one reporting funnel. Around forty modules call it, so the
* signature stays put; only the transport moved to `magic-observability`.
*
* What that buys: whatever was thrown becomes a real `Error` on the way in (a
* bare `throw "nope"` used to reach PostHog with no stack and no message), and
* nested context is flattened to dotted keys PostHog can actually filter on.
*
* The bare `./observability` import is for its side effect β€” it guarantees the
* client is constructed before the first report, whichever module happens to
* fail first.
*/
// oxlint-disable-next-line typescript/no-explicit-any -- Anything can be thrown, and this is the boundary that has to accept it.
export const sendError = (error: any) => {
export const sendError = (error: any, context?: Record<string, unknown>) => {
if (config.ENV === "development") {
// oxlint-disable-next-line no-console -- Development-only mirror of what gets reported to PostHog.
console.error(error);
} else {
posthog.captureException(error);
return;
}

captureError(error, context);
};
59 changes: 59 additions & 0 deletions apps/mobile/src/services/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as Updates from "expo-updates";

import { getExpoPostHog, initExpo } from "magic-observability/expo";

import { config } from "@/services/config";

/**
* `code_bundle_id` in Bugsnag terms: the OTA update group the running
* JavaScript came from. An `eas update` ships new JS under an unchanged binary
* version, so the app's semver cannot identify which bundle a stack trace came
* from β€” the update group can, and it is what the sourcemaps uploaded by
* `scripts/upload-posthog-sourcemaps-ota.sh` are filed under. Undefined in a
* dev client and on the very first launch of a fresh binary, which is correct:
* there is no OTA update in play.
*/
const { manifest } = Updates;
const metadata = "metadata" in manifest ? manifest.metadata : undefined;
const updateGroup =
metadata && "updateGroup" in metadata ? metadata.updateGroup : undefined;

/**
* The app's single PostHog client, built through `magic-observability/expo` so
* every GSTJ project reports exceptions in the same shape.
*
* `initExpo` constructs a standalone instance rather than letting
* `<PostHogProvider apiKey>` do it, because the provider has no way to
* configure `errorTracking`. `_layout` hands the raw instance back to the
* provider for screen tracking and hook access; the non-React singletons
* (analytics, sagas, util modules) reach the same client through the exports
* here.
*
* `environment` and `release` are registered as super properties by `initExpo`
* itself, so every event and every exception carries them without a separate
* `register()` call at boot.
*
* With no key resolved this is a no-op client β€” every method present, every
* method silent, nothing written to the console. A clone without a `.env`
* boots and runs.
*/
export const observability = initExpo({
key: config.POSTHOG_KEY,
host: config.POSTHOG_HOST,
environment: config.ENV,
release: typeof updateGroup === "string" ? updateGroup : undefined,
// Manual events only: this app fires explicit capture()/screen() calls, and
// replay is off at the project level anyway.
sessionReplay: false,
posthogOptions: { captureAppLifecycleEvents: false },
});

/**
* The raw `posthog-react-native` instance, or `null` when telemetry is off.
*
* Needed by `<PostHogProvider client={...}>` and by anything reaching for
* feature flags or surveys β€” the shared client surface deliberately does not
* wrap those. Exposed as a function so the `null` case stays visible at the
* call site.
*/
export { getExpoPostHog };
16 changes: 0 additions & 16 deletions apps/mobile/src/services/posthog.ts

This file was deleted.

Loading
Loading