Files
Richard SolomouandGitHub 0c3380b2b0 feat: support addTracingHeaders in react-native sdk (#3432)
* feat(react-native): support __add_tracing_headers for linking requests to sessions

The browser SDK already supports injecting X-POSTHOG-DISTINCT-ID and
X-POSTHOG-SESSION-ID headers on outgoing requests (used, e.g., for linking
LLM traces to session replays). The React Native SDK silently ignored
__add_tracing_headers because no equivalent patching existed.

Patch the global fetch on init when the option is set, and add the tracing
headers to requests whose hostname matches the configured list.

Fixes #3196

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* refactor(react-native): make tracing-headers patch idempotent

Detect an existing PostHog-installed fetch patch via a non-enumerable
marker and unwrap it before re-wrapping, so re-initialisation (HMR, tests,
or creating a second PostHog instance) replaces the previous patch instead
of stacking on top. The latest instance's hostname list and distinct/session
ids take effect.

Also fold the symmetric matching/non-matching hostname tests into a single
`it.each` and add a regression test for the idempotency fix.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* refactor: rename __add_tracing_headers to addTracingHeaders

The `__` prefix signalled an internal/experimental option, but the config
is now a public API surface (documented for linking LLM traces to session
replays). Per review consensus, drop the prefix on both browser and RN
SDKs. `__add_tracing_headers` is kept as a deprecated alias on both so
existing users keep working.

Also address review feedback on the RN tracing-headers module:

- drop the dead `isArray` guard (type is already `string[] | undefined`)
- narrow `input` with `instanceof Request` instead of a cast
- bind a new `initWithHeaders` rather than reassigning the `init` param
- document the unwrap-chain limitation on the PATCH_MARKER
- replace `globalThis as any` with a typed narrow in tests
- cover more branches: URL input, Request input, Headers-instance
  init.headers, and fetch rejection propagation

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* refactor: move tracing-headers to @posthog/core and drop RN deprecated alias

- Move tracing-headers.ts (and its tests) from packages/react-native to
  packages/core so other SDKs (posthog-web, future clients) can reuse the
  implementation. Decouple from the RN PostHog type via a minimal
  TracingHeadersClient interface ({ getDistinctId, getSessionId }).
- Drop the `__add_tracing_headers` deprecated alias from the RN
  PostHogOptions. RN never shipped this option, so there's no user to be
  backwards-compatible with — keeping the alias would be dead code on day one.
- Split the changeset into two: one for the cross-package rename
  (posthog-js + types + core), one for the new RN feature.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* refactor: move addTracingHeaders option to PostHogCoreOptions

Any SDK extending PostHogCoreOptions (posthog-react-native today,
posthog-web in the future) automatically picks up the config field,
matching the patch function now living in @posthog/core.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* chore(examples): add tracing-headers screen to example-expo-53

Lets anyone running the example visually confirm that the `addTracingHeaders`
fetch patch actually injects `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID`
on-device. The screen fires two requests — one to a listed hostname
(httpbin.org, which echoes headers back) and one to an unlisted host — and
renders the echoed headers, highlighting PostHog ones.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* chore(examples): use postman-echo for unlisted hostname case

httpbin.org.com wasn't a real domain and failed DNS on-device, which
looked like a bug in the example. postman-echo.com is a real working
header-echo service, so the unlisted case now exercises an actual
network round-trip and the demo is just about the header filter
rather than a DNS failure.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6

* chore(browser): add _getConfiguredHostnames to terser mangled names

New private method added when renaming __add_tracing_headers to
addTracingHeaders in the TracingHeaders extension.

Generated-By: PostHog Code
Task-Id: 50cf1519-462f-4dc4-a8af-6d9ac94466e6
2026-04-23 09:31:29 +03:00

149 lines
5.4 KiB
TypeScript

import { useState } from 'react'
import { Button, StyleSheet, Text, View } from 'react-native'
import ParallaxScrollView from '@/components/ParallaxScrollView'
import { ThemedText } from '@/components/ThemedText'
import { ThemedView } from '@/components/ThemedView'
import { IconSymbol } from '@/components/ui/IconSymbol'
// httpbin.org/headers echoes the request headers back in the JSON response — perfect
// for eyeballing that `addTracingHeaders` actually reaches the network layer on device.
const LISTED_URL = 'https://httpbin.org/headers'
// postman-echo.com/headers also echoes headers but is NOT in the `addTracingHeaders`
// list in app/posthog.tsx, so the PostHog headers should NOT appear on this response.
const UNLISTED_URL = 'https://postman-echo.com/headers'
type Result = {
url: string
status?: number
echoedHeaders?: Record<string, string>
error?: string
}
export default function TracingHeadersScreen() {
const [result, setResult] = useState<Result | null>(null)
const [loading, setLoading] = useState(false)
const runRequest = async (url: string): Promise<void> => {
setLoading(true)
setResult(null)
try {
const response = await fetch(url)
const body = (await response.json()) as { headers?: Record<string, string> }
setResult({ url, status: response.status, echoedHeaders: body.headers ?? {} })
} catch (error) {
setResult({ url, error: error instanceof Error ? error.message : String(error) })
} finally {
setLoading(false)
}
}
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={<IconSymbol size={310} color="#808080" name="link" style={styles.headerImage} />}
>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Tracing Headers</ThemedText>
</ThemedView>
<ThemedText>
When `addTracingHeaders` is set on PostHog init, the SDK patches the global `fetch` to inject
`X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` on requests to the configured hostnames. This is
used to link LLM traces and backend spans to the current PostHog session replay.
</ThemedText>
<ThemedView style={styles.sectionContainer}>
<ThemedText type="subtitle">Listed hostname (httpbin.org)</ThemedText>
<ThemedText>
Echoes the request headers back. You should see `X-Posthog-Distinct-Id` and
`X-Posthog-Session-Id` in the response.
</ThemedText>
<Button onPress={() => void runRequest(LISTED_URL)} title="Fetch listed hostname" />
</ThemedView>
<ThemedView style={styles.sectionContainer}>
<ThemedText type="subtitle">Unlisted hostname</ThemedText>
<ThemedText>Same endpoint on a different host. The PostHog headers should NOT be added.</ThemedText>
<Button onPress={() => void runRequest(UNLISTED_URL)} title="Fetch unlisted hostname" />
</ThemedView>
{loading && (
<View style={styles.resultBox}>
<Text>Loading…</Text>
</View>
)}
{result && !loading && (
<View style={styles.resultBox}>
<Text style={styles.resultTitle}>Request: {result.url}</Text>
{result.error ? (
<Text style={styles.resultError}>Error: {result.error}</Text>
) : (
<>
<Text style={styles.resultStatus}>Status: {result.status}</Text>
<Text style={styles.resultSubtitle}>Echoed headers:</Text>
{Object.entries(result.echoedHeaders ?? {}).map(([key, value]) => {
const isTracing = key.toLowerCase().startsWith('x-posthog-')
return (
<Text
key={key}
style={[styles.resultHeader, isTracing && styles.resultHeaderTracing]}
>
{key}: {value}
</Text>
)
})}
</>
)}
</View>
)}
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
sectionContainer: {
gap: 8,
marginTop: 16,
},
resultBox: {
marginTop: 16,
padding: 12,
backgroundColor: '#f5f5f5',
borderRadius: 8,
gap: 4,
},
resultTitle: {
fontWeight: '700',
},
resultStatus: {
color: '#2e7d32',
fontWeight: '600',
},
resultSubtitle: {
marginTop: 6,
fontWeight: '600',
},
resultHeader: {
fontSize: 12,
fontFamily: 'Courier',
},
resultHeaderTracing: {
color: '#1565c0',
fontWeight: '700',
},
resultError: {
color: '#c62828',
},
})