@posthog/core 1.39.6 → 1.40.0

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,414 @@
1
+ import type {
2
+ CaptureMetricOptions,
3
+ MetricAttributes,
4
+ MetricSample,
5
+ MetricType,
6
+ OtlpHistogramDataPoint,
7
+ OtlpMetric,
8
+ OtlpMetricsPayload,
9
+ OtlpNumberDataPoint,
10
+ } from '@posthog/types'
11
+ import type { Logger } from '../types'
12
+ import { isArray, safeSetTimeout } from '../utils'
13
+ import { toOtlpKeyValueList } from '../logs/logs-utils'
14
+ import {
15
+ DEFAULT_HISTOGRAM_BOUNDS,
16
+ bucketIndexFor,
17
+ buildMetricsResourceAttributes,
18
+ buildOtlpMetricsPayload,
19
+ msToUnixNano,
20
+ seriesKey,
21
+ } from './metrics-utils'
22
+ import type { MetricsHost, ResolvedPostHogMetricsConfig } from './types'
23
+
24
+ const OTLP_TEMPORALITY_DELTA = 1
25
+
26
+ interface HistogramState {
27
+ count: number
28
+ sum: number
29
+ min: number
30
+ max: number
31
+ bucketCounts: number[]
32
+ }
33
+
34
+ /**
35
+ * One aggregated series within the current flush window. Exactly one of
36
+ * `total` (count), `last` (gauge), or `hist` is populated, matching `type`.
37
+ */
38
+ interface SeriesState {
39
+ name: string
40
+ type: MetricType
41
+ unit?: string
42
+ attributes?: MetricAttributes
43
+ windowStartMs: number
44
+ total?: number
45
+ last?: number
46
+ hist?: HistogramState
47
+ }
48
+
49
+ /**
50
+ * Statsd-style pre-aggregating metrics client.
51
+ *
52
+ * Samples are folded into per-series aggregates in memory (counts sum,
53
+ * gauges keep the last value, histograms accumulate buckets) and flushed as
54
+ * one OTLP data point per series per window — a burst of 10k `count()` calls
55
+ * costs one data point on the wire. Sums and histograms use delta
56
+ * temporality, so each data point stands alone and client restarts need no
57
+ * cross-window state.
58
+ *
59
+ * Deliberately unlike logs, no per-user context (distinct ID, session ID) is
60
+ * attached: every attribute value creates a new series, and per-user series
61
+ * are the canonical metrics-cardinality explosion.
62
+ */
63
+ export class PostHogMetrics {
64
+ private _series = new Map<string, SeriesState>()
65
+ private _flushTimer?: ReturnType<typeof safeSetTimeout>
66
+ // Serializes flushes — a manual flush() during an in-flight timer flush
67
+ // queues behind it instead of racing it for the same window.
68
+ private _flushPromise: Promise<void> | null = null
69
+ // One cardinality warning per window, however many series get dropped.
70
+ private _seriesCapWarned = false
71
+ // Type seen per metric name this window; reusing a name with a different
72
+ // type gets a one-time dev hint. The read path queries by name, so mixing
73
+ // types under one name produces charts that blend both series.
74
+ private _typeByName = new Map<string, MetricType>()
75
+ private _typeCollisionWarned = new Set<string>()
76
+
77
+ constructor(
78
+ private readonly _instance: MetricsHost,
79
+ private readonly _config: ResolvedPostHogMetricsConfig,
80
+ private readonly _logger: Logger
81
+ ) {}
82
+
83
+ count(name: string, value: number = 1, options?: CaptureMetricOptions): void {
84
+ this._capture({ name, type: 'count', value, unit: options?.unit, attributes: options?.attributes })
85
+ }
86
+
87
+ gauge(name: string, value: number, options?: CaptureMetricOptions): void {
88
+ this._capture({ name, type: 'gauge', value, unit: options?.unit, attributes: options?.attributes })
89
+ }
90
+
91
+ histogram(name: string, value: number, options?: CaptureMetricOptions): void {
92
+ this._capture({ name, type: 'histogram', value, unit: options?.unit, attributes: options?.attributes })
93
+ }
94
+
95
+ /** Sends everything aggregated so far without waiting for the flush interval. */
96
+ flush(): Promise<void> {
97
+ const prev = this._flushPromise
98
+ const run = async (): Promise<void> => {
99
+ if (prev) {
100
+ await prev.catch(() => {})
101
+ }
102
+ await this._doFlush()
103
+ }
104
+ const p = run().finally(() => {
105
+ if (this._flushPromise === p) {
106
+ this._flushPromise = null
107
+ }
108
+ })
109
+ this._flushPromise = p
110
+ return p
111
+ }
112
+
113
+ /**
114
+ * Synchronously snapshots the current window into an OTLP payload and
115
+ * resets it, bypassing the flush serializer entirely — for unload-time
116
+ * drains where the host must hand the payload to a synchronous transport
117
+ * (sendBeacon) in the same tick. Returns `null` when there is nothing to
118
+ * send. The caller owns delivery; there is no retry for a drained window.
119
+ */
120
+ drainWindow(): OtlpMetricsPayload | null {
121
+ if (this._series.size === 0) {
122
+ return null
123
+ }
124
+ const window = this._series
125
+ this._series = new Map()
126
+ this._seriesCapWarned = false
127
+ this._typeByName = new Map()
128
+ this._typeCollisionWarned = new Set()
129
+ return this._buildPayload(window)
130
+ }
131
+
132
+ /** Clears the flush timer and drops the current window. */
133
+ reset(): void {
134
+ this._clearFlushTimer()
135
+ this._series = new Map()
136
+ this._flushPromise = null
137
+ this._seriesCapWarned = false
138
+ this._typeByName = new Map()
139
+ this._typeCollisionWarned = new Set()
140
+ }
141
+
142
+ private _capture(sample: MetricSample): void {
143
+ if (this._instance.isDisabled || this._instance.optedOut) {
144
+ return
145
+ }
146
+
147
+ const filtered = this._runBeforeSend(sample)
148
+ if (filtered === null) {
149
+ return
150
+ }
151
+
152
+ if (!filtered.name || typeof filtered.name !== 'string') {
153
+ this._logger.warn('Dropping metric with empty name')
154
+ return
155
+ }
156
+ if (typeof filtered.value !== 'number' || !Number.isFinite(filtered.value)) {
157
+ this._logger.warn(`Dropping metric '${filtered.name}': value must be a finite number`)
158
+ return
159
+ }
160
+ // Checked after beforeSend so a hook can't turn a count negative either.
161
+ if (filtered.type === 'count' && filtered.value < 0) {
162
+ this._logger.warn(`Dropping count '${filtered.name}': counters are monotonic, value must be >= 0`)
163
+ return
164
+ }
165
+
166
+ const seenType = this._typeByName.get(filtered.name)
167
+ if (seenType === undefined) {
168
+ this._typeByName.set(filtered.name, filtered.type)
169
+ } else if (seenType !== filtered.type && !this._typeCollisionWarned.has(filtered.name)) {
170
+ this._typeCollisionWarned.add(filtered.name)
171
+ this._logger.warn(
172
+ `Metric name '${filtered.name}' is already used as a ${seenType}; ` +
173
+ `recording it as a ${filtered.type} too will blend both series in charts. Use a distinct name.`
174
+ )
175
+ }
176
+
177
+ const key = seriesKey(filtered.type, filtered.name, filtered.unit, filtered.attributes)
178
+ let state = this._series.get(key)
179
+ if (!state) {
180
+ if (this._series.size >= this._config.maxSeriesPerFlush) {
181
+ if (!this._seriesCapWarned) {
182
+ this._seriesCapWarned = true
183
+ this._logger.warn(
184
+ `Metric series cap reached (${this._config.maxSeriesPerFlush} per flush window); ` +
185
+ `dropping new series until the next flush. Reduce attribute cardinality.`
186
+ )
187
+ }
188
+ return
189
+ }
190
+ state = {
191
+ name: filtered.name,
192
+ type: filtered.type,
193
+ unit: filtered.unit,
194
+ // Snapshot: the key was computed from these values, so a caller
195
+ // mutating the object after capture must not change the stored series.
196
+ attributes: filtered.attributes ? { ...filtered.attributes } : undefined,
197
+ windowStartMs: Date.now(),
198
+ }
199
+ this._series.set(key, state)
200
+ }
201
+
202
+ this._fold(state, filtered.value)
203
+ this._armFlushTimer()
204
+ }
205
+
206
+ private _fold(state: SeriesState, value: number): void {
207
+ switch (state.type) {
208
+ case 'count':
209
+ state.total = (state.total ?? 0) + value
210
+ break
211
+ case 'gauge':
212
+ state.last = value
213
+ break
214
+ case 'histogram': {
215
+ if (!state.hist) {
216
+ state.hist = {
217
+ count: 0,
218
+ sum: 0,
219
+ min: value,
220
+ max: value,
221
+ bucketCounts: new Array(DEFAULT_HISTOGRAM_BOUNDS.length + 1).fill(0),
222
+ }
223
+ }
224
+ const hist = state.hist
225
+ hist.count += 1
226
+ hist.sum += value
227
+ hist.min = Math.min(hist.min, value)
228
+ hist.max = Math.max(hist.max, value)
229
+ hist.bucketCounts[bucketIndexFor(value, DEFAULT_HISTOGRAM_BOUNDS)] += 1
230
+ break
231
+ }
232
+ }
233
+ }
234
+
235
+ private _runBeforeSend(sample: MetricSample): MetricSample | null {
236
+ const beforeSend = this._config.beforeSend
237
+ if (!beforeSend) {
238
+ return sample
239
+ }
240
+ const fns = isArray(beforeSend) ? beforeSend : [beforeSend]
241
+ let result: MetricSample = sample
242
+ for (const fn of fns) {
243
+ try {
244
+ const next = fn(result)
245
+ if (!next) {
246
+ this._logger.info(`Metric was rejected in beforeSend function`)
247
+ return null
248
+ }
249
+ result = next
250
+ } catch (e) {
251
+ this._logger.error(`Error in beforeSend function for metric:`, e)
252
+ return null
253
+ }
254
+ }
255
+ return result
256
+ }
257
+
258
+ private _armFlushTimer(): void {
259
+ if (this._flushTimer) {
260
+ return
261
+ }
262
+ this._flushTimer = safeSetTimeout(() => {
263
+ this._flushTimer = undefined
264
+ this.flush().catch(() => {})
265
+ }, this._config.flushIntervalMs)
266
+ }
267
+
268
+ private _clearFlushTimer(): void {
269
+ if (this._flushTimer) {
270
+ clearTimeout(this._flushTimer)
271
+ this._flushTimer = undefined
272
+ }
273
+ }
274
+
275
+ private async _doFlush(): Promise<void> {
276
+ if (this._series.size === 0) {
277
+ return
278
+ }
279
+
280
+ // Snapshot and reset the window; samples captured while the send is in
281
+ // flight fold into a fresh window instead of the one being sent.
282
+ const window = this._series
283
+ this._series = new Map()
284
+ this._seriesCapWarned = false
285
+ this._typeByName = new Map()
286
+ this._typeCollisionWarned = new Set()
287
+
288
+ const outcome = await this._instance._sendMetricsBatch(this._buildPayload(window))
289
+ switch (outcome.kind) {
290
+ case 'ok':
291
+ return
292
+ case 'retry-later':
293
+ // Transient failure: merge the unsent window back so the data rides
294
+ // the next flush instead of being lost — and re-arm the timer, since
295
+ // with no new captures nothing else would schedule that flush.
296
+ this._mergeWindowBack(window)
297
+ this._armFlushTimer()
298
+ return
299
+ case 'too-large':
300
+ this._logger.warn('Metrics batch exceeded the server size limit and was dropped')
301
+ return
302
+ case 'fatal':
303
+ this._logger.error('Failed to send metrics batch:', outcome.error)
304
+ return
305
+ }
306
+ }
307
+
308
+ private _buildPayload(window: Map<string, SeriesState>): OtlpMetricsPayload {
309
+ return buildOtlpMetricsPayload(
310
+ this._buildMetrics(window),
311
+ buildMetricsResourceAttributes(this._config, this._instance.getLibraryId(), this._instance.getLibraryVersion()),
312
+ this._instance.getLibraryId(),
313
+ this._instance.getLibraryVersion()
314
+ )
315
+ }
316
+
317
+ /**
318
+ * Groups the window's series into OTLP metric entries — one entry per
319
+ * (type, name, unit), one data point per attribute combination.
320
+ */
321
+ private _buildMetrics(window: Map<string, SeriesState>): OtlpMetric[] {
322
+ const nowNano = msToUnixNano(Date.now())
323
+ const byMetric = new Map<string, OtlpMetric>()
324
+
325
+ for (const state of window.values()) {
326
+ const metricKey = seriesKey(state.type, state.name, state.unit, undefined)
327
+ let metric = byMetric.get(metricKey)
328
+ if (!metric) {
329
+ metric = { name: state.name, ...(state.unit && { unit: state.unit }) }
330
+ if (state.type === 'count') {
331
+ metric.sum = { aggregationTemporality: OTLP_TEMPORALITY_DELTA, isMonotonic: true, dataPoints: [] }
332
+ } else if (state.type === 'gauge') {
333
+ metric.gauge = { dataPoints: [] }
334
+ } else {
335
+ metric.histogram = { aggregationTemporality: OTLP_TEMPORALITY_DELTA, dataPoints: [] }
336
+ }
337
+ byMetric.set(metricKey, metric)
338
+ }
339
+
340
+ const attributes = toOtlpKeyValueList(state.attributes ?? {})
341
+ const startNano = msToUnixNano(state.windowStartMs)
342
+
343
+ if (state.type === 'count') {
344
+ const dp: OtlpNumberDataPoint = {
345
+ attributes,
346
+ startTimeUnixNano: startNano,
347
+ timeUnixNano: nowNano,
348
+ asDouble: state.total ?? 0,
349
+ }
350
+ metric.sum!.dataPoints.push(dp)
351
+ } else if (state.type === 'gauge') {
352
+ const dp: OtlpNumberDataPoint = {
353
+ attributes,
354
+ timeUnixNano: nowNano,
355
+ asDouble: state.last ?? 0,
356
+ }
357
+ metric.gauge!.dataPoints.push(dp)
358
+ } else if (state.hist) {
359
+ const dp: OtlpHistogramDataPoint = {
360
+ attributes,
361
+ startTimeUnixNano: startNano,
362
+ timeUnixNano: nowNano,
363
+ count: state.hist.count,
364
+ sum: state.hist.sum,
365
+ min: state.hist.min,
366
+ max: state.hist.max,
367
+ bucketCounts: state.hist.bucketCounts,
368
+ explicitBounds: DEFAULT_HISTOGRAM_BOUNDS,
369
+ }
370
+ metric.histogram!.dataPoints.push(dp)
371
+ }
372
+ }
373
+
374
+ return Array.from(byMetric.values())
375
+ }
376
+
377
+ /** Folds an unsent window back into the live one after a transient send failure. */
378
+ private _mergeWindowBack(window: Map<string, SeriesState>): void {
379
+ for (const [key, old] of window) {
380
+ const current = this._series.get(key)
381
+ if (!current) {
382
+ this._series.set(key, old)
383
+ continue
384
+ }
385
+ current.windowStartMs = Math.min(current.windowStartMs, old.windowStartMs)
386
+ switch (current.type) {
387
+ case 'count':
388
+ current.total = (current.total ?? 0) + (old.total ?? 0)
389
+ break
390
+ case 'gauge':
391
+ // The live window's value is newer — keep it.
392
+ break
393
+ case 'histogram':
394
+ if (old.hist) {
395
+ if (!current.hist) {
396
+ current.hist = old.hist
397
+ } else {
398
+ current.hist.count += old.hist.count
399
+ current.hist.sum += old.hist.sum
400
+ current.hist.min = Math.min(current.hist.min, old.hist.min)
401
+ current.hist.max = Math.max(current.hist.max, old.hist.max)
402
+ for (let i = 0; i < current.hist.bucketCounts.length; i++) {
403
+ current.hist.bucketCounts[i] += old.hist.bucketCounts[i]
404
+ }
405
+ }
406
+ }
407
+ break
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ export { buildOtlpMetricsPayload, buildMetricsResourceAttributes, DEFAULT_HISTOGRAM_BOUNDS } from './metrics-utils'
414
+ export type { MetricsHost, PostHogMetricsConfig, ResolvedPostHogMetricsConfig, SendMetricsBatchOutcome } from './types'
@@ -0,0 +1,100 @@
1
+ import type { MetricAttributeValue, OtlpMetric, OtlpMetricsPayload } from '@posthog/types'
2
+ import { toOtlpKeyValueList } from '../logs/logs-utils'
3
+ import type { ResolvedPostHogMetricsConfig } from './types'
4
+
5
+ /**
6
+ * Default histogram bucket boundaries — the OpenTelemetry SDK defaults.
7
+ * Chosen so the server-side p95/quantile aggregations have usable resolution
8
+ * for the common latency/size ranges without any per-metric configuration.
9
+ */
10
+ export const DEFAULT_HISTOGRAM_BOUNDS = [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000]
11
+
12
+ /**
13
+ * Converts epoch millis to the unix-nanos string OTLP requires (uint64
14
+ * doesn't fit in JS Number, so concatenate instead of multiplying).
15
+ */
16
+ export function msToUnixNano(ms: number): string {
17
+ return String(ms) + '000000'
18
+ }
19
+
20
+ /**
21
+ * Canonical identity of a series within the aggregation window: type, name,
22
+ * unit, and the attribute set with keys sorted so insertion order can't split
23
+ * a series. NUL (`\u0000`) separators can't appear in metric names or JSON output.
24
+ */
25
+ export function seriesKey(
26
+ type: string,
27
+ name: string,
28
+ unit: string | undefined,
29
+ attributes: Record<string, MetricAttributeValue> | undefined
30
+ ): string {
31
+ let attrsKey = ''
32
+ if (attributes) {
33
+ const keys = Object.keys(attributes).sort()
34
+ attrsKey = keys.map((k) => `${JSON.stringify(k)}:${JSON.stringify(attributes[k])}`).join(',')
35
+ }
36
+ return `${type}\u0000${name}\u0000${unit ?? ''}\u0000${attrsKey}`
37
+ }
38
+
39
+ /**
40
+ * Returns the bucket index for a histogram observation: the first boundary
41
+ * the value is `<=`, or the overflow bucket (`bounds.length`) past the last.
42
+ */
43
+ export function bucketIndexFor(value: number, bounds: number[]): number {
44
+ for (let i = 0; i < bounds.length; i++) {
45
+ if (value <= bounds[i]) {
46
+ return i
47
+ }
48
+ }
49
+ return bounds.length
50
+ }
51
+
52
+ /**
53
+ * OTLP resource attributes for every metrics batch. Same layering policy as
54
+ * the logs builder: user `resourceAttributes` spread first, SDK-controlled
55
+ * keys layered on top so a stray user key can't clobber attribution.
56
+ */
57
+ export function buildMetricsResourceAttributes(
58
+ config: ResolvedPostHogMetricsConfig,
59
+ scopeName: string,
60
+ scopeVersion: string
61
+ ): Record<string, MetricAttributeValue> {
62
+ return {
63
+ ...config.resourceAttributes,
64
+ 'service.name': config.serviceName || 'unknown_service',
65
+ ...(config.environment && { 'deployment.environment': config.environment }),
66
+ ...(config.serviceVersion && { 'service.version': config.serviceVersion }),
67
+ 'telemetry.sdk.name': scopeName,
68
+ 'telemetry.sdk.version': scopeVersion,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Wraps aggregated metrics in the OTLP `resourceMetrics` envelope
74
+ * (`ExportMetricsServiceRequest`, JSON encoding).
75
+ *
76
+ * Encoding notes pinned by the ingest's JSON deserializer: nano timestamps
77
+ * are decimal strings, but histogram `count`/`bucketCounts` are plain JSON
78
+ * numbers — string-encoded u64s in those fields have been silently dropped
79
+ * by upstream opentelemetry-proto deserializers (opentelemetry-rust#3328).
80
+ */
81
+ export function buildOtlpMetricsPayload(
82
+ metrics: OtlpMetric[],
83
+ resourceAttributes: Record<string, MetricAttributeValue>,
84
+ scopeName: string,
85
+ scopeVersion: string
86
+ ): OtlpMetricsPayload {
87
+ return {
88
+ resourceMetrics: [
89
+ {
90
+ resource: { attributes: toOtlpKeyValueList(resourceAttributes) },
91
+ scopeMetrics: [
92
+ {
93
+ scope: { name: scopeName, version: scopeVersion },
94
+ metrics,
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ }
100
+ }
@@ -0,0 +1,89 @@
1
+ // Re-export OTLP/metric types from @posthog/types so the rest of the metrics
2
+ // module can pull everything from one place.
3
+ export type {
4
+ MetricAttributeValue,
5
+ MetricAttributes,
6
+ MetricType,
7
+ CaptureMetricOptions,
8
+ MetricSample,
9
+ BeforeSendMetricFn,
10
+ Metrics,
11
+ OtlpNumberDataPoint,
12
+ OtlpHistogramDataPoint,
13
+ OtlpMetric,
14
+ OtlpMetricsPayload,
15
+ } from '@posthog/types'
16
+
17
+ import type { BeforeSendMetricFn, MetricAttributeValue, OtlpMetricsPayload } from '@posthog/types'
18
+
19
+ /** Same tagged outcome shape as `SendLogsBatchOutcome` — one policy for both signals. */
20
+ export type SendMetricsBatchOutcome =
21
+ | { kind: 'ok' }
22
+ | { kind: 'retry-later'; error: unknown }
23
+ | { kind: 'too-large' }
24
+ | { kind: 'fatal'; error: unknown }
25
+
26
+ /**
27
+ * The minimal host surface `PostHogMetrics` depends on. `PostHogCoreStateless`
28
+ * satisfies it structurally; the browser supplies an adapter backed by its
29
+ * own request layer.
30
+ */
31
+ export interface MetricsHost {
32
+ readonly isDisabled: boolean
33
+ readonly optedOut: boolean
34
+ _sendMetricsBatch(payload: OtlpMetricsPayload): Promise<SendMetricsBatchOutcome>
35
+ getLibraryId(): string
36
+ getLibraryVersion(): string
37
+ }
38
+
39
+ /**
40
+ * Configuration for the metrics feature on `new PostHog(key, { metrics: ... })`.
41
+ * All fields are optional; per-SDK defaults apply.
42
+ */
43
+ export interface PostHogMetricsConfig {
44
+ /**
45
+ * Service name attached as the OTLP `service.name` resource attribute.
46
+ * Part of every series' identity; used by the Metrics UI for filtering.
47
+ * Defaults to `'unknown_service'` when unset.
48
+ */
49
+ serviceName?: string
50
+
51
+ /** Service version attached as OTLP `service.version`. */
52
+ serviceVersion?: string
53
+
54
+ /** Deployment environment attached as OTLP `deployment.environment`. */
55
+ environment?: string
56
+
57
+ /**
58
+ * Extra OTLP resource attributes attached to every batch. Spread first;
59
+ * SDK-controlled keys are layered on top so users can't clobber them.
60
+ */
61
+ resourceAttributes?: Record<string, MetricAttributeValue>
62
+
63
+ /**
64
+ * How often the aggregated window is flushed (ms). Samples are aggregated
65
+ * in memory between flushes — one data point per series per window, no
66
+ * matter how many calls. Default: 10000.
67
+ */
68
+ flushIntervalMs?: number
69
+
70
+ /**
71
+ * Cardinality guardrail: max distinct series (name + type + unit +
72
+ * attribute combination) held per flush window. Samples for series beyond
73
+ * the cap are dropped with one warning per window. Default: 1000.
74
+ */
75
+ maxSeriesPerFlush?: number
76
+
77
+ /**
78
+ * Pre-aggregation filter. See {@link BeforeSendMetricFn}. Configure as a
79
+ * single function or a chain.
80
+ */
81
+ beforeSend?: BeforeSendMetricFn | BeforeSendMetricFn[]
82
+ }
83
+
84
+ // Fields PostHogMetrics needs resolved at runtime. The host SDK fills in its
85
+ // defaults and hands the resolved config to the PostHogMetrics constructor.
86
+ export interface ResolvedPostHogMetricsConfig extends PostHogMetricsConfig {
87
+ flushIntervalMs: number
88
+ maxSeriesPerFlush: number
89
+ }