Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/automatic-exception-steps-react-native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@posthog/core': minor
'posthog-react-native': minor
---

Add automatic exception steps to React Native, so a captured exception carries a timeline even where nobody called `addExceptionStep`. Set `errorTracking.exceptionSteps.automatic` to `true`, or to an object, to record a step for a screen change (`navigation`), an autocaptured tap (`taps`), or an app lifecycle transition (`lifecycle`). Every signal stays off by default, because each step adds bytes to every captured exception.

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.

The buffer keeps its existing lifetime. A capture keeps the steps, and `reset()` keeps them too, because steps scope to the app session rather than to the user session.
4 changes: 4 additions & 0 deletions examples/example-expo-53/app/(tabs)/error-tracking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export default function ErrorTrackingScreen() {
Record breadcrumb-style steps with addExceptionStep. Buffered steps attach to every captured
$exception as $exception_steps — including the manual captures above and the native crash below.
</ThemedText>
<ThemedText>
This example also sets errorTracking.exceptionSteps.automatic, so switching tabs, tapping a button
and backgrounding the app each leave their own step. Those steps carry a $type.
</ThemedText>
{EXCEPTION_STEPS.map(({ label, message, properties }) => (
<Button
key={label}
Expand Down
5 changes: 5 additions & 0 deletions examples/example-expo-53/app/posthog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const posthog = new PostHog(process.env.EXPO_PUBLIC_POSTHOG_PROJECT_API_K
// `uploadNativeSymbols` option in app.json).
nativeCrashes: true,
},
exceptionSteps: {
// Record a step for every screen change, autocaptured tap and lifecycle transition, so a
// captured exception carries a timeline without an addExceptionStep call at each site.
automatic: true,
},
},
// Inject X-POSTHOG-DISTINCT-ID and X-POSTHOG-SESSION-ID on outgoing fetch
// requests to these hostnames. Used by the Tracing Headers screen to verify
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/error-tracking/exception-steps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ describe('exception steps', () => {
droppedKeys: [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE, EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP],
})
})

it('keeps a caller-provided $type, which the SDK does not own', () => {
const result = stripReservedExceptionStepFields({
[EXCEPTION_STEP_INTERNAL_FIELDS.TYPE]: 'checkout',
})

expect(result).toEqual({
sanitizedProperties: { $type: 'checkout' },
droppedKeys: [],
})
})
})

describe('ExceptionStepsBuffer', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/error-tracking/exception-steps.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { isArray, isNumber, isObject, isString, safeJsonStringify } from '@/utils'

export const EXCEPTION_STEP_INTERNAL_FIELDS = {
TYPE: '$type',
MESSAGE: '$message',
TIMESTAMP: '$timestamp',
} as const

/**
* Only `$message` and `$timestamp` are reserved: the SDK owns their canonical values, so a caller
* cannot spoof them. `$type` stays writable by a caller who wants to categorise their own steps, and
* an SDK sets it itself only on a step the SDK records without the caller asking.
*/
const RESERVED_EXCEPTION_STEP_KEYS = new Set<string>([
EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE,
EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2239,6 +2239,28 @@
],
"path": "dist/error-tracking/index.d.ts"
},
{
"id": "AutomaticExceptionStepsOptions",
"name": "AutomaticExceptionStepsOptions",
"properties": [
{
"description": "Screen changes.",
"type": "boolean",
"name": "navigation"
},
{
"description": "Taps the SDK already autocaptures.",
"type": "boolean",
"name": "taps"
},
{
"description": "App lifecycle transitions such as open, foreground and background.",
"type": "boolean",
"name": "lifecycle"
}
],
"path": "dist/error-tracking/automatic-steps.d.ts"
},
{
"id": "BaseException",
"name": "BaseException",
Expand Down Expand Up @@ -2593,6 +2615,11 @@
"id": "ExceptionStepsOptions",
"name": "ExceptionStepsOptions",
"properties": [
{
"description": "Records steps for app signals the SDK already observes, so an exception carries a timeline even where nobody called `addExceptionStep`. Every signal is off by default, because each step adds bytes to every captured exception.\nPass `true` to enable every signal, or an object to enable single signals.\nfalse",
"type": "boolean | AutomaticExceptionStepsOptions",
"name": "automatic"
},
{
"description": "Whether exception steps are recorded and attached. true",
"type": "boolean",
Expand Down
168 changes: 168 additions & 0 deletions packages/react-native/src/error-tracking/automatic-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { isString, isArray, isObject } from '@posthog/core'

/**
* `$type` values the SDK sets on an automatic step, so the error tracking timeline labels the step.
* The signals are the ones React Native observes, so the vocabulary lives here rather than in
* `@posthog/core`, which every SDK shares.
*/
export const EXCEPTION_STEP_TYPES = {
NAVIGATION: 'navigation',
TAP: 'tap',
LIFECYCLE: 'lifecycle',
} as const

export type ExceptionStepType = (typeof EXCEPTION_STEP_TYPES)[keyof typeof EXCEPTION_STEP_TYPES]

/**
* Which app signals the SDK turns into automatic steps. Every signal is opt-in, because a step adds
* bytes to each captured exception and the buffer already carries whatever the caller added by hand.
*/
export type AutomaticExceptionStepsOptions = {
/** Screen changes. @default false */
navigation?: boolean
/** Taps the SDK already autocaptures. @default false */
taps?: boolean
/** App lifecycle transitions such as open, foreground and background. @default false */
lifecycle?: boolean
}

export type ResolvedAutomaticExceptionStepsOptions = {
navigation: boolean
taps: boolean
lifecycle: boolean
}

const ALL_SIGNALS_OFF: ResolvedAutomaticExceptionStepsOptions = {
navigation: false,
taps: false,
lifecycle: false,
}

const ALL_SIGNALS_ON: ResolvedAutomaticExceptionStepsOptions = {
navigation: true,
taps: true,
lifecycle: true,
}

/**
* Resolves the automatic-steps options. `true` enables every signal, `false` and `undefined` disable
* every signal, and an object enables only the signals it sets.
*/
export function resolveAutomaticExceptionStepsOptions(
options?: boolean | AutomaticExceptionStepsOptions | null
): ResolvedAutomaticExceptionStepsOptions {
if (options === true) {
return { ...ALL_SIGNALS_ON }
}

if (!options || !isObject(options)) {
return { ...ALL_SIGNALS_OFF }
}

return {
navigation: options.navigation ?? ALL_SIGNALS_OFF.navigation,
taps: options.taps ?? ALL_SIGNALS_OFF.taps,
lifecycle: options.lifecycle ?? ALL_SIGNALS_OFF.lifecycle,
}
}

/**
* App lifecycle events the SDK captures in `captureAppLifecycleEvents`. The step message is the
* event name itself, so the timeline reads the same as the event list.
*/
const LIFECYCLE_EVENTS = new Set<string>([
'Application Installed',
'Application Updated',
'Application Opened',
'Application Became Active',
'Application Backgrounded',
])

const TOUCH_EVENT_TYPE = 'touch'

export type AutomaticExceptionStep = {
type: ExceptionStepType
message: string
}

/**
* Maps an enqueued event to the automatic exception step it should leave behind, or `undefined` when
* the event is not a signal the caller enabled.
*
* The function is pure and reads only the event name and its properties, so the caller can run it on
* the capture path without touching the SDK's state.
*/
export function buildAutomaticExceptionStep(
config: ResolvedAutomaticExceptionStepsOptions,
event: unknown,
properties: unknown
): AutomaticExceptionStep | undefined {
if (!isString(event)) {
return undefined
}

if (config.navigation && event === '$screen') {
return buildNavigationStep(properties)
}

if (config.taps && event === '$autocapture') {
return buildTapStep(properties)
}

if (config.lifecycle && LIFECYCLE_EVENTS.has(event)) {
return { type: EXCEPTION_STEP_TYPES.LIFECYCLE, message: event }
}

return undefined
}

function buildNavigationStep(properties: unknown): AutomaticExceptionStep | undefined {
const screenName = readStringProperty(properties, '$screen_name')
if (!screenName) {
return undefined
}

return { type: EXCEPTION_STEP_TYPES.NAVIGATION, message: `Screen: ${screenName}` }
}

/**
* Only a touch leaves a tap step. The label comes from the innermost autocaptured element, which
* holds either a `ph-label` or a component display name.
*
* The step never carries `$el_text` or touch coordinates. Element text is user-visible copy and can
* hold personal data, and an exception timeline does not need the pixel the user hit.
*/
function buildTapStep(properties: unknown): AutomaticExceptionStep | undefined {
if (readStringProperty(properties, '$event_type') !== TOUCH_EVENT_TYPE) {
return undefined
}

const label = readTapLabel(properties)
return {
type: EXCEPTION_STEP_TYPES.TAP,
message: label ? `Tap: ${label}` : 'Tap',
}
}

function readTapLabel(properties: unknown): string | undefined {
const elements = isObject(properties) ? (properties as Record<string, unknown>).$elements : undefined
if (!isArray(elements) || elements.length === 0) {
return undefined
}

// Elements run innermost first, so the first entry is the element the user actually hit.
return readStringProperty(elements[0], 'tag_name')
}

function readStringProperty(source: unknown, key: string): string | undefined {
if (!isObject(source)) {
return undefined
}

const value = (source as Record<string, unknown>)[key]
if (!isString(value) || value.trim().length === 0) {
return undefined
}

return value
}
60 changes: 59 additions & 1 deletion packages/react-native/src/error-tracking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
} from '@posthog/core'
import { Properties } from '@posthog/types'
import { trackConsole, trackUncaughtExceptions, trackUnhandledRejections } from './utils'
import {
AutomaticExceptionStepsOptions,
ResolvedAutomaticExceptionStepsOptions,
buildAutomaticExceptionStep,
resolveAutomaticExceptionStepsOptions,
} from './automatic-steps'
import { getRemoteConfigBool } from '../utils'
import { OptionalReactNativePlugin } from '../optional/OptionalPlugin'

Expand Down Expand Up @@ -45,6 +51,16 @@ export interface ExceptionStepsOptions {
* @default 32768
*/
maxBytes?: number
/**
* Records steps for app signals the SDK already observes, so an exception carries a timeline even
* where nobody called `addExceptionStep`. Every signal is off by default, because each step adds
* bytes to every captured exception.
*
* Pass `true` to enable every signal, or an object to enable single signals.
*
* @default false
*/
automatic?: boolean | AutomaticExceptionStepsOptions
}

export interface ErrorTrackingOptions {
Expand All @@ -67,6 +83,7 @@ export class ErrorTracking {
private logger: Logger
private options: ResolvedErrorTrackingOptions
private _exceptionStepsConfig: CoreErrorTracking.ResolvedExceptionStepsConfig
private _automaticStepsConfig: ResolvedAutomaticExceptionStepsOptions
private _exceptionStepsBuffer: CoreErrorTracking.ExceptionStepsBuffer
private _nativeForwardingEnabled: boolean = false

Expand All @@ -89,6 +106,7 @@ export class ErrorTracking {
this._exceptionStepsConfig = CoreErrorTracking.resolveExceptionStepsConfig(
exceptionSteps ? { enabled: exceptionSteps.enabled, max_bytes: exceptionSteps.maxBytes } : undefined
)
this._automaticStepsConfig = resolveAutomaticExceptionStepsOptions(exceptionSteps?.automatic)
this._exceptionStepsBuffer = new CoreErrorTracking.ExceptionStepsBuffer(this._exceptionStepsConfig)
this.autocapture(this.options.autocapture)
}
Expand Down Expand Up @@ -135,6 +153,44 @@ export class ErrorTracking {
}
}

/**
* True when the caller enabled at least one automatic signal. The capture path checks this first,
* so an app that never enabled the feature pays one boolean read per event.
*/
get automaticExceptionStepsEnabled(): boolean {
const config = this._automaticStepsConfig
return config.navigation || config.taps || config.lifecycle
}

/**
* Records the automatic step that an enqueued event maps to, and ignores every event that maps to
* no step. The SDK sets `$type` here, so the timeline can tell an automatic step from a manual one.
*
* This runs on the capture path. It never throws, and it never captures an event of its own.
*/
onEnqueuedEvent(event: unknown, properties: unknown): void {
if (!this._exceptionStepsConfig.enabled || !this.automaticExceptionStepsEnabled) {
return
}

try {
const step = buildAutomaticExceptionStep(this._automaticStepsConfig, event, properties)
if (!step) {
return
}

const stepProperties = { [CoreErrorTracking.EXCEPTION_STEP_INTERNAL_FIELDS.TYPE]: step.type }
this._exceptionStepsBuffer.add({
Comment thread
veria-ai[bot] marked this conversation as resolved.
...stepProperties,
[CoreErrorTracking.EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]: step.message,
[CoreErrorTracking.EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]: new Date().toISOString(),
})
this.forwardExceptionStepToNative(step.message, stepProperties)
} catch (error) {
this.logger.error('Failed to add automatic exception step. Ignoring breadcrumb.', error)
}
}

/**
* Native error tracking initializes asynchronously, so steps recorded before then are buffered
* only in JS. The host calls this once native is ready to enable forwarding and replay the buffer,
Expand Down Expand Up @@ -194,7 +250,9 @@ export class ErrorTracking {
}

/**
* Clears the buffer. Called on SDK close, not on capture or identity changes.
* Clears the buffer. The host calls this on SDK close only. A capture keeps the buffer, so every
* exception in one app session carries the same steps, and `reset()` keeps it too, because steps
* scope to the app session rather than to the user session.
*/
clearExceptionSteps(): void {
this._exceptionStepsBuffer.clear()
Expand Down
Loading
Loading