@posthog/core 1.26.0 → 1.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import type {
2
+ CaptureLogOptions,
3
+ LogAttributeValue,
4
+ LogSdkContext,
5
+ LogSeverityLevel,
6
+ OtlpAnyValue,
7
+ OtlpKeyValue,
8
+ OtlpLogRecord,
9
+ OtlpLogsPayload,
10
+ OtlpSeverityEntry,
11
+ OtlpSeverityText,
12
+ } from '@posthog/types'
13
+ import { isArray, isBoolean, isNull, isUndefined } from '../utils'
14
+
15
+ // ============================================================================
16
+ // Severity mapping
17
+ // ============================================================================
18
+
19
+ const OTLP_SEVERITY_MAP: Record<LogSeverityLevel, OtlpSeverityEntry> = {
20
+ trace: { text: 'TRACE', number: 1 },
21
+ debug: { text: 'DEBUG', number: 5 },
22
+ info: { text: 'INFO', number: 9 },
23
+ warn: { text: 'WARN', number: 13 },
24
+ error: { text: 'ERROR', number: 17 },
25
+ fatal: { text: 'FATAL', number: 21 },
26
+ }
27
+
28
+ const DEFAULT_OTLP_SEVERITY = OTLP_SEVERITY_MAP.info
29
+
30
+ export function getOtlpSeverityText(level: LogSeverityLevel): OtlpSeverityText {
31
+ return (OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY).text
32
+ }
33
+
34
+ export function getOtlpSeverityNumber(level: LogSeverityLevel): number {
35
+ return (OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY).number
36
+ }
37
+
38
+ // ============================================================================
39
+ // OTLP AnyValue conversion
40
+ // ============================================================================
41
+
42
+ export function toOtlpAnyValue(value: LogAttributeValue): OtlpAnyValue {
43
+ if (isBoolean(value)) {
44
+ return { boolValue: value }
45
+ }
46
+ // NOTE: typeof check (not core's isNumber) so NaN is included. core's
47
+ // isNumber explicitly excludes NaN via the `x === x` guard, which would
48
+ // route NaN through the JSON.stringify branch below — JSON has no
49
+ // representation for non-finite floats and JSON.stringify turns them into
50
+ // `null`, losing the value server-side. proto3 JSON mapping (which OTLP/HTTP
51
+ // rides) requires the literal strings; we encode them as stringValue to keep
52
+ // the human-readable signal regardless of which downstream parser sees them.
53
+ if (typeof value === 'number') {
54
+ if (!Number.isFinite(value)) {
55
+ return { stringValue: String(value) }
56
+ }
57
+ if (Number.isInteger(value)) {
58
+ return { intValue: value }
59
+ }
60
+ return { doubleValue: value }
61
+ }
62
+ if (typeof value === 'string') {
63
+ return { stringValue: value }
64
+ }
65
+ if (isArray(value)) {
66
+ return { arrayValue: { values: value.map((v) => toOtlpAnyValue(v as LogAttributeValue)) } }
67
+ }
68
+ // Objects fall back to JSON. OTLP supports kvlistValue but the encoder
69
+ // stays flat for simplicity.
70
+ try {
71
+ return { stringValue: JSON.stringify(value) }
72
+ } catch {
73
+ return { stringValue: String(value) }
74
+ }
75
+ }
76
+
77
+ export function toOtlpKeyValueList(attrs: Record<string, LogAttributeValue>): OtlpKeyValue[] {
78
+ const result: OtlpKeyValue[] = []
79
+ for (const key in attrs) {
80
+ const value = attrs[key]
81
+ if (isNull(value) || isUndefined(value)) {
82
+ continue
83
+ }
84
+ result.push({ key, value: toOtlpAnyValue(value) })
85
+ }
86
+ return result
87
+ }
88
+
89
+ // ============================================================================
90
+ // OTLP LogRecord construction
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Returns the current wall-clock time as a unix-nanos string.
95
+ *
96
+ * OTLP requires nanoseconds as a string (uint64 doesn't fit in JS Number).
97
+ * `Date.now() * 1_000_000` would exceed Number.MAX_SAFE_INTEGER, so we
98
+ * concatenate instead of multiplying.
99
+ */
100
+ function timestampToUnixNano(): string {
101
+ return String(Date.now()) + '000000'
102
+ }
103
+
104
+ /**
105
+ * Builds a single OTLP log record.
106
+ *
107
+ * Auto-attribute population is shape-driven: any field present on `sdkContext`
108
+ * is emitted as the corresponding attribute. Each SDK populates only the
109
+ * fields that apply to it (browser fills `currentUrl`; mobile fills
110
+ * `screenName` / `appState`), so a missing field never adds a stray attribute.
111
+ *
112
+ * User-provided `options.attributes` always wins on conflicts.
113
+ */
114
+ export function buildOtlpLogRecord(options: CaptureLogOptions, sdkContext: LogSdkContext): OtlpLogRecord {
115
+ const level: LogSeverityLevel = options.level || 'info'
116
+ const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY
117
+ const now = timestampToUnixNano()
118
+
119
+ const autoAttributes: Record<string, LogAttributeValue> = {}
120
+
121
+ if (sdkContext.distinctId) {
122
+ autoAttributes.posthogDistinctId = sdkContext.distinctId
123
+ }
124
+ if (sdkContext.sessionId) {
125
+ autoAttributes.sessionId = sdkContext.sessionId
126
+ }
127
+ if (sdkContext.currentUrl) {
128
+ autoAttributes['url.full'] = sdkContext.currentUrl
129
+ }
130
+ if (sdkContext.screenName) {
131
+ autoAttributes['screen.name'] = sdkContext.screenName
132
+ }
133
+ if (sdkContext.appState) {
134
+ autoAttributes['app.state'] = sdkContext.appState
135
+ }
136
+ if (sdkContext.activeFeatureFlags && sdkContext.activeFeatureFlags.length > 0) {
137
+ autoAttributes.feature_flags = sdkContext.activeFeatureFlags
138
+ }
139
+
140
+ const mergedAttributes = {
141
+ ...autoAttributes,
142
+ ...(options.attributes || {}),
143
+ }
144
+
145
+ const record: OtlpLogRecord = {
146
+ timeUnixNano: now,
147
+ observedTimeUnixNano: now,
148
+ severityNumber,
149
+ severityText,
150
+ body: { stringValue: options.body },
151
+ attributes: toOtlpKeyValueList(mergedAttributes),
152
+ }
153
+
154
+ if (options.trace_id) {
155
+ record.traceId = options.trace_id
156
+ }
157
+ if (options.span_id) {
158
+ record.spanId = options.span_id
159
+ }
160
+ if (!isUndefined(options.trace_flags)) {
161
+ record.flags = options.trace_flags
162
+ }
163
+
164
+ return record
165
+ }
166
+
167
+ // ============================================================================
168
+ // OTLP envelope construction
169
+ // ============================================================================
170
+
171
+ /**
172
+ * Wraps a list of records in the OTLP `resourceLogs` envelope.
173
+ *
174
+ * `scopeName` is the SDK package name (`posthog-js`, `posthog-react-native`,
175
+ * etc.). `scopeVersion` is the SDK semver. The server combines them into a
176
+ * single `instrumentation_scope` field (`{name}@{version}`) used for
177
+ * SDK-version-level attribution in queries and dashboards.
178
+ */
179
+ export function buildOtlpLogsPayload(
180
+ logRecords: OtlpLogRecord[],
181
+ resourceAttributes: Record<string, LogAttributeValue>,
182
+ scopeName: string,
183
+ scopeVersion: string
184
+ ): OtlpLogsPayload {
185
+ return {
186
+ resourceLogs: [
187
+ {
188
+ resource: { attributes: toOtlpKeyValueList(resourceAttributes) },
189
+ scopeLogs: [
190
+ {
191
+ scope: { name: scopeName, version: scopeVersion },
192
+ logRecords,
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ }
198
+ }
@@ -0,0 +1,105 @@
1
+ import { isFunction } from './utils/type-utils'
2
+
3
+ const DISTINCT_ID_HEADER = 'X-POSTHOG-DISTINCT-ID'
4
+ const SESSION_ID_HEADER = 'X-POSTHOG-SESSION-ID'
5
+ const PATCH_MARKER = '__posthog_tracing_headers_patched__'
6
+
7
+ const parseHostname = (url: string): string | undefined => {
8
+ try {
9
+ return new URL(url).hostname
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ const shouldAddHeaders = (url: string, hostnames: string[]): boolean => {
16
+ const hostname = parseHostname(url)
17
+ if (!hostname) {
18
+ return false
19
+ }
20
+ return hostnames.includes(hostname)
21
+ }
22
+
23
+ /**
24
+ * Minimal contract the tracing-headers patch needs from a PostHog client:
25
+ * something that can report the current distinct and session ids.
26
+ */
27
+ export interface TracingHeadersClient {
28
+ getDistinctId(): string
29
+ getSessionId(): string
30
+ }
31
+
32
+ type FetchFn = typeof fetch
33
+ type PatchedFetch = FetchFn & { [PATCH_MARKER]?: { original: FetchFn } }
34
+
35
+ /**
36
+ * Patches `globalThis.fetch` to inject `X-POSTHOG-DISTINCT-ID` and
37
+ * `X-POSTHOG-SESSION-ID` headers on requests whose hostname matches `hostnames`.
38
+ *
39
+ * Used by SDKs that run in environments with a WHATWG `fetch` (posthog-react-native,
40
+ * posthog-web) to link outgoing requests to the PostHog session — e.g. to link LLM
41
+ * traces captured by a backend to a frontend session replay.
42
+ *
43
+ * The wrapped fetch is tagged with a non-enumerable marker so that calling this
44
+ * again (on HMR, tests, or a second PostHog instance) unwraps the previous patch
45
+ * before rewrapping — preventing patches from stacking. Returns a function that
46
+ * restores the original fetch when called.
47
+ */
48
+ export const patchFetchForTracingHeaders = (client: TracingHeadersClient, hostnames: string[]): (() => void) => {
49
+ const globalAny = globalThis as unknown as { fetch?: PatchedFetch }
50
+ const currentFetch = globalAny.fetch
51
+ if (!isFunction(currentFetch)) {
52
+ return () => {}
53
+ }
54
+
55
+ // If we already patched fetch ourselves, unwrap so the latest client's hostname list
56
+ // and session/distinct ids take effect without stacking patches.
57
+ //
58
+ // Limitation: we only unwrap our own immediate predecessor. If another library wraps fetch
59
+ // between two patch calls, their wrapper has no PATCH_MARKER, so we treat it as the original —
60
+ // meaning the earlier patch stays live underneath and headers could be written twice.
61
+ // This is considered out of scope; we rely on PostHog being initialised once per app.
62
+ const originalFetch: FetchFn = currentFetch[PATCH_MARKER]?.original ?? currentFetch
63
+
64
+ const wrappedFetch: PatchedFetch = async function (input, init) {
65
+ try {
66
+ const urlString =
67
+ typeof input === 'string'
68
+ ? input
69
+ : input instanceof URL
70
+ ? input.toString()
71
+ : input instanceof Request
72
+ ? input.url
73
+ : undefined
74
+ if (urlString && shouldAddHeaders(urlString, hostnames)) {
75
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
76
+ const distinctId = client.getDistinctId()
77
+ const sessionId = client.getSessionId()
78
+ if (distinctId) {
79
+ headers.set(DISTINCT_ID_HEADER, distinctId)
80
+ }
81
+ if (sessionId) {
82
+ headers.set(SESSION_ID_HEADER, sessionId)
83
+ }
84
+ const initWithHeaders = { ...(init ?? {}), headers }
85
+ return originalFetch.call(globalAny, input, initWithHeaders)
86
+ }
87
+ } catch {
88
+ // If anything goes wrong, fall through to the original fetch without tracing headers.
89
+ }
90
+ return originalFetch.call(globalAny, input, init)
91
+ }
92
+
93
+ Object.defineProperty(wrappedFetch, PATCH_MARKER, {
94
+ value: { original: originalFetch },
95
+ enumerable: false,
96
+ })
97
+
98
+ globalAny.fetch = wrappedFetch
99
+
100
+ return () => {
101
+ if (globalAny.fetch === wrappedFetch) {
102
+ globalAny.fetch = originalFetch
103
+ }
104
+ }
105
+ }
package/src/types.ts CHANGED
@@ -191,6 +191,19 @@ export type PostHogCoreOptions = {
191
191
  * If a function returns null, the event will be dropped.
192
192
  */
193
193
  before_send?: BeforeSendFn | BeforeSendFn[]
194
+
195
+ /**
196
+ * A list of hostnames for which to inject PostHog tracing headers
197
+ * (X-POSTHOG-DISTINCT-ID, X-POSTHOG-SESSION-ID) on outgoing `fetch` requests.
198
+ *
199
+ * Use this to link requests made from your app to session replays and LLM traces
200
+ * in PostHog. When set, the global `fetch` is patched on initialization and the
201
+ * headers are added to requests whose hostname matches one of the entries.
202
+ *
203
+ * Requires the SDK to wire up `patchFetchForTracingHeaders` against this option
204
+ * (currently supported in posthog-react-native).
205
+ */
206
+ addTracingHeaders?: string[]
194
207
  }
195
208
 
196
209
  export enum PostHogPersistedProperty {