@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -29
- package/dist/index.mjs +3 -1
- package/dist/logs/logs-utils.d.ts +26 -0
- package/dist/logs/logs-utils.d.ts.map +1 -0
- package/dist/logs/logs-utils.js +181 -0
- package/dist/logs/logs-utils.mjs +132 -0
- package/dist/tracing-headers.d.ts +23 -0
- package/dist/tracing-headers.d.ts.map +1 -0
- package/dist/tracing-headers.js +85 -0
- package/dist/tracing-headers.mjs +51 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +9 -0
- package/src/logs/logs-utils.spec.ts +264 -0
- package/src/logs/logs-utils.ts +198 -0
- package/src/tracing-headers.ts +105 -0
- package/src/types.ts +13 -0
|
@@ -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 {
|