Skip to content

feat(observability): route PostHog through magic-observability and unblock OTA sourcemaps - #111

Merged
GSTJ merged 3 commits into
mainfrom
feat/observability
Jul 28, 2026
Merged

feat(observability): route PostHog through magic-observability and unblock OTA sourcemaps#111
GSTJ merged 3 commits into
mainfrom
feat/observability

Conversation

@GSTJ

@GSTJ GSTJ commented Jul 28, 2026

Copy link
Copy Markdown
Owner

📝 DESCRIPTION:

Two things, both PostHog.

1. The three clients become one

apps/mobile and packages/api had each hand-rolled their own PostHog wiring and reached different answers to the same questions; apps/nextjs had nothing. magic-observability is pegada's own shape, generalised across the GSTJ repos, so all three now share one client surface, one captureError, and one error boundary that works on React and React Native.

apps/mobile (magic-observability/expo)

  • services/posthog.tsservices/observability.ts, built with initExpo. Same standalone-instance-then-hand-it-to-the-provider shape as before, which is still the only way to get configured error tracking and PostHogProvider's screen tracking.
  • environment and the OTA update group (release) are registered by initExpo itself, replacing the hand-rolled posthog.register({ code_bundle_id }) block in src/config.ts.
  • NetworkBoundary swaps PostHogErrorBoundary for ObservabilityBoundary.
  • PostHogProvider is now skipped entirely when there is no key, rather than being handed an uninitialised client.

packages/api (magic-observability/node)

  • shared/posthog.tsshared/observability.ts, runtime: "serverless" (the old flushAt: 1, flushInterval: 0 under a name).
  • FlagService reads the raw posthog-node handle — feature flags are deliberately not wrapped — and a null client falls through to the caller's defaultValue, 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.tsx now reports what it catches instead of only rendering; its 500 markup moved to components/something-went-wrong.tsx so 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.screenViewed still calls posthog.screen() on the raw SDK: $screen is 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:

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 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-conflict would 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:

  • Mobile gains uncaught exceptions, unhandled rejections and native crashes captured by default. Previously only explicit sendError calls were reported.
  • Everything thrown is normalised into a real Error on the way in. throw "nope" used to reach PostHog with no stack and no message.
  • Error context is flattened to dotted keys, so nested properties become filterable.
  • EAS Publish stops failing.

Dormant until NEXT_PUBLIC_POSTHOG_KEY is set in Vercel — the whole apps/nextjs half. Pageviews, client exceptions, the top-level boundary and app/error.tsx all 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 existing POSTHOG_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 .env now boots instead of throwing at import time.

Variable Read by Set today?
EXPO_PUBLIC_POSTHOG_KEY → falls back to EXPO_PUBLIC_POSTHOG_API_KEY mobile yes, under the old name
POSTHOG_KEY → falls back to POSTHOG_API_KEY @pegada/api, Next server yes, under the old name
NEXT_PUBLIC_POSTHOG_KEY Next browser no
*_POSTHOG_HOST all optional, defaults to https://us.i.posthog.com

Both 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-rolled posthog.register(...). initExpo sets { environment, release } from the same values, so code_bundle_id becomes release and carries the identical OTA update group. environment is unchanged.

Any saved PostHog insight, filter or alert on code_bundle_id will need repointing at release. Nothing else moved, and no event name changed.

Notes

  • magic-observability@1.0.0 published today; pnpm 11 blocks anything under 24h old, hence the version-pinned minimumReleaseAgeExclude entry.
  • posthog-js is now in the site's client bundle even while it no-ops — initWebAnalytics imports it statically. First Load JS shared by all is 177 kB.
  • packages/api and apps/nextjs each build their own posthog-node singleton, lazily. They capture different things (explicit sendError vs. 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 release property feeds, and splitting them would leave one PR merging into a red main.

Verified locally: pnpm install clean, lint 0 (including magic/no-manual-classname), format clean, typecheck 5/5, test 73 passing, next build green, and expo export --platform ios bundles — Metro resolves the package's subpath exports.

pnpm build at the repo root fails on this machine for a missing local .env.dev; confirmed identical on a clean checkout of main, so it is not from this branch.

GSTJ added 3 commits July 27, 2026 22:10
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
@GSTJ
GSTJ merged commit 19b8d55 into main Jul 28, 2026
9 checks passed
@GSTJ
GSTJ deleted the feat/observability branch July 28, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant