@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.
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/.github/dependabot.yml +42 -2
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/README.md +5 -5
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/index.js +4 -7
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/package.json +3 -3
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/additionalProperties.test.js +47 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/const.test.js +20 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify/test/patternProperties.test.js +24 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.gitattributes +2 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/dependabot.yml +53 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/workflows/ci.yml +154 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/.github/workflows/lock-threads.yml +19 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/LICENSE +30 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/README.md +152 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/benchmark.mjs +159 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/encoder.mjs +54 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/equal.mjs +51 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/non-simple-domain.mjs +22 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/package.json +17 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/string-array-to-hex-stripped.mjs +24 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/benchmark/ws-is-secure.mjs +65 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/eslint.config.js +6 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/index.js +405 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/lib/schemes.js +267 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/lib/utils.js +585 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/package.json +68 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/ajv.test.js +43 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/equal.test.js +168 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/fixtures/uri-js-parse.json +501 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/fixtures/uri-js-serialize.json +120 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/parse.test.js +325 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/resolve.test.js +87 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/rfc-3986.test.js +90 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/scheme-normalization.test.js +16 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security-normalization.test.js +39 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security-utf8-encoding.test.js +0 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/security.test.js +161 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/serialize.test.js +151 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/uri-js-compatibility.test.js +33 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/uri-js.test.js +912 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/test/util.test.js +47 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/tsconfig.json +10 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/types/index.d.ts +51 -0
- package/node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri/types/index.tst.ts +15 -0
- package/node_modules/@posthog/core/dist/index.d.ts +3 -0
- package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/index.js +133 -93
- package/node_modules/@posthog/core/dist/index.mjs +2 -1
- package/node_modules/@posthog/core/dist/metrics/index.d.ts +61 -0
- package/node_modules/@posthog/core/dist/metrics/index.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/metrics/index.js +326 -0
- package/node_modules/@posthog/core/dist/metrics/index.mjs +283 -0
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts +41 -0
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.js +117 -0
- package/node_modules/@posthog/core/dist/metrics/metrics-utils.mjs +68 -0
- package/node_modules/@posthog/core/dist/metrics/types.d.ts +69 -0
- package/node_modules/@posthog/core/dist/metrics/types.d.ts.map +1 -0
- package/node_modules/@posthog/core/dist/metrics/types.js +18 -0
- package/node_modules/@posthog/core/dist/metrics/types.mjs +0 -0
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts +2 -0
- package/node_modules/@posthog/core/dist/posthog-core-stateless.d.ts.map +1 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.js +16 -1
- package/node_modules/@posthog/core/dist/posthog-core-stateless.mjs +16 -1
- package/node_modules/@posthog/core/package.json +2 -2
- package/node_modules/@posthog/core/src/index.ts +20 -0
- package/node_modules/@posthog/core/src/metrics/index.spec.ts +459 -0
- package/node_modules/@posthog/core/src/metrics/index.ts +414 -0
- package/node_modules/@posthog/core/src/metrics/metrics-utils.ts +100 -0
- package/node_modules/@posthog/core/src/metrics/types.ts +89 -0
- package/node_modules/@posthog/core/src/posthog-core-stateless.ts +26 -0
- package/node_modules/@posthog/types/dist/capture-metric.d.ts +122 -0
- package/node_modules/@posthog/types/dist/capture-metric.d.ts.map +1 -0
- package/node_modules/@posthog/types/dist/capture-metric.js +36 -0
- package/node_modules/@posthog/types/dist/capture-metric.mjs +2 -0
- package/node_modules/@posthog/types/dist/index.d.ts +3 -1
- package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
- package/node_modules/@posthog/types/dist/index.js +19 -1
- package/node_modules/@posthog/types/dist/index.mjs +2 -0
- package/node_modules/@posthog/types/dist/posthog-config.d.ts +60 -0
- package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
- package/node_modules/@posthog/types/package.json +1 -1
- package/node_modules/@posthog/types/src/capture-metric.ts +133 -0
- package/node_modules/@posthog/types/src/index.ts +17 -0
- package/node_modules/@posthog/types/src/posthog-config.ts +62 -0
- package/node_modules/@zuplo/core/package.json +1 -1
- package/node_modules/@zuplo/graphql/package.json +1 -1
- package/node_modules/@zuplo/openapi-tools/package.json +1 -1
- package/node_modules/@zuplo/otel/package.json +1 -1
- package/node_modules/@zuplo/runtime/out/esm/{chunk-FAOWNDMK.js → chunk-IC7XGD5C.js} +93 -93
- package/node_modules/@zuplo/runtime/out/esm/chunk-IC7XGD5C.js.map +1 -0
- package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/esm/index.js.map +1 -1
- package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
- package/node_modules/@zuplo/runtime/out/types/index.d.ts +830 -6
- package/node_modules/@zuplo/runtime/package.json +1 -1
- package/package.json +6 -6
- package/node_modules/@zuplo/runtime/out/esm/chunk-FAOWNDMK.js.map +0 -1
- /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)) {
|