From 65f64f94a02aeffdefd55684371b025e546fd176 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 22:10:22 -0300 Subject: [PATCH 1/3] feat(observability): route PostHog through magic-observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PostHog clients in this repo were hand-rolled, and each got a different answer to the same questions. `apps/mobile` constructed `new PostHog(...)` with no error-tracking config at all, so uncaught exceptions and unhandled rejections were only reported where somebody remembered to call `sendError`. `packages/api` had arrived independently at `flushAt: 1, flushInterval: 0`. Neither normalised what was thrown, so a `throw "nope"` anywhere reached PostHog as an exception with no stack and no message, and neither flattened context, so a nested property was one nobody could filter on. `apps/nextjs` had nothing. magic-observability is that shape, generalised. It is the same client surface on all three, one `captureError`, one error boundary that works on React and React Native, and a no-op client when there is no key. Mobile keeps everything it had. The Amplitude-era `analytics.track({ event_type })` call shape is untouched across its ~30 call sites; only the transport underneath moved. `screenViewed` still calls `posthog.screen()` directly, because `$screen` is a mobile-only event and the shared surface deliberately stops short of methods four platforms out of five cannot implement. What it gains is uncaught exceptions, unhandled rejections and native crashes on by default, and `PostHogErrorBoundary` becomes `ObservabilityBoundary` so a render error in NetworkBoundary reports in the same shape as one from the API. The site is new wiring: `instrumentation-client.ts` before hydration, `onRequestError` on the server, a top-level boundary, and `app/error.tsx` now reports instead of only rendering. `NEXT_PUBLIC_POSTHOG_KEY` does not exist for this project yet, so all of it is a silent no-op today. That is the designed state — setting the variable is the whole activation step. Two env names are read everywhere, newest first: `EXPO_PUBLIC_POSTHOG_KEY` then `EXPO_PUBLIC_POSTHOG_API_KEY`, `POSTHOG_KEY` then `POSTHOG_API_KEY`. The older names are what EAS and Vercel already store the token under, and renaming them there is not something this repo can do. Both are now optional: without a key the client is silent rather than throwing at import, so a clone with no `.env` boots. `magic-observability@1.0.0` published today, and pnpm 11 blocks anything under 24h old, hence the minimumReleaseAgeExclude entry. Claude-Session: https://claude.ai/code/session_01Fe92vvdc4R3F4BDBFoLcw1 --- .env.example | 35 +++++ apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 47 +++--- .../src/components/NetworkBoundary/index.tsx | 15 +- apps/mobile/src/config.ts | 24 +-- apps/mobile/src/services/analytics/index.ts | 37 +++-- apps/mobile/src/services/config.ts | 18 ++- apps/mobile/src/services/error-tracking.ts | 23 ++- apps/mobile/src/services/observability.ts | 59 +++++++ apps/mobile/src/services/posthog.ts | 16 -- apps/nextjs/package.json | 4 + apps/nextjs/src/app/error.tsx | 49 +++--- apps/nextjs/src/app/layout.tsx | 5 +- apps/nextjs/src/app/providers.tsx | 42 +++++ .../src/components/something-went-wrong.tsx | 38 +++++ apps/nextjs/src/env.ts | 32 ++++ apps/nextjs/src/instrumentation-client.ts | 18 +++ apps/nextjs/src/instrumentation.ts | 29 ++++ packages/api/package.json | 1 + packages/api/src/errors/errors.ts | 11 +- .../suggestion-service.test.ts | 13 +- packages/api/src/services/flag-service.ts | 11 +- .../api/src/services/message-service.test.ts | 13 +- packages/api/src/shared/config.ts | 15 +- packages/api/src/shared/observability.ts | 36 +++++ packages/api/src/shared/posthog.ts | 12 -- pnpm-lock.yaml | 144 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 28 files changed, 621 insertions(+), 128 deletions(-) create mode 100644 apps/mobile/src/services/observability.ts delete mode 100644 apps/mobile/src/services/posthog.ts create mode 100644 apps/nextjs/src/app/providers.tsx create mode 100644 apps/nextjs/src/components/something-went-wrong.tsx create mode 100644 apps/nextjs/src/env.ts create mode 100644 apps/nextjs/src/instrumentation-client.ts create mode 100644 apps/nextjs/src/instrumentation.ts create mode 100644 packages/api/src/shared/observability.ts delete mode 100644 packages/api/src/shared/posthog.ts 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..56b54765 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,6 +2471,9 @@ 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'} @@ -2461,9 +2482,25 @@ packages: '@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==} engines: {node: '>=16.13'} @@ -3457,6 +3494,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 +4137,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 +4337,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 +4914,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 +5772,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 +6571,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 +6633,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 +6696,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 +7738,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,6 +9976,11 @@ 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 @@ -9897,8 +9989,21 @@ snapshots: dependencies: '@posthog/types': 1.392.1 + '@posthog/core@1.45.1': + dependencies: + '@posthog/types': 1.398.0 + + '@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.392.1': {} + '@posthog/types@1.398.0': {} + '@prisma/client@5.17.0(prisma@5.17.0)': optionalDependencies: prisma: 5.17.0 @@ -11135,6 +11240,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 +11922,8 @@ snapshots: core-js@3.29.1: {} + core-js@3.49.0: {} + cosmiconfig@5.2.1: dependencies: import-fresh: 2.0.0 @@ -12001,6 +12111,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 +12808,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 +13847,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,6 +15026,20 @@ 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 @@ -14928,6 +15066,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 +15124,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 +16250,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 From 691112e297938e1b89927c48b3bc53fbd691ef4f Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 22:10:22 -0300 Subject: [PATCH 2/3] fix(ci): make the OTA sourcemap upload idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EAS Publish has been red on main since run 30316394285, after the OTA it was publishing had already shipped: 400 release_id_mismatch Symbol set 568c270f… already has a release ID 400 content_hash_mismatch Symbol set 568c270f… already exists with different content. Oops! Content mismatch: use --skip-on-conflict or --force The posthog-react-native Metro serializer's chunk id is stable per platform bundle rather than derived from the bundle's contents, so every OTA update re-uploads the same two symbol sets — one ios, one android — with new content inside them. PostHog refuses that by default, and refuses harder once a symbol set has been bound to a release. `--force` is the right half of the CLI's own advice. `--skip-on-conflict` is the wrong half here: it keeps the previous bundle's maps, so every stack trace from the update just published would symbolicate against stale sources. An OTA is a rolling deploy; the newest bundle is the one in users' hands. `--release-version` is now passed explicitly, which posthog-cli's docs call strongly recommended for release CD. It was 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 degraded silently if it ever didn't. What this does not fix, deliberately: a symbol set that already carries a release id keeps the first one, because PostHog will not rebind it, and the CLI degrades to an unreleased upload on its own. Nothing that matters is lost — symbolication joins a frame to a map by chunk id, not by release, and the app-side join is separate and intact now that initExpo registers the OTA update group as `release` on every event. Claude-Session: https://claude.ai/code/session_01Fe92vvdc4R3F4BDBFoLcw1 --- scripts/upload-posthog-sourcemaps-ota.sh | 50 ++++++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) 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." From cf19c41c9f45b4a81c39b8797327be2a2172a359 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 22:12:39 -0300 Subject: [PATCH 3/3] build(deps): dedupe @posthog/core after adding posthog-node to the site apps/nextjs's new posthog-node resolved a newer @posthog/core and @posthog/types than posthog-react-native had pinned, leaving two copies of each in the lockfile. The Dedupe check catches exactly this. Claude-Session: https://claude.ai/code/session_01Fe92vvdc4R3F4BDBFoLcw1 --- pnpm-lock.yaml | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56b54765..d6ae0179 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2479,9 +2479,6 @@ packages: 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==} @@ -2495,9 +2492,6 @@ packages: '@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==} @@ -9985,10 +9979,6 @@ snapshots: dependencies: detect-libc: 2.1.2 - '@posthog/core@1.39.6': - dependencies: - '@posthog/types': 1.392.1 - '@posthog/core@1.45.1': dependencies: '@posthog/types': 1.398.0 @@ -10000,8 +9990,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@posthog/types@1.392.1': {} - '@posthog/types@1.398.0': {} '@prisma/client@5.17.0(prisma@5.17.0)': @@ -15042,7 +15030,7 @@ snapshots: 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 @@ -15053,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)