feat(react-native): record automatic exception steps - #4573
Conversation
`addExceptionStep` shipped in 4.50.0, but every step is manual, so an exception carries a timeline only where somebody added a call. Bugsnag and Sentry leave breadcrumbs for navigation, taps and lifecycle on their own. Set `errorTracking.exceptionSteps.automatic` to `true`, or to an object, to record a step for a screen change, an autocaptured tap, or an app lifecycle transition. Every signal stays off by default, because each step adds bytes to every captured exception. The recorder hangs off the existing `processBeforeEnqueue` chokepoint, which already sees `$screen`, `$autocapture` and the lifecycle events, so nothing new is patched or wrapped. Automatic steps share the byte-bounded buffer and the native forwarding that manual steps use, so a native crash carries the same timeline. Automatic steps carry `$type`, which the error tracking timeline already renders. `$type` and `$level` join `EXCEPTION_STEP_INTERNAL_FIELDS` in `@posthog/core` to match that wire contract, and neither becomes reserved, so a caller can still categorise a manual step. A manual step stays untyped. An event that `before_send` dropped leaves no step: a caller drops an event to keep data out of PostHog, so it must not reappear in an exception payload. A tap step carries neither `$el_text` nor touch coordinates, because element text is user-visible copy that can hold personal data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR overviewThis pull request adds automatic exception-step recording to the React Native SDK, capturing recent screen and tap context for exception events. One low-impact privacy issue remains: resetting the active identity does not clear buffered exception steps, so a later user on the same device could report the previous user’s recent activity under the new identity. One other issue has already been addressed, and clearing this buffer during reset would resolve the remaining concern. Open issues (1)
Fixed/addressed: 1 · PR risk: 4/10 |
|
Reviews (1): Last reviewed commit: "feat(react-native): record automatic exc..." | Re-trigger Greptile |
posthog-node Compliance ReportDate: 2026-08-20 21:12:18 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
posthog-js Compliance ReportDate: 2026-08-20 21:16:05 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
`reset()` is the logout boundary, but the exception-step buffer survived it. On a shared device the next user could trigger an exception that still carried the previous user's screen names and tap labels. The buffer only cleared on SDK close. Automatic steps make this worse, because the buffer now fills on every screen change and tap rather than only where an app calls `addExceptionStep`. `reset()` now clears the buffer, for automatic and manual steps alike. The clear runs outside the wrapped `super.reset()`, so a disabled client or a client that is not yet initialized still drops the steps. A capture still leaves the buffer intact, so every exception in one session carries the same steps. This reverses one line of the previous contract, which said the buffer clears on SDK close and not on an identity change. `reset()` already clears `SessionReplayEventTriggerActivatedSession` for the same reason, and posthog-ios clears `_lastScreenName` in its own `reset()`. The mirrored native buffer still survives a native reset. The plugin bridge exposes only `addExceptionStep`, and `PostHogSDK.reset()` in posthog-ios does not clear its buffer, so that half needs changes in posthog-ios, posthog-android and @posthog/react-native-plugin. Also commit the regenerated public API references, which `pnpm check:public-api` requires to be current. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Size Change: +1.51 kB (+0.01%) Total Size: 19.9 MB 📦 View Changed
ℹ️ View Unchanged
|
| "path": "src/types.ts" | ||
| }, | ||
| { | ||
| "id": "AutomaticExceptionStepsConfig", |
There was a problem hiding this comment.
Can we specialize the react native config for this as I don't think it applies to node ?
There was a problem hiding this comment.
Agreed, done in 4042b7d. AutomaticExceptionStepsOptions, the resolver and the $type vocabulary now live in packages/react-native/src/error-tracking/automatic-steps.ts. navigation, taps and lifecycle are signals React Native observes, so they had no business in a type posthog-node re-exports.
This file is back to what main has. Core now adds one field name and nothing else.
| export const EXCEPTION_STEP_INTERNAL_FIELDS = { | ||
| TYPE: '$type', | ||
| MESSAGE: '$message', | ||
| LEVEL: '$level', |
There was a problem hiding this comment.
do we use level anywhere ?
There was a problem hiding this comment.
No, nothing writes it. I added $level only to mirror the field the error tracking timeline already reads, and this PR never sets it. I removed it in 4042b7d.
Core now adds only TYPE: '$type', which automatic steps do set. Neither field is reserved, so a caller can still categorise a manual step by hand.
ioannisj
left a comment
There was a problem hiding this comment.
Left a small comment on reset() behavior. Looks good to me otherwise, i'll leave the final approval for error tracking team since this is ultimately a product decision
|
|
||
| Automatic steps carry `$type`, which the error tracking timeline already renders, and they share the byte-bounded buffer and the native forwarding that manual steps use. A manual step stays untyped. An event that `before_send` dropped leaves no step. | ||
|
|
||
| `reset()` now clears the exception-step buffer, for automatic and manual steps alike. Exception steps are user-session state, so on a shared device the previous user's screen names and tap labels must not reach the next user's exception. The buffer still survives a capture, so every exception in one session carries the same steps. |
There was a problem hiding this comment.
This leaves RN the odd one out from our SDKs? iirc, web and other mobile SDKs don't clear exception steps on reset.
This was previously discussed here.
For exception tracking I think steps should be scoped to app session and not user session. The whole idea isn't understanding user behavior, it's understanding app behavior, and user changing is part of a normal app flow. Losing the steps around a login or logout flow is losing exactly the context you'd want when something breaks there?
cc @hpouillot wdyt?
There was a problem hiding this comment.
You are right, and I reverted it in 4042b7d. RN keeps steps across reset() again, so it matches web, iOS, Android and Flutter. I added a test that locks the behavior in, plus a comment on reset() so the next person does not clear it by accident.
Your app-behavior argument is the one that convinced me, and my privacy argument turned out weaker than I first thought. Automatic steps carry no $el_text and no touch coordinates, only structural labels like Screen: Cart and Tap: CheckoutButton, so what crossed the boundary was app shape rather than user content.
For context on why I touched it at all: the review bot flagged the shared-device case, and automatic steps do change the volume, because the buffer now fills on every screen change and tap instead of only where an app calls addExceptionStep. That is a difference in degree, not a reason to break consistency with four SDKs.
Two notes from the thread you linked, for whoever makes the call.
The identity-change question reads as open rather than settled. @hpouillot said "I think I would scope it to the session not the device", then "so ideally we reset the buffer when user changes", then "but I don't mind releasing a first version without". You said "I don't have a strong opinion tbh" and made the app-level argument, and he answered "Yes ok make sense". So the current behavior looks like the v1 choice, and hugues leaned the other way at the time.
There is also a middle option in that thread, and it is hugues's: "we could also create an automatic exception step 'user changed'?" You deferred it partly because you did not want to "mix up completely manual + semi-automatic api at the sdk level just yet". This PR adds exactly that semi-automatic API, so that objection has lapsed. An identity step at reset() keeps the login and logout context you want, and it marks the boundary so nobody reads user A's steps as user B's.
I am happy to add that step here, or to leave it out and keep this PR to automatic steps only. Your call, and hugues's.
There was a problem hiding this comment.
I agree that a "user changed" step would be valuable here as well, esp now that we're adding automatic exception steps
Address review on #4573. 1. Move the automatic-steps options, the resolver and the `$type` vocabulary out of `@posthog/core` and into the React Native package. `navigation`, `taps` and `lifecycle` are signals React Native observes, so they have no meaning in `posthog-node`, which re-exports core types. The node public API reference goes back to what main has. 2. Drop `LEVEL: '$level'` from `EXCEPTION_STEP_INTERNAL_FIELDS`. Nothing in this PR writes `$level`. Core now adds only `TYPE: '$type'`, which automatic steps do set. 3. Revert the `reset()` clear. Exception steps scope to the app session, not to the user session, so a logout keeps the steps that explain a failure in the logout flow itself. Every other SDK keeps them too. A test now locks the behavior in, and `reset()` carries a comment so nobody clears it by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| * @public | ||
| */ | ||
| reset(propertiesToKeep?: PostHogPersistedProperty[]): void { | ||
| // NOTE: reset() deliberately keeps the exception-step buffer. Steps scope to the app session, not |
There was a problem hiding this comment.
Low: Exception steps cross user resets
reset() changes the active identity but now retains buffered screen names and tap labels. A subsequent user on the same device can trigger an exception that sends the previous user's activity under the new identity; clear the exception-step buffer at the start of reset() before calling super.reset().
There was a problem hiding this comment.
Tracking, but not changing it. You flagged this on the first commit, I did clear the buffer in reset(), and @ioannisj asked for that revert on review. Exception steps scope to the app session across every PostHog SDK, so clearing here would make React Native the only one that drops the steps explaining a failure in a login or logout flow.
The exposure is narrower than it reads. An automatic step carries no $el_text and no touch coordinates, only a structural label such as Screen: Cart or Tap: CheckoutButton, so what crosses the boundary is app shape rather than user content. A manual step can carry more, and the docs already tell callers to keep personal data out of step payloads.
The open follow-up is hugues's idea from the original design thread: record a "user changed" step at reset() instead of clearing. That keeps the login and logout context and marks the identity boundary in the timeline. That decision sits with the error tracking team, in the thread above.
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
Problem
addExceptionStepshipped inposthog-react-native4.50.0 (#3861), and it does what the docs promise. But every step is manual, so an exception carries a timeline only where somebody remembered to add a call. Bugsnag and Sentry leave breadcrumbs for navigation, network requests, taps and lifecycle on their own, so a crash arrives with a trail even in code nobody instrumented.No PostHog SDK records a step automatically today. I checked
posthog-js,posthog-ios,posthog-androidandposthog-flutter:addExceptionStepis a public API in each, and nothing calls it internally.This came from a React Native customer who asked for the Bugsnag Breadcrumbs equivalent, found the manual API, and said the manual calls feel like the part they would forget.
What this does
Set
errorTracking.exceptionSteps.automaticto record a step for a signal the SDK already observes:navigation$screenScreen: Cart,$type: navigationtaps$autocapture(touch)Tap: CheckoutButton,$type: taplifecycleApplication Openedand the other fourApplication Backgrounded,$type: lifecycleEvery signal is off by default. Each step adds bytes to every captured exception, so this is opt-in, which also matches
errorTracking.autocapture.How it works
The recorder hangs off the existing
processBeforeEnqueuechokepoint inposthog-rn.ts, which already sees$screen,$autocaptureand the lifecycle events. Nothing new is patched or wrapped. The mapping itself is a pure function, so it is testable without the SDK.Automatic steps share the byte-bounded buffer and the native forwarding that manual steps use, so a native crash carries the same timeline.
Decisions worth a look
$typeon the wire. The error tracking UI already reads$typeand$levelper step (frontend/src/lib/components/Errors/exceptionStepsValidation.ts) and renders$typeas the secondary line in the session timeline, but no SDK emits either one. This adds both toEXCEPTION_STEP_INTERNAL_FIELDSin@posthog/coreto match that contract. Neither becomes reserved, so a caller can still categorise a manual step by hand.$type: 'manual'would put a new label next to every existing step for every current user, so only automatic steps carry$type.before_sendto keep data out of PostHog. Resurrecting it inside an exception payload would defeat that, so only a surviving event records a step.$el_textnor coordinates. Element text is user-visible copy that can hold personal data, and the label fromtag_nameis either aph-labelor a component display name.@posthog/core, not in the RN package, so iOS, Android and web can adopt the same shape and the same$typevocabulary. Only React Native reads it today.Not in this PR
requestbreadcrumb is the notable remaining gap. It needs request interception, and the RN SDK patches onlyfetch, notXMLHttpRequest, so anaxiosdefault would go unrecorded. That deserves its own change.errorTracking.autocapture.consoleand the Logs product. A third path would be redundant.posthog.com/docs/error-tracking/captureneeds an automatic-steps section. Happy to follow with that PR once the config shape is settled here.Testing
exception-steps-automatic.spec.tscovers the mapping and the buffer wiring;exception-steps-automatic-capture.spec.tsdrives a live client throughready()so the assertions run through the real enqueue path rather than a mocked one.automatic-steps.tsis at 100% statement coverage.resolveAutomaticExceptionStepsConfigand the$type/$levelstrip behavior are covered; the full core suite passes (845).browserpackage (persistence-key-policy,module.test.ts, and a playwright spec run under jest) reproduce on a cleanmainwith these changes stashed, so they are unrelated.errorTracking.exceptionSteps.automatic: trueis wired intoexample-expo-53for manual verification: switch tabs, tap a button, background the app, then capture an exception and read the timeline.🤖 Generated with Claude Code