@zuplo/cli 6.72.0 → 6.72.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.
Files changed (99) hide show
  1. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/.github/dependabot.yml +42 -2
  2. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/README.md +5 -5
  3. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/index.js +4 -7
  4. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/package.json +3 -3
  5. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/additionalProperties.test.js +47 -0
  6. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/const.test.js +20 -0
  7. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/patternProperties.test.js +24 -0
  8. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.gitattributes +2 -0
  9. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/dependabot.yml +53 -0
  10. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/workflows/ci.yml +154 -0
  11. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/workflows/lock-threads.yml +19 -0
  12. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/LICENSE +30 -0
  13. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/README.md +152 -0
  14. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/benchmark.mjs +159 -0
  15. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/encoder.mjs +54 -0
  16. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/equal.mjs +51 -0
  17. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/non-simple-domain.mjs +22 -0
  18. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/package.json +17 -0
  19. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/string-array-to-hex-stripped.mjs +24 -0
  20. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/ws-is-secure.mjs +65 -0
  21. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/eslint.config.js +6 -0
  22. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/index.js +405 -0
  23. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/lib/schemes.js +267 -0
  24. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/lib/utils.js +585 -0
  25. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/package.json +68 -0
  26. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/ajv.test.js +43 -0
  27. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/equal.test.js +168 -0
  28. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/fixtures/uri-js-parse.json +501 -0
  29. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/fixtures/uri-js-serialize.json +120 -0
  30. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/parse.test.js +325 -0
  31. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
  32. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/resolve.test.js +87 -0
  33. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/rfc-3986.test.js +90 -0
  34. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/scheme-normalization.test.js +16 -0
  35. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security-normalization.test.js +39 -0
  36. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security-utf8-encoding.test.js +0 -0
  37. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security.test.js +161 -0
  38. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/serialize.test.js +151 -0
  39. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/uri-js-compatibility.test.js +33 -0
  40. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/uri-js.test.js +912 -0
  41. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/util.test.js +47 -0
  42. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/tsconfig.json +10 -0
  43. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/types/index.d.ts +51 -0
  44. package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/types/index.tst.ts +15 -0
  45. package/node_modules/@posthog/core/dist/index.d.ts +3 -0
  46. package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
  47. package/node_modules/@posthog/core/dist/index.js +133 -93
  48. package/node_modules/@posthog/core/dist/index.mjs +2 -1
  49. package/node_modules/@posthog/core/dist/metrics/index.d.ts +61 -0
  50. package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -0
  51. package/node_modules/@posthog/core/dist/metrics/index.js +326 -0
  52. package/node_modules/@posthog/core/dist/metrics/index.mjs +283 -0
  53. package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts +41 -0
  54. package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -0
  55. package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +117 -0
  56. package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +68 -0
  57. package/node_modules/@posthog/core/dist/metrics/types.d.ts +69 -0
  58. package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -0
  59. package/node_modules/@posthog/core/dist/metrics/types.js +18 -0
  60. package/node_modules/@posthog/core/dist/metrics/types.mjs +0 -0
  61. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +2 -0
  62. package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
  63. package/node_modules/@posthog/core/dist/posthog-core-stateless.js +16 -1
  64. package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +16 -1
  65. package/node_modules/@posthog/core/package.json +2 -2
  66. package/node_modules/@posthog/core/src/index.ts +20 -0
  67. package/node_modules/@posthog/core/src/metrics/index.spec.ts +459 -0
  68. package/node_modules/@posthog/core/src/metrics/index.ts +414 -0
  69. package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +100 -0
  70. package/node_modules/@posthog/core/src/metrics/types.ts +89 -0
  71. package/node_modules/@posthog/core/src/posthog-core-stateless.ts +26 -0
  72. package/node_modules/@posthog/types/dist/capture-metric.d.ts +122 -0
  73. package/node_modules/@posthog/types/dist/capture-metric.d.ts.map +1 -0
  74. package/node_modules/@posthog/types/dist/capture-metric.js +36 -0
  75. package/node_modules/@posthog/types/dist/capture-metric.mjs +2 -0
  76. package/node_modules/@posthog/types/dist/index.d.ts +3 -1
  77. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  78. package/node_modules/@posthog/types/dist/index.js +19 -1
  79. package/node_modules/@posthog/types/dist/index.mjs +2 -0
  80. package/node_modules/@posthog/types/dist/posthog-config.d.ts +60 -0
  81. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  82. package/node_modules/@posthog/types/package.json +1 -1
  83. package/node_modules/@posthog/types/src/capture-metric.ts +133 -0
  84. package/node_modules/@posthog/types/src/index.ts +17 -0
  85. package/node_modules/@posthog/types/src/posthog-config.ts +62 -0
  86. package/node_modules/@zuplo/core/package.json +1 -1
  87. package/node_modules/@zuplo/graphql/package.json +1 -1
  88. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  89. package/node_modules/@zuplo/otel/package.json +1 -1
  90. package/node_modules/@zuplo/runtime/out/esm/{chunk-FAOWNDMK.js → chunk-IC7XGD5C.js} +93 -93
  91. package/node_modules/@zuplo/runtime/out/esm/chunk-IC7XGD5C.js.map +1 -0
  92. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  93. package/node_modules/@zuplo/runtime/out/esm/index.js.map +1 -1
  94. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
  95. package/node_modules/@zuplo/runtime/out/types/index.d.ts +830 -6
  96. package/node_modules/@zuplo/runtime/package.json +1 -1
  97. package/package.json +6 -6
  98. package/node_modules/@zuplo/runtime/out/esm/chunk-FAOWNDMK.js.map +0 -1
  99. /package/node_modules/@zuplo/runtime/out/esm/{chunk-FAOWNDMK.js.LEGAL.txt → chunk-IC7XGD5C.js.LEGAL.txt} +0 -0
@@ -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
+ }
@@ -189,7 +189,9 @@ export abstract class PostHogCoreStateless {
189
189
  private maxQueueSize: number
190
190
  private flushInterval: number
191
191
  private flushPromise: Promise<any> | null = null
192
+ private pendingFlushPromise: Promise<void> | null = null
192
193
  private flushPromises: Set<Promise<any>> = new Set()
194
+ private _dequeuedMessagesCount: number = 0
193
195
  private shutdownPromise: Promise<void> | null = null
194
196
  private requestTimeout: number
195
197
  private featureFlagsRequestTimeoutMs: number
@@ -1191,6 +1193,9 @@ export abstract class PostHogCoreStateless {
1191
1193
  * Avoids unnecessary promise errors
1192
1194
  */
1193
1195
  private flushBackground(): void {
1196
+ if (this.pendingFlushPromise) {
1197
+ return
1198
+ }
1194
1199
  void this.flush().catch(async (err) => {
1195
1200
  await logFlushError(err)
1196
1201
  })
@@ -1259,6 +1264,10 @@ export abstract class PostHogCoreStateless {
1259
1264
  return Promise.resolve()
1260
1265
  }
1261
1266
 
1267
+ if (!waitForPendingPromises && this.pendingFlushPromise) {
1268
+ return this.pendingFlushPromise
1269
+ }
1270
+
1262
1271
  const previousFlushPromise = this.flushPromise
1263
1272
  const maxPromiseId = this.promiseQueue.maxId
1264
1273
 
@@ -1275,15 +1284,22 @@ export abstract class PostHogCoreStateless {
1275
1284
  // Use a custom allSettled implementation to avoid issues with patching Promise on RN
1276
1285
  .then(() => allSettled([previousFlushPromise]))
1277
1286
  .then(() => {
1287
+ if (this.pendingFlushPromise === nextFlushPromise) {
1288
+ this.pendingFlushPromise = null
1289
+ }
1278
1290
  return this._flush()
1279
1291
  })
1280
1292
 
1293
+ this.pendingFlushPromise = nextFlushPromise
1281
1294
  this.flushPromise = nextFlushPromise
1282
1295
  this.flushPromises.add(nextFlushPromise)
1283
1296
  void this.addPendingPromise(nextFlushPromise)
1284
1297
 
1285
1298
  allSettled([nextFlushPromise]).then(() => {
1286
1299
  this.flushPromises.delete(nextFlushPromise)
1300
+ if (this.pendingFlushPromise === nextFlushPromise) {
1301
+ this.pendingFlushPromise = null
1302
+ }
1287
1303
  // If there are no others waiting to flush, clear the promise.
1288
1304
  // We don't strictly need to do this, but it could make debugging easier
1289
1305
  if (this.flushPromise === nextFlushPromise) {
@@ -1331,6 +1347,7 @@ export abstract class PostHogCoreStateless {
1331
1347
  const newQueue = refreshedQueue.slice(batchItems.length)
1332
1348
  this.setPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue, newQueue)
1333
1349
  queue = newQueue
1350
+ this._dequeuedMessagesCount += batchItems.length
1334
1351
  // Wait for storage to complete to prevent duplicate events on app crash
1335
1352
  await this.flushStorage()
1336
1353
  }
@@ -1532,6 +1549,8 @@ export abstract class PostHogCoreStateless {
1532
1549
  break
1533
1550
  }
1534
1551
 
1552
+ const dequeuedBeforeFlush = this._dequeuedMessagesCount
1553
+
1535
1554
  // flush again to make sure we send all events, some of which might've been added
1536
1555
  // while we were waiting for the pending promises to resolve
1537
1556
  // For example, see sendFeatureFlags in posthog-node/src/posthog-node.ts::capture
@@ -1540,6 +1559,13 @@ export abstract class PostHogCoreStateless {
1540
1559
  if (hasTimedOut) {
1541
1560
  break
1542
1561
  }
1562
+
1563
+ if (this._dequeuedMessagesCount === dequeuedBeforeFlush) {
1564
+ this._logger.warn(
1565
+ 'Shutdown flush completed but did not send any queued events. Stopping drain to avoid a loop.'
1566
+ )
1567
+ break
1568
+ }
1543
1569
  }
1544
1570
  } catch (e) {
1545
1571
  if (!isPostHogFetchError(e)) {