diff --git a/.env.example b/.env.example
index c02eac8a..063c89cb 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
@@ -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=
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index c1e80742..15156837 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -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",
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 8d16ca46..f991822d 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -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";
@@ -76,25 +76,38 @@ const App = () => {
}
}, []);
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ // `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 {tree};
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {tree}
);
diff --git a/apps/mobile/src/components/NetworkBoundary/index.tsx b/apps/mobile/src/components/NetworkBoundary/index.tsx
index 6c5be360..71efad35 100644
--- a/apps/mobile/src/components/NetworkBoundary/index.tsx
+++ b/apps/mobile/src/components/NetworkBoundary/index.tsx
@@ -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,
@@ -139,13 +140,21 @@ const QueryAwareErrorBoundary = ({
children,
errorFallback,
}: PropsWithChildren>) => {
+ // `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 (
- }>
+ }
+ >
{children}
-
+
);
};
diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts
index 7326a320..2dc98972 100644
--- a/apps/mobile/src/config.ts
+++ b/apps/mobile/src/config.ts
@@ -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({
@@ -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) || "",
-});
diff --git a/apps/mobile/src/services/analytics/index.ts b/apps/mobile/src/services/analytics/index.ts
index 0123390e..342a1145 100644
--- a/apps/mobile/src/services/analytics/index.ts
+++ b/apps/mobile/src/services/analytics/index.ts
@@ -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[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;
-const toEventProperties = (properties?: LooseProperties) =>
- properties as EventProperties | undefined;
-
type TrackEvent = {
event_type: string;
event_properties?: LooseProperties;
@@ -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 = {
diff --git a/apps/mobile/src/services/config.ts b/apps/mobile/src/services/config.ts
index 2171d404..e1407edc 100644
--- a/apps/mobile/src/services/config.ts
+++ b/apps/mobile/src/services/config.ts
@@ -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(),
@@ -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:
diff --git a/apps/mobile/src/services/error-tracking.ts b/apps/mobile/src/services/error-tracking.ts
index bc02436e..4c7a8e65 100644
--- a/apps/mobile/src/services/error-tracking.ts
+++ b/apps/mobile/src/services/error-tracking.ts
@@ -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) => {
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);
};
diff --git a/apps/mobile/src/services/observability.ts b/apps/mobile/src/services/observability.ts
new file mode 100644
index 00000000..5dda3272
--- /dev/null
+++ b/apps/mobile/src/services/observability.ts
@@ -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
+ * `` 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 `` 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 };
diff --git a/apps/mobile/src/services/posthog.ts b/apps/mobile/src/services/posthog.ts
deleted file mode 100644
index 9c092f0f..00000000
--- a/apps/mobile/src/services/posthog.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import PostHog from "posthog-react-native";
-
-import { config } from "@/services/config";
-
-// Single PostHog client for analytics + error tracking. Instantiated as a
-// standalone instance (not just the hook) so the non-React singletons —
-// analytics, errorTracking, sagas, util modules — can all reach it. Also
-// passed to in _layout for the error
-// boundary and hook access.
-export const posthog = new PostHog(config.POSTHOG_API_KEY, {
- host: config.POSTHOG_HOST,
- // Manual events only — we fire explicit capture()/screen() calls, so keep
- // session replay off and don't auto-capture app lifecycle events.
- enableSessionReplay: false,
- captureAppLifecycleEvents: false,
-});
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 5b5519f1..d4a06dcf 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -15,6 +15,7 @@
"@pegada/api": "workspace:*",
"@pegada/database": "workspace:*",
"@pegada/shared": "workspace:*",
+ "@posthog/react": "^1.10.3",
"@tailwindcss/typography": "^0.5.19",
"@types/mdx": "^2.0.13",
"@upstash/ratelimit": "^2.0.8",
@@ -28,10 +29,13 @@
"handlebars-loader": "^1.7.3",
"i18next": "^23.12.2",
"lucide-react": "^0.424.0",
+ "magic-observability": "1.0.0",
"next": "15.5.22",
"next-intl": "^4.13.1",
"next-mdx-remote": "^6.0.0",
"postcss": "8.5.23",
+ "posthog-js": "^1.407.2",
+ "posthog-node": "^5.39.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"redis": "^4.7.0",
diff --git a/apps/nextjs/src/app/error.tsx b/apps/nextjs/src/app/error.tsx
index 6f48c6d5..135ad74b 100644
--- a/apps/nextjs/src/app/error.tsx
+++ b/apps/nextjs/src/app/error.tsx
@@ -1,36 +1,41 @@
"use client";
+import { useEffect } from "react";
+
+import { captureError } from "magic-observability/web";
+
+import { SomethingWentWrong } from "@/components/something-went-wrong";
+
+/**
+ * Next's route-level error boundary. It is a client component, so it reports
+ * through the browser client rather than `magic-observability/next` — the
+ * server half never sees an error React caught during hydration or a client
+ * render.
+ *
+ * The ``/`` wrapper is deliberate and predates this change: this
+ * file stands in for `global-error.tsx` too, and a boundary that replaces the
+ * root layout has to render the document shell itself.
+ */
const GlobalError = ({
+ error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) => {
+ useEffect(() => {
+ captureError(error, {
+ source: "app-error-boundary",
+ // Next replaces a server error's message with a digest in production;
+ // it is the only handle that ties this event to the server-side log.
+ ...(error.digest ? { digest: error.digest } : {}),
+ });
+ }, [error]);
+
return (
-
-
-
-
- 500
-
-
- Something went wrong
-
-
- We encountered an error. Please try again later.
-
-
-
-
-
+
);
diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx
index bec10bd5..3a030edd 100644
--- a/apps/nextjs/src/app/layout.tsx
+++ b/apps/nextjs/src/app/layout.tsx
@@ -5,6 +5,7 @@ import { Epilogue } from "next/font/google";
import { Analytics } from "@vercel/analytics/react";
+import { Providers } from "@/app/providers";
import { getSafeLocale } from "@/lib/get-safe-locale";
import { t } from "@/lib/translate";
import { cn } from "@/lib/utils";
@@ -29,7 +30,9 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
- {children}
+
+ {children}
+
);
};
diff --git a/apps/nextjs/src/app/providers.tsx b/apps/nextjs/src/app/providers.tsx
new file mode 100644
index 00000000..befaa923
--- /dev/null
+++ b/apps/nextjs/src/app/providers.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import {
+ ObservabilityBoundary,
+ ObservabilityProvider,
+} from "magic-observability/react";
+import { getWebClient } from "magic-observability/web";
+
+import { SomethingWentWrong } from "@/components/something-went-wrong";
+
+// Hoisted out of the render: a fresh object on every render is a new prop
+// identity, which re-renders the boundary for nothing.
+const BOUNDARY_CONTEXT = { boundary: "root" };
+
+/**
+ * PostHog's React context plus a top-level error boundary.
+ *
+ * The client itself is built in `instrumentation-client.ts`, which Next
+ * evaluates before hydration; `getWebClient()` picks up whatever that produced
+ * — a real client, or the no-op one that exists while this project has no
+ * `NEXT_PUBLIC_POSTHOG_KEY`. `ObservabilityProvider` renders nothing but its
+ * children in the no-op case, on purpose: a provider handing an uninitialised
+ * `posthog-js` to `usePostHog()` gives every consumer a client that queues
+ * events forever.
+ *
+ * `fallback` is passed as a component rather than an element so the boundary
+ * can hand it `reset` — that is what makes its "Try again" button work.
+ *
+ * `app/error.tsx` still exists and still reports: it is Next's own route-level
+ * boundary, and it catches the things this one is mounted beneath.
+ */
+export const Providers = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+);
diff --git a/apps/nextjs/src/components/something-went-wrong.tsx b/apps/nextjs/src/components/something-went-wrong.tsx
new file mode 100644
index 00000000..e94891a3
--- /dev/null
+++ b/apps/nextjs/src/components/something-went-wrong.tsx
@@ -0,0 +1,38 @@
+/**
+ * The 500 screen. Lifted out of `app/error.tsx` so Next's route-level error
+ * boundary and the top-level `ObservabilityBoundary` in `app/providers.tsx`
+ * render the same thing — a user who hits one has no way of knowing which
+ * boundary caught it, and two near-identical copies of this markup would drift.
+ *
+ * `reset` is optional because the observability boundary's reset clears its own
+ * state rather than re-running a route render; with no handler the button is
+ * simply not offered.
+ */
+export const SomethingWentWrong = ({ reset }: { reset?: () => void }) => {
+ return (
+
+
+
+
+ 500
+
+
+ Something went wrong
+
+
+ We encountered an error. Please try again later.
+
+ {reset ? (
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts
new file mode 100644
index 00000000..820d19a8
--- /dev/null
+++ b/apps/nextjs/src/env.ts
@@ -0,0 +1,32 @@
+/**
+ * The site's env boundary. `magic-oxlint-config` only allows `process.env`
+ * reads in a file called `env.ts`, and bundlers only substitute
+ * `process.env.NEXT_PUBLIC_*` where it is written out literally, so both
+ * constraints point at exactly this file.
+ *
+ * `magic-observability` reads `NEXT_PUBLIC_POSTHOG_KEY` / `POSTHOG_KEY` on its
+ * own. The explicit reads here exist for one reason: this project's deployment
+ * environments already store the token as `POSTHOG_API_KEY` (that is what
+ * `@pegada/api` has always used), and the server half should point at the same
+ * project rather than sitting dark until someone adds a second variable.
+ */
+
+/** Server-side PostHog token. Undefined means telemetry no-ops, silently. */
+export const posthogServerKey = (): string | undefined =>
+ process.env.POSTHOG_KEY ??
+ process.env.POSTHOG_API_KEY ??
+ process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+export const posthogHost = (): string | undefined =>
+ process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST;
+
+/**
+ * Vercel's deploy environment (`production` / `preview` / `development`) and
+ * the commit the bundle was built from, registered as super properties so an
+ * exception can be pinned to a deploy.
+ */
+export const deployEnvironment = (): string =>
+ process.env.NEXT_PUBLIC_VERCEL_ENV ?? "development";
+
+export const deployRelease = (): string | undefined =>
+ process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA;
diff --git a/apps/nextjs/src/instrumentation-client.ts b/apps/nextjs/src/instrumentation-client.ts
new file mode 100644
index 00000000..ab4efd29
--- /dev/null
+++ b/apps/nextjs/src/instrumentation-client.ts
@@ -0,0 +1,18 @@
+import { initWebAnalytics } from "magic-observability/web";
+
+import { deployEnvironment, deployRelease } from "@/env";
+
+/**
+ * Browser telemetry, started before hydration. Next 15.3+ evaluates this file
+ * automatically — there is no import of it anywhere, and adding one would run
+ * it twice.
+ *
+ * `NEXT_PUBLIC_POSTHOG_KEY` is not set for this project yet. Until it is, this
+ * returns a no-op client: pageviews, exceptions and `capture()` calls all go
+ * nowhere, nothing is written to the console, and nothing has to change here
+ * on the day the key lands.
+ */
+initWebAnalytics({
+ environment: deployEnvironment(),
+ release: deployRelease(),
+});
diff --git a/apps/nextjs/src/instrumentation.ts b/apps/nextjs/src/instrumentation.ts
new file mode 100644
index 00000000..b5fcd431
--- /dev/null
+++ b/apps/nextjs/src/instrumentation.ts
@@ -0,0 +1,29 @@
+import { createRequestErrorHandler } from "magic-observability/next";
+
+import {
+ deployEnvironment,
+ deployRelease,
+ posthogHost,
+ posthogServerKey,
+} from "@/env";
+
+/**
+ * Server-side error capture. Next calls `onRequestError` for every uncaught
+ * throw in a server component, route handler or server action.
+ *
+ * The handler skips the edge runtime (`posthog-node` cannot symbolicate
+ * there), reads `distinct_id` off the `ph_phc_*_posthog` cookie so a server
+ * exception lands on the same person as their browser events, attaches the
+ * route metadata Next hands over, and flushes before the function freezes.
+ *
+ * `register` is required by Next but has nothing to do here — the client is
+ * built lazily on the first error rather than on every cold start.
+ */
+export const register = () => {};
+
+export const onRequestError = createRequestErrorHandler({
+ key: posthogServerKey(),
+ host: posthogHost(),
+ environment: deployEnvironment(),
+ release: deployRelease(),
+});
diff --git a/packages/api/package.json b/packages/api/package.json
index d3f1aaca..ef0685d8 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -35,6 +35,7 @@
"i18next": "^23.12.2",
"jest": "^29.7.0",
"jsonwebtoken": "^9.0.3",
+ "magic-observability": "1.0.0",
"nsfwjs": "^4.3.0",
"posthog-node": "^5.39.4",
"semver": "^7.8.5",
diff --git a/packages/api/src/errors/errors.ts b/packages/api/src/errors/errors.ts
index f6fdc86f..fded4db7 100644
--- a/packages/api/src/errors/errors.ts
+++ b/packages/api/src/errors/errors.ts
@@ -1,14 +1,19 @@
import { config } from "../shared/config";
-import { posthog } from "../shared/posthog";
+import { observability } from "../shared/observability";
+/**
+ * The API's one reporting funnel. Same signature it always had; the transport
+ * is now `magic-observability/node`, which turns a non-`Error` throw into one
+ * with a usable stack and flattens `context` into dotted, filterable keys.
+ */
// 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) => {
if (config.NODE_ENV === "development") {
// oxlint-disable-next-line no-console -- Development-only mirror of what gets reported to PostHog.
console.error(error);
}
- posthog.captureException(error);
+ observability.captureError(error, context);
};
export const logDebug = (...props: unknown[]) => {
diff --git a/packages/api/src/services/SuggestionService/suggestion-service.test.ts b/packages/api/src/services/SuggestionService/suggestion-service.test.ts
index 304fc702..27dda803 100644
--- a/packages/api/src/services/SuggestionService/suggestion-service.test.ts
+++ b/packages/api/src/services/SuggestionService/suggestion-service.test.ts
@@ -10,14 +10,19 @@ import { dogSafeSchema } from "../../dtos/dog-dto";
import { SwipeService } from "../swipe-service";
import { SuggestionService } from "./suggestion-service";
-jest.mock("../../shared/posthog", () => ({
- posthog: {
- captureException: jest.fn(),
+jest.mock("../../shared/observability", () => ({
+ observability: {
+ enabled: false,
+ disabledReason: "explicitly-disabled",
capture: jest.fn(),
+ captureError: jest.fn(),
identify: jest.fn(),
- isFeatureEnabled: jest.fn(),
+ reset: jest.fn(),
+ register: jest.fn(),
+ flush: jest.fn(),
shutdown: jest.fn(),
},
+ getPostHogNode: jest.fn(() => null),
}));
afterAll(async () => {
diff --git a/packages/api/src/services/flag-service.ts b/packages/api/src/services/flag-service.ts
index e1d78f5f..eef88ec4 100644
--- a/packages/api/src/services/flag-service.ts
+++ b/packages/api/src/services/flag-service.ts
@@ -1,13 +1,18 @@
import { sendError } from "../errors/errors";
import { cacheFunctionResultFor } from "../shared/cache-function-result-for";
-import { posthog } from "../shared/posthog";
+import { getPostHogNode } from "../shared/observability";
const FIVE_SECONDS = 5000;
// Cache the result of isFeatureEnabled for 5 seconds
// This prevents our quota from being exceeded.
+//
+// Reads the raw `posthog-node` handle rather than the shared client: feature
+// flags are deliberately not wrapped by `magic-observability`. `undefined`
+// (no key configured) falls through to the caller's `defaultValue` below,
+// which is the same branch a PostHog outage takes.
const cachedIsFeatureEnabled = cacheFunctionResultFor(
- (feature: string) => posthog.isFeatureEnabled(feature, ""),
+ (feature: string) => getPostHogNode()?.isFeatureEnabled(feature, ""),
FIVE_SECONDS,
);
@@ -26,7 +31,7 @@ export class FlagService {
}) {
try {
const result = await cachedIsFeatureEnabled(feature);
- return result;
+ return result ?? defaultValue;
} catch (error) {
sendError(error);
return defaultValue;
diff --git a/packages/api/src/services/message-service.test.ts b/packages/api/src/services/message-service.test.ts
index d6d5fcb6..5d795813 100644
--- a/packages/api/src/services/message-service.test.ts
+++ b/packages/api/src/services/message-service.test.ts
@@ -4,14 +4,19 @@ import { generateFakeUserWithDog } from "@pegada/database/fixtures/generate-fake
import MessageService from "./message-service";
-jest.mock("../shared/posthog", () => ({
- posthog: {
- captureException: jest.fn(),
+jest.mock("../shared/observability", () => ({
+ observability: {
+ enabled: false,
+ disabledReason: "explicitly-disabled",
capture: jest.fn(),
+ captureError: jest.fn(),
identify: jest.fn(),
- isFeatureEnabled: jest.fn(),
+ reset: jest.fn(),
+ register: jest.fn(),
+ flush: jest.fn(),
shutdown: jest.fn(),
},
+ getPostHogNode: jest.fn(() => null),
}));
afterAll(async () => {
diff --git a/packages/api/src/shared/config.ts b/packages/api/src/shared/config.ts
index e2793657..2f6ec3d4 100644
--- a/packages/api/src/shared/config.ts
+++ b/packages/api/src/shared/config.ts
@@ -14,9 +14,18 @@ const configSchema = z.object({
.enum(["development", "production", "test"])
.default("development"),
- /** POSTHOG (analytics + error tracking + feature flags) */
- POSTHOG_API_KEY: z.string(),
- POSTHOG_HOST: z.string(),
+ /**
+ * POSTHOG (analytics + error tracking + feature flags)
+ *
+ * `POSTHOG_KEY` is the shared GSTJ variable name; `POSTHOG_API_KEY` is what
+ * this project's environments already set. Both optional: without a key
+ * `magic-observability` hands back a silent no-op client, so a fresh clone
+ * and the test suite boot without a PostHog project instead of throwing
+ * here at import time.
+ */
+ POSTHOG_KEY: z.string().optional(),
+ POSTHOG_API_KEY: z.string().optional(),
+ POSTHOG_HOST: z.string().optional(),
/** SERVER */
PORT: z.coerce.number().default(3009),
diff --git a/packages/api/src/shared/observability.ts b/packages/api/src/shared/observability.ts
new file mode 100644
index 00000000..55f2acf5
--- /dev/null
+++ b/packages/api/src/shared/observability.ts
@@ -0,0 +1,36 @@
+import { getPostHogNode, initNode } from "magic-observability/node";
+
+import { config } from "./config";
+
+/**
+ * Single PostHog client for the whole API: feature flags (FlagService), error
+ * tracking (errors.ts), and any server-side events.
+ *
+ * Built through `magic-observability/node` so an exception raised in a tRPC
+ * procedure arrives in PostHog looking exactly like one raised in the mobile
+ * app: whatever was thrown normalised into a real `Error`, context flattened
+ * into keys PostHog can filter on.
+ *
+ * `runtime: "serverless"` is the old `flushAt: 1, flushInterval: 0` under a
+ * name — these functions are frozen the instant they return a response, and a
+ * batched event would never leave the process.
+ *
+ * No key resolved means a silent no-op client, which is what lets the test
+ * suite and a fresh checkout run without a PostHog project.
+ */
+export const observability = initNode({
+ // `POSTHOG_KEY` is the shared GSTJ convention; `POSTHOG_API_KEY` is the name
+ // this project's deployment environments already store the token under. Both
+ // are accepted, newest first, so the rename needs no coordinated deploy.
+ key: config.POSTHOG_KEY ?? config.POSTHOG_API_KEY,
+ host: config.POSTHOG_HOST,
+ environment: config.NODE_ENV,
+ runtime: "serverless",
+});
+
+/**
+ * `posthog-node` itself, for feature flags — `isFeatureEnabled` is not part of
+ * the shared client surface, on purpose. `null` when telemetry is off, which
+ * `FlagService` already treats as "use the default value".
+ */
+export { getPostHogNode };
diff --git a/packages/api/src/shared/posthog.ts b/packages/api/src/shared/posthog.ts
deleted file mode 100644
index 94ae1673..00000000
--- a/packages/api/src/shared/posthog.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PostHog } from "posthog-node";
-
-import { config } from "./config";
-
-// Single PostHog client for the whole API: feature flags (FlagService),
-// error tracking (errors.ts), and any server-side events. flushAt/flushInterval
-// keep it responsive in the short-lived serverless functions we run on.
-export const posthog = new PostHog(config.POSTHOG_API_KEY, {
- host: config.POSTHOG_HOST,
- flushAt: 1,
- flushInterval: 0,
-});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e97a698..d6ae0179 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -244,6 +244,9 @@ importers:
lottie-react-native:
specifier: 7.3.8
version: 7.3.8(react-native@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@13.6.9(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
+ magic-observability:
+ specifier: 1.0.0
+ version: 1.0.0(@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0))(posthog-js@1.407.2)(posthog-node@5.39.4(rxjs@7.8.1))(posthog-react-native@4.54.4(dd0719650ac7408b8594bb3923b10638))(react@19.2.0)
posthog-react-native:
specifier: ^4.54.4
version: 4.54.4(dd0719650ac7408b8594bb3923b10638)
@@ -416,6 +419,9 @@ importers:
'@pegada/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@posthog/react':
+ specifier: ^1.10.3
+ version: 1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0)
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.15.43)(@types/node@22.1.0)(typescript@5.4.5)))
@@ -455,6 +461,9 @@ importers:
lucide-react:
specifier: ^0.424.0
version: 0.424.0(react@19.2.0)
+ magic-observability:
+ specifier: 1.0.0
+ version: 1.0.0(@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0))(posthog-js@1.407.2)(posthog-node@5.39.4(rxjs@7.8.1))(posthog-react-native@4.54.4(dd0719650ac7408b8594bb3923b10638))(react@19.2.0)
next:
specifier: 15.5.22
version: 15.5.22(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@22.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -467,6 +476,12 @@ importers:
postcss:
specifier: ^8.5.18
version: 8.5.23
+ posthog-js:
+ specifier: ^1.407.2
+ version: 1.407.2
+ posthog-node:
+ specifier: ^5.39.4
+ version: 5.39.4(rxjs@7.8.1)
react:
specifier: 19.2.0
version: 19.2.0
@@ -561,6 +576,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
+ magic-observability:
+ specifier: 1.0.0
+ version: 1.0.0(@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0))(posthog-js@1.407.2)(posthog-node@5.39.4(rxjs@7.8.1))(posthog-react-native@4.54.4(dd0719650ac7408b8594bb3923b10638))(react@19.2.0)
nsfwjs:
specifier: ^4.3.0
version: 4.3.0(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(buffer@6.0.3)
@@ -2453,16 +2471,29 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@posthog/browser-common@0.2.1':
+ resolution: {integrity: sha512-FTVPsRw6GBKWvFwN26TNIQNNYPR9FsUj+B+CKhk/ht63IRzn4yYFtFOjcmW+bfXvNHuRADs6APbZ6Haz1kpoAw==}
+
'@posthog/cli@0.8.1':
resolution: {integrity: sha512-7gCx2rslQtXSN0RfdxHRXumc5E1CY8ldVDxncUGeQ7qTFHprRbryH17W//fcuNMeNhvr6vc8DStj9gGWSS/Zfw==}
engines: {node: '>=14.14', npm: '>=6'}
hasBin: true
- '@posthog/core@1.39.6':
- resolution: {integrity: sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==}
+ '@posthog/core@1.45.1':
+ resolution: {integrity: sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==}
+
+ '@posthog/react@1.10.3':
+ resolution: {integrity: sha512-Qu//fGQmVlX0B9kTA3LLg67e7AYLEmeuA0Bf1qSyUM0uUILcRQGjQezhNQPLYSTakOqvXEnl6fM2iQBF6Toxrw==}
+ peerDependencies:
+ '@types/react': '>=16.8.0'
+ posthog-js: '>=1.257.2'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
- '@posthog/types@1.392.1':
- resolution: {integrity: sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w==}
+ '@posthog/types@1.398.0':
+ resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==}
'@prisma/client@5.17.0':
resolution: {integrity: sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==}
@@ -3457,6 +3488,9 @@ packages:
'@types/stylis@4.2.5':
resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -4097,6 +4131,9 @@ packages:
core-js@3.29.1:
resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
+ core-js@3.49.0:
+ resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
+
cosmiconfig@5.2.1:
resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==}
engines: {node: '>=4'}
@@ -4294,6 +4331,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
+ dompurify@3.4.12:
+ resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
+
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -4868,6 +4908,9 @@ packages:
fetch-nodeshim@0.4.10:
resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==}
+ fflate@0.4.9:
+ resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
+
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -5723,6 +5766,27 @@ packages:
engines: {node: '>=22'}
hasBin: true
+ magic-observability@1.0.0:
+ resolution: {integrity: sha512-DTZvIMdIAuGLckNn5Hx5uI+VCmHffrDNO4dB/IAlktMCqJM/o2Xx8ccmhYu83OHq9JUlJvxRL+HbKjnraUfnOQ==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ '@posthog/react': '>=1.10.0'
+ posthog-js: '>=1.257.2'
+ posthog-node: '>=5.0.0'
+ posthog-react-native: '>=4.35.0'
+ react: '>=18.0.0'
+ peerDependenciesMeta:
+ '@posthog/react':
+ optional: true
+ posthog-js:
+ optional: true
+ posthog-node:
+ optional: true
+ posthog-react-native:
+ optional: true
+ react:
+ optional: true
+
magic-oxfmt-config@1.2.0:
resolution: {integrity: sha512-Wq46MqLnrZTJx1V2nJumdyW8OgA4URnhrfkYx6NYeewQRJAN+wiS1lxu1+5gU6BvlDetmbz7Ol/Xb8sxVXgW3A==}
engines: {node: '>=22.18.0'}
@@ -6501,6 +6565,9 @@ packages:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
+ posthog-js@1.407.2:
+ resolution: {integrity: sha512-5deTvXopn+NJWEmCUw8Ix/ms3cv2+60WJ73Vgbvu2wSkZY/FIj2uqAgCnq7IewGpGC+tH7CAbPYFzH6hw8eW1w==}
+
posthog-node@5.39.4:
resolution: {integrity: sha512-+fCQ7htBFRQQFbIzl1T0TA7bDwYyaB9XP308ZFMCUoB5LzTzOFxBa6TYVrxdH/VQl43WXTp6sf0QsG2Z4XlNBg==}
engines: {node: ^20.20.0 || >=22.22.0}
@@ -6560,6 +6627,14 @@ packages:
react-native-svg:
optional: true
+ preact@10.29.7:
+ resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==}
+ peerDependencies:
+ preact-render-to-string: '>=5'
+ peerDependenciesMeta:
+ preact-render-to-string:
+ optional: true
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -6615,6 +6690,9 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+ query-selector-shadow-dom@1.0.1:
+ resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
+
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -7654,6 +7732,9 @@ packages:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
+ web-vitals@5.3.0:
+ resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -9889,15 +9970,27 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@posthog/browser-common@0.2.1':
+ dependencies:
+ '@posthog/core': 1.45.1
+ '@posthog/types': 1.398.0
+
'@posthog/cli@0.8.1':
dependencies:
detect-libc: 2.1.2
- '@posthog/core@1.39.6':
+ '@posthog/core@1.45.1':
dependencies:
- '@posthog/types': 1.392.1
+ '@posthog/types': 1.398.0
- '@posthog/types@1.392.1': {}
+ '@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0)':
+ dependencies:
+ posthog-js: 1.407.2
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.17
+
+ '@posthog/types@1.398.0': {}
'@prisma/client@5.17.0(prisma@5.17.0)':
optionalDependencies:
@@ -11135,6 +11228,9 @@ snapshots:
'@types/stylis@4.2.5': {}
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@2.0.10': {}
'@types/unist@3.0.2': {}
@@ -11814,6 +11910,8 @@ snapshots:
core-js@3.29.1: {}
+ core-js@3.49.0: {}
+
cosmiconfig@5.2.1:
dependencies:
import-fresh: 2.0.0
@@ -12001,6 +12099,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
+ dompurify@3.4.12:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.1.0:
dependencies:
dom-serializer: 2.0.0
@@ -12694,6 +12796,8 @@ snapshots:
fetch-nodeshim@0.4.10: {}
+ fflate@0.4.9: {}
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -13731,6 +13835,14 @@ snapshots:
dependencies:
ts-morph: 28.0.0
+ magic-observability@1.0.0(@posthog/react@1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0))(posthog-js@1.407.2)(posthog-node@5.39.4(rxjs@7.8.1))(posthog-react-native@4.54.4(dd0719650ac7408b8594bb3923b10638))(react@19.2.0):
+ optionalDependencies:
+ '@posthog/react': 1.10.3(@types/react@19.2.17)(posthog-js@1.407.2)(react@19.2.0)
+ posthog-js: 1.407.2
+ posthog-node: 5.39.4(rxjs@7.8.1)
+ posthog-react-native: 4.54.4(dd0719650ac7408b8594bb3923b10638)
+ react: 19.2.0
+
magic-oxfmt-config@1.2.0(oxfmt@0.60.0):
optionalDependencies:
oxfmt: 0.60.0
@@ -14902,9 +15014,23 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ posthog-js@1.407.2:
+ dependencies:
+ '@posthog/browser-common': 0.2.1
+ '@posthog/core': 1.45.1
+ '@posthog/types': 1.398.0
+ core-js: 3.49.0
+ dompurify: 3.4.12
+ fflate: 0.4.9
+ preact: 10.29.7
+ query-selector-shadow-dom: 1.0.1
+ web-vitals: 5.3.0
+ transitivePeerDependencies:
+ - preact-render-to-string
+
posthog-node@5.39.4(rxjs@7.8.1):
dependencies:
- '@posthog/core': 1.39.6
+ '@posthog/core': 1.45.1
optionalDependencies:
rxjs: 7.8.1
@@ -14915,8 +15041,8 @@ snapshots:
posthog-react-native@4.54.4(dd0719650ac7408b8594bb3923b10638):
dependencies:
- '@posthog/core': 1.39.6
- '@posthog/types': 1.392.1
+ '@posthog/core': 1.45.1
+ '@posthog/types': 1.398.0
optionalDependencies:
'@react-native-async-storage/async-storage': 2.2.0(react-native@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@13.6.9(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))
'@react-navigation/native': 7.2.4(react-native@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@13.6.9(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
@@ -14928,6 +15054,8 @@ snapshots:
react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@13.6.9(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
react-native-svg: 15.15.3(react-native@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@13.6.9(supports-color@8.1.1))(@react-native/metro-config@0.85.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.0)(supports-color@8.1.1))(react@19.2.0)
+ preact@10.29.7: {}
+
prelude-ls@1.2.1: {}
pretty-format@26.6.2:
@@ -14984,6 +15112,8 @@ snapshots:
pure-rand@6.1.0: {}
+ query-selector-shadow-dom@1.0.1: {}
+
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
@@ -16108,6 +16238,8 @@ snapshots:
web-streams-polyfill@4.0.0-beta.3: {}
+ web-vitals@5.3.0: {}
+
webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 356c632e..1f4faa17 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -61,6 +61,7 @@ overrides:
minimumReleaseAgeExclude:
- eslint-plugin-safe-jsx@1.3.0
- magic-codemods@1.1.0
+ - magic-observability@1.0.0
- magic-oxfmt-config@1.2.0
- magic-oxlint-config@1.2.0
- magic-tsconfig@1.2.0
diff --git a/scripts/upload-posthog-sourcemaps-ota.sh b/scripts/upload-posthog-sourcemaps-ota.sh
index 12e35b14..485dcf3b 100755
--- a/scripts/upload-posthog-sourcemaps-ota.sh
+++ b/scripts/upload-posthog-sourcemaps-ota.sh
@@ -25,23 +25,57 @@ fi
cd "$(dirname "$0")/../apps/mobile"
OUTPUT_DIR="dist"
-# Bundle identifier is stable across OTA updates (only the JS changes), so
-# every OTA upload is tagged under the same release-name as native builds.
-# release-version is left to posthog-cli's own git auto-detection: it
-# derives commit/branch info from the checkout, which ties each OTA upload
-# to the exact commit `eas update` published -- more precise than the app's
-# semver for this path, since many OTAs land between version bumps.
+
+# The bundle identifier is stable across OTA updates (only the JS changes), so
+# every OTA upload is filed under the same release-name as native builds.
RELEASE_NAME="app.pegada"
+# posthog-cli's own docs call setting this explicitly "strongly recommended
+# during release CD workflows". It used to be left to the CLI's git
+# auto-detection, which read the same commit out of the checkout -- but only
+# as long as the checkout has git metadata, and it silently produced a
+# different value (or none) if it ever didn't. GITHUB_SHA in CI, the local
+# HEAD otherwise.
+RELEASE_VERSION="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}"
+
echo "Exporting JS bundle + sourcemaps to $OUTPUT_DIR..."
npx expo export --dump-sourcemap --output-dir "$OUTPUT_DIR"
# Chunk ids are already injected at bundle time by the posthog-react-native
# Metro serializer (see metro.config.js's getPostHogExpoConfig); only the
# upload step is needed here.
-echo "Uploading sourcemaps to PostHog..."
+#
+# --force is what makes this idempotent, and it is load-bearing. The Metro
+# serializer's chunk id is stable per platform bundle, not derived from the
+# bundle's contents, so every OTA update re-uploads the SAME two symbol sets
+# (one ios, one android) with different content. PostHog rejects that by
+# default:
+#
+# 400 release_id_mismatch "Symbol set already has a release ID"
+# 400 content_hash_mismatch "Symbol set already exists with different content."
+# Oops! Content mismatch: use --skip-on-conflict or --force
+#
+# which is exactly how run 30316394285 went red on main after the OTA had
+# already shipped. Of the two remedies the CLI offers, --force is the correct
+# one here and --skip-on-conflict is actively wrong: skipping keeps the
+# PREVIOUS bundle's maps, so every stack trace from the update we just
+# published would symbolicate against stale sources. An OTA is a rolling
+# deploy -- the newest bundle is the one in users' hands, and its maps are the
+# ones worth keeping.
+#
+# What --force does not fix: a symbol set that already carries a release id
+# keeps the first one it was given, because PostHog will not rebind it. The
+# CLI degrades to an unreleased upload on its own (--skip-release-on-fail,
+# on by default). That costs nothing that matters -- symbolication joins a
+# stack frame to a sourcemap by CHUNK ID, and the release is metadata beside
+# it. The app-side join is separate and intact: initExpo registers the OTA
+# update group as the `release` super property on every event (see
+# apps/mobile/src/services/observability.ts).
+echo "Uploading sourcemaps to PostHog ($RELEASE_NAME@$RELEASE_VERSION)..."
npx posthog-cli hermes upload \
--directory "$OUTPUT_DIR" \
- --release-name "$RELEASE_NAME"
+ --release-name "$RELEASE_NAME" \
+ --release-version "$RELEASE_VERSION" \
+ --force
echo "PostHog sourcemap upload complete."