feat(observability): route PostHog through magic-observability and unblock OTA sourcemaps - #111
Merged
Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📝 DESCRIPTION:
Two things, both PostHog.
1. The three clients become one
apps/mobileandpackages/apihad each hand-rolled their own PostHog wiring and reached different answers to the same questions;apps/nextjshad nothing.magic-observabilityis pegada's own shape, generalised across the GSTJ repos, so all three now share one client surface, onecaptureError, and one error boundary that works on React and React Native.apps/mobile (
magic-observability/expo)services/posthog.ts→services/observability.ts, built withinitExpo. Same standalone-instance-then-hand-it-to-the-provider shape as before, which is still the only way to get configured error tracking andPostHogProvider's screen tracking.environmentand the OTA update group (release) are registered byinitExpoitself, replacing the hand-rolledposthog.register({ code_bundle_id })block insrc/config.ts.NetworkBoundaryswapsPostHogErrorBoundaryforObservabilityBoundary.PostHogProvideris now skipped entirely when there is no key, rather than being handed an uninitialised client.packages/api (
magic-observability/node)shared/posthog.ts→shared/observability.ts,runtime: "serverless"(the oldflushAt: 1, flushInterval: 0under a name).FlagServicereads the rawposthog-nodehandle — feature flags are deliberately not wrapped — and a null client falls through to the caller'sdefaultValue, the same branch a PostHog outage takes.apps/nextjs (
magic-observability/web,/react,/next) — all new:src/instrumentation-client.ts(browser, pre-hydration),src/instrumentation.ts(onRequestError),src/app/providers.tsx(provider + top-level boundary).app/error.tsxnow reports what it catches instead of only rendering; its 500 markup moved tocomponents/something-went-wrong.tsxso both boundaries render the same screen.No analytics events were removed. The Amplitude-era
analytics.track({ event_type, event_properties })call shape is untouched across its ~30 call sites — only the transport underneath moved.analytics.screenViewedstill callsposthog.screen()on the raw SDK:$screenis a mobile-only event and the shared client surface deliberately stops short of methods four of the five platforms cannot implement.2. EAS Publish goes green again
The workflow has been red on main since run 30316394285 — failing after the OTA it was publishing had already shipped:
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 re-uploads the same two symbol sets with new content inside them. The upload now passes
--force(the newest bundle is the one in users' hands, so its maps are the ones worth keeping —--skip-on-conflictwould keep the previous bundle's maps and symbolicate every new stack trace against stale sources) and an explicit--release-version, which posthog-cli's docs call strongly recommended for release CD.What is live today, and what activates when a key lands
Live now — mobile and the API already have their tokens, so this ships as a behaviour change there:
sendErrorcalls were reported.Erroron the way in.throw "nope"used to reach PostHog with no stack and no message.Dormant until
NEXT_PUBLIC_POSTHOG_KEYis set in Vercel — the wholeapps/nextjshalf. Pageviews, client exceptions, the top-level boundary andapp/error.tsxall resolve to a no-op client that writes nothing and logs nothing. Setting the variable is the entire activation step; no code change follows it. The server half (onRequestError) already picks up the existingPOSTHOG_API_KEY, so it is live wherever that is set.Env variables
Documented in
.env.example. Every one of them is optional — no key means a silent no-op client, which is why a clone with no.envnow boots instead of throwing at import time.EXPO_PUBLIC_POSTHOG_KEY→ falls back toEXPO_PUBLIC_POSTHOG_API_KEYPOSTHOG_KEY→ falls back toPOSTHOG_API_KEY@pegada/api, Next serverNEXT_PUBLIC_POSTHOG_KEY*_POSTHOG_HOSThttps://us.i.posthog.comBoth names are read everywhere, newest first, because the old ones are what EAS and Vercel already store the token under and renaming them there is not something this repo can do.
One property rename to know about
Mobile's super properties were
{ environment, code_bundle_id }, set by a hand-rolledposthog.register(...).initExposets{ environment, release }from the same values, socode_bundle_idbecomesreleaseand carries the identical OTA update group.environmentis unchanged.Any saved PostHog insight, filter or alert on
code_bundle_idwill need repointing atrelease. Nothing else moved, and no event name changed.Notes
magic-observability@1.0.0published today; pnpm 11 blocks anything under 24h old, hence the version-pinnedminimumReleaseAgeExcludeentry.posthog-jsis now in the site's client bundle even while it no-ops —initWebAnalyticsimports it statically. First Load JS shared by all is 177 kB.packages/apiandapps/nextjseach build their ownposthog-nodesingleton, lazily. They capture different things (explicitsendErrorvs. Next's request errors) and the Next one is only constructed on the first server error.💛 MY AMAZING PR CONTAINS:
Does this PR change only one thing (And only what is necessary for it)?
Two, and they are joined at the hip: the CI failure being fixed is in the same sourcemap pipeline the mobile client's
releaseproperty feeds, and splitting them would leave one PR merging into a red main.Verified locally:
pnpm installclean,lint0 (includingmagic/no-manual-classname),formatclean,typecheck5/5,test73 passing,next buildgreen, andexpo export --platform iosbundles — Metro resolves the package's subpath exports.pnpm buildat the repo root fails on this machine for a missing local.env.dev; confirmed identical on a clean checkout ofmain, so it is not from this branch.