@posthog/core 1.39.5 → 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.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +133 -93
- package/dist/index.mjs +2 -1
- package/dist/metrics/index.d.ts +61 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +326 -0
- package/dist/metrics/index.mjs +283 -0
- package/dist/metrics/metrics-utils.d.ts +41 -0
- package/dist/metrics/metrics-utils.d.ts.map +1 -0
- package/dist/metrics/metrics-utils.js +117 -0
- package/dist/metrics/metrics-utils.mjs +68 -0
- package/dist/metrics/types.d.ts +69 -0
- package/dist/metrics/types.d.ts.map +1 -0
- package/dist/metrics/types.js +18 -0
- package/dist/metrics/types.mjs +0 -0
- package/dist/posthog-core.d.ts +2 -2
- package/dist/posthog-core.d.ts.map +1 -1
- package/dist/posthog-core.js +5 -4
- package/dist/posthog-core.mjs +5 -4
- package/package.json +2 -2
- package/src/index.ts +20 -0
- package/src/metrics/index.spec.ts +459 -0
- package/src/metrics/index.ts +414 -0
- package/src/metrics/metrics-utils.ts +100 -0
- package/src/metrics/types.ts +89 -0
- package/src/posthog-core.ts +7 -4
|
@@ -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
|
+
}
|
package/src/posthog-core.ts
CHANGED
|
@@ -1068,8 +1068,11 @@ export abstract class PostHogCore extends PostHogCoreStateless {
|
|
|
1068
1068
|
}
|
|
1069
1069
|
}
|
|
1070
1070
|
|
|
1071
|
-
getFeatureFlag(key: string): FeatureFlagValue | undefined {
|
|
1072
|
-
const result = this._getFeatureFlagResult(key, {
|
|
1071
|
+
getFeatureFlag(key: string, options?: FeatureFlagResultOptions): FeatureFlagValue | undefined {
|
|
1072
|
+
const result = this._getFeatureFlagResult(key, {
|
|
1073
|
+
missingFlagBehavior: 'getFeatureFlag',
|
|
1074
|
+
sendEvent: options?.sendEvent,
|
|
1075
|
+
})
|
|
1073
1076
|
return result?.variant ?? result?.enabled
|
|
1074
1077
|
}
|
|
1075
1078
|
|
|
@@ -1137,8 +1140,8 @@ export abstract class PostHogCore extends PostHogCoreStateless {
|
|
|
1137
1140
|
}
|
|
1138
1141
|
}
|
|
1139
1142
|
|
|
1140
|
-
isFeatureEnabled(key: string): boolean | undefined {
|
|
1141
|
-
const response = this.getFeatureFlag(key)
|
|
1143
|
+
isFeatureEnabled(key: string, options?: FeatureFlagResultOptions): boolean | undefined {
|
|
1144
|
+
const response = this.getFeatureFlag(key, options)
|
|
1142
1145
|
if (response === undefined) {
|
|
1143
1146
|
return undefined
|
|
1144
1147
|
}
|