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."