@posthog/core 1.41.0 → 1.42.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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.mjs +2 -2
- package/dist/metrics/config.d.ts +9 -0
- package/dist/metrics/config.d.ts.map +1 -0
- package/dist/metrics/config.js +49 -0
- package/dist/metrics/config.mjs +15 -0
- package/dist/metrics/index.d.ts +8 -1
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +39 -19
- package/dist/metrics/index.mjs +36 -19
- package/dist/metrics/types.d.ts +1 -1
- package/dist/metrics/types.d.ts.map +1 -1
- package/dist/posthog-core-stateless.d.ts +9 -1
- package/dist/posthog-core-stateless.d.ts.map +1 -1
- package/dist/posthog-core-stateless.js +43 -0
- package/dist/posthog-core-stateless.mjs +43 -0
- package/dist/surveys/activation.d.ts +20 -0
- package/dist/surveys/activation.d.ts.map +1 -0
- package/dist/surveys/activation.js +45 -0
- package/dist/surveys/activation.mjs +8 -0
- package/dist/surveys/events.d.ts +1 -4
- package/dist/surveys/events.d.ts.map +1 -1
- package/dist/surveys/index.d.ts +2 -0
- package/dist/surveys/index.d.ts.map +1 -1
- package/dist/surveys/index.js +16 -2
- package/dist/surveys/index.mjs +3 -1
- package/dist/surveys/keys.d.ts +14 -0
- package/dist/surveys/keys.d.ts.map +1 -0
- package/dist/surveys/keys.js +45 -0
- package/dist/surveys/keys.mjs +8 -0
- package/dist/types.d.ts +87 -76
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +65 -76
- package/dist/types.mjs +66 -77
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/metrics/config.ts +23 -0
- package/src/metrics/index.spec.ts +49 -0
- package/src/metrics/index.ts +66 -24
- package/src/metrics/types.ts +1 -0
- package/src/posthog-core-stateless.ts +51 -1
- package/src/surveys/activation.spec.ts +61 -0
- package/src/surveys/activation.ts +28 -0
- package/src/surveys/events.ts +1 -5
- package/src/surveys/index.ts +2 -0
- package/src/surveys/keys.spec.ts +29 -0
- package/src/surveys/keys.ts +23 -0
- package/src/types.ts +88 -76
package/dist/types.js
CHANGED
|
@@ -89,82 +89,71 @@ const FeatureFlagError = {
|
|
|
89
89
|
UNKNOWN_ERROR: 'unknown_error',
|
|
90
90
|
apiError: (status)=>`api_error_${status}`
|
|
91
91
|
};
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
SurveySchedule["Once"] = "once";
|
|
158
|
-
SurveySchedule["Recurring"] = "recurring";
|
|
159
|
-
SurveySchedule["Always"] = "always";
|
|
160
|
-
return SurveySchedule;
|
|
161
|
-
}({});
|
|
162
|
-
var ActionStepStringMatching = /*#__PURE__*/ function(ActionStepStringMatching) {
|
|
163
|
-
ActionStepStringMatching["Contains"] = "contains";
|
|
164
|
-
ActionStepStringMatching["Exact"] = "exact";
|
|
165
|
-
ActionStepStringMatching["Regex"] = "regex";
|
|
166
|
-
return ActionStepStringMatching;
|
|
167
|
-
}({});
|
|
92
|
+
const SurveyPosition = {
|
|
93
|
+
TopLeft: 'top_left',
|
|
94
|
+
TopCenter: 'top_center',
|
|
95
|
+
TopRight: 'top_right',
|
|
96
|
+
MiddleLeft: 'middle_left',
|
|
97
|
+
MiddleCenter: 'middle_center',
|
|
98
|
+
MiddleRight: 'middle_right',
|
|
99
|
+
Left: 'left',
|
|
100
|
+
Right: 'right',
|
|
101
|
+
Center: 'center'
|
|
102
|
+
};
|
|
103
|
+
const SurveyWidgetType = {
|
|
104
|
+
Button: 'button',
|
|
105
|
+
Tab: 'tab',
|
|
106
|
+
Selector: 'selector'
|
|
107
|
+
};
|
|
108
|
+
const SurveyType = {
|
|
109
|
+
Popover: 'popover',
|
|
110
|
+
API: 'api',
|
|
111
|
+
Widget: 'widget',
|
|
112
|
+
ExternalSurvey: 'external_survey'
|
|
113
|
+
};
|
|
114
|
+
const SurveyQuestionDescriptionContentType = {
|
|
115
|
+
Html: 'html',
|
|
116
|
+
Text: 'text'
|
|
117
|
+
};
|
|
118
|
+
const SurveyValidationType = {
|
|
119
|
+
MinLength: 'min_length',
|
|
120
|
+
MaxLength: 'max_length'
|
|
121
|
+
};
|
|
122
|
+
const SurveyRatingDisplay = {
|
|
123
|
+
Number: 'number',
|
|
124
|
+
Emoji: 'emoji'
|
|
125
|
+
};
|
|
126
|
+
const SurveyQuestionType = {
|
|
127
|
+
Open: 'open',
|
|
128
|
+
MultipleChoice: 'multiple_choice',
|
|
129
|
+
SingleChoice: 'single_choice',
|
|
130
|
+
Rating: 'rating',
|
|
131
|
+
Link: 'link'
|
|
132
|
+
};
|
|
133
|
+
const SurveyQuestionBranchingType = {
|
|
134
|
+
NextQuestion: 'next_question',
|
|
135
|
+
End: 'end',
|
|
136
|
+
ResponseBased: 'response_based',
|
|
137
|
+
SpecificQuestion: 'specific_question'
|
|
138
|
+
};
|
|
139
|
+
const SurveyMatchType = {
|
|
140
|
+
Regex: 'regex',
|
|
141
|
+
NotRegex: 'not_regex',
|
|
142
|
+
Exact: 'exact',
|
|
143
|
+
IsNot: 'is_not',
|
|
144
|
+
Icontains: 'icontains',
|
|
145
|
+
NotIcontains: 'not_icontains'
|
|
146
|
+
};
|
|
147
|
+
const SurveySchedule = {
|
|
148
|
+
Once: 'once',
|
|
149
|
+
Recurring: 'recurring',
|
|
150
|
+
Always: 'always'
|
|
151
|
+
};
|
|
152
|
+
const ActionStepStringMatching = {
|
|
153
|
+
Contains: 'contains',
|
|
154
|
+
Exact: 'exact',
|
|
155
|
+
Regex: 'regex'
|
|
156
|
+
};
|
|
168
157
|
const knownUnsafeEditableEvent = [
|
|
169
158
|
'$snapshot',
|
|
170
159
|
'$pageview',
|
package/dist/types.mjs
CHANGED
|
@@ -46,82 +46,71 @@ const FeatureFlagError = {
|
|
|
46
46
|
UNKNOWN_ERROR: 'unknown_error',
|
|
47
47
|
apiError: (status)=>`api_error_${status}`
|
|
48
48
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
SurveySchedule["Once"] = "once";
|
|
115
|
-
SurveySchedule["Recurring"] = "recurring";
|
|
116
|
-
SurveySchedule["Always"] = "always";
|
|
117
|
-
return SurveySchedule;
|
|
118
|
-
}({});
|
|
119
|
-
var types_ActionStepStringMatching = /*#__PURE__*/ function(ActionStepStringMatching) {
|
|
120
|
-
ActionStepStringMatching["Contains"] = "contains";
|
|
121
|
-
ActionStepStringMatching["Exact"] = "exact";
|
|
122
|
-
ActionStepStringMatching["Regex"] = "regex";
|
|
123
|
-
return ActionStepStringMatching;
|
|
124
|
-
}({});
|
|
49
|
+
const SurveyPosition = {
|
|
50
|
+
TopLeft: 'top_left',
|
|
51
|
+
TopCenter: 'top_center',
|
|
52
|
+
TopRight: 'top_right',
|
|
53
|
+
MiddleLeft: 'middle_left',
|
|
54
|
+
MiddleCenter: 'middle_center',
|
|
55
|
+
MiddleRight: 'middle_right',
|
|
56
|
+
Left: 'left',
|
|
57
|
+
Right: 'right',
|
|
58
|
+
Center: 'center'
|
|
59
|
+
};
|
|
60
|
+
const SurveyWidgetType = {
|
|
61
|
+
Button: 'button',
|
|
62
|
+
Tab: 'tab',
|
|
63
|
+
Selector: 'selector'
|
|
64
|
+
};
|
|
65
|
+
const SurveyType = {
|
|
66
|
+
Popover: 'popover',
|
|
67
|
+
API: 'api',
|
|
68
|
+
Widget: 'widget',
|
|
69
|
+
ExternalSurvey: 'external_survey'
|
|
70
|
+
};
|
|
71
|
+
const SurveyQuestionDescriptionContentType = {
|
|
72
|
+
Html: 'html',
|
|
73
|
+
Text: 'text'
|
|
74
|
+
};
|
|
75
|
+
const SurveyValidationType = {
|
|
76
|
+
MinLength: 'min_length',
|
|
77
|
+
MaxLength: 'max_length'
|
|
78
|
+
};
|
|
79
|
+
const SurveyRatingDisplay = {
|
|
80
|
+
Number: 'number',
|
|
81
|
+
Emoji: 'emoji'
|
|
82
|
+
};
|
|
83
|
+
const SurveyQuestionType = {
|
|
84
|
+
Open: 'open',
|
|
85
|
+
MultipleChoice: 'multiple_choice',
|
|
86
|
+
SingleChoice: 'single_choice',
|
|
87
|
+
Rating: 'rating',
|
|
88
|
+
Link: 'link'
|
|
89
|
+
};
|
|
90
|
+
const SurveyQuestionBranchingType = {
|
|
91
|
+
NextQuestion: 'next_question',
|
|
92
|
+
End: 'end',
|
|
93
|
+
ResponseBased: 'response_based',
|
|
94
|
+
SpecificQuestion: 'specific_question'
|
|
95
|
+
};
|
|
96
|
+
const SurveyMatchType = {
|
|
97
|
+
Regex: 'regex',
|
|
98
|
+
NotRegex: 'not_regex',
|
|
99
|
+
Exact: 'exact',
|
|
100
|
+
IsNot: 'is_not',
|
|
101
|
+
Icontains: 'icontains',
|
|
102
|
+
NotIcontains: 'not_icontains'
|
|
103
|
+
};
|
|
104
|
+
const SurveySchedule = {
|
|
105
|
+
Once: 'once',
|
|
106
|
+
Recurring: 'recurring',
|
|
107
|
+
Always: 'always'
|
|
108
|
+
};
|
|
109
|
+
const ActionStepStringMatching = {
|
|
110
|
+
Contains: 'contains',
|
|
111
|
+
Exact: 'exact',
|
|
112
|
+
Regex: 'regex'
|
|
113
|
+
};
|
|
125
114
|
const knownUnsafeEditableEvent = [
|
|
126
115
|
'$snapshot',
|
|
127
116
|
'$pageview',
|
|
@@ -141,4 +130,4 @@ const knownUnsafeEditableEvent = [
|
|
|
141
130
|
const knownUnsafeEditableEventProperty = [
|
|
142
131
|
'token'
|
|
143
132
|
];
|
|
144
|
-
export {
|
|
133
|
+
export { ActionStepStringMatching, types_Compression as Compression, FeatureFlagError, types_PostHogPersistedProperty as PostHogPersistedProperty, SurveyMatchType, SurveyPosition, SurveyQuestionBranchingType, SurveyQuestionDescriptionContentType, SurveyQuestionType, SurveyRatingDisplay, SurveySchedule, SurveyType, SurveyValidationType, SurveyWidgetType, knownUnsafeEditableEvent, knownUnsafeEditableEventProperty };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"bugs": {
|
|
5
5
|
"url": "https://github.com/PostHog/posthog-js/issues"
|
|
6
6
|
},
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"@posthog/types": "^1.
|
|
73
|
+
"@posthog/types": "^1.395.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@rslib/core": "0.10.6",
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export {
|
|
|
45
45
|
buildOtlpMetricsPayload,
|
|
46
46
|
buildMetricsResourceAttributes,
|
|
47
47
|
DEFAULT_HISTOGRAM_BOUNDS,
|
|
48
|
+
resolveMetricsConfig,
|
|
48
49
|
} from './metrics'
|
|
49
50
|
export type {
|
|
50
51
|
MetricsHost,
|
|
@@ -59,6 +60,8 @@ export type {
|
|
|
59
60
|
MetricAttributeValue,
|
|
60
61
|
MetricSample,
|
|
61
62
|
MetricType,
|
|
63
|
+
Metrics,
|
|
64
|
+
MetricsConfig,
|
|
62
65
|
} from './metrics/types'
|
|
63
66
|
export { uuidv7 } from './vendor/uuidv7'
|
|
64
67
|
export * from './cookie'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MetricsConfig } from '@posthog/types'
|
|
2
|
+
import type { ResolvedPostHogMetricsConfig } from './types'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 10000
|
|
5
|
+
const DEFAULT_MAX_SERIES_PER_FLUSH = 1000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the public `metrics` config into the shape `PostHogMetrics`
|
|
9
|
+
* consumes. Same precedence rule as logs: OTLP keys in `resourceAttributes`
|
|
10
|
+
* win over the named config fields.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveMetricsConfig(config: MetricsConfig | undefined): ResolvedPostHogMetricsConfig {
|
|
13
|
+
const resourceAttributes = config?.resourceAttributes
|
|
14
|
+
return {
|
|
15
|
+
serviceName: (resourceAttributes?.['service.name'] as string | undefined) ?? config?.serviceName,
|
|
16
|
+
serviceVersion: (resourceAttributes?.['service.version'] as string | undefined) ?? config?.serviceVersion,
|
|
17
|
+
environment: (resourceAttributes?.['deployment.environment'] as string | undefined) ?? config?.environment,
|
|
18
|
+
resourceAttributes,
|
|
19
|
+
beforeSend: config?.beforeSend,
|
|
20
|
+
flushIntervalMs: config?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
21
|
+
maxSeriesPerFlush: config?.maxSeriesPerFlush ?? DEFAULT_MAX_SERIES_PER_FLUSH,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -349,6 +349,34 @@ describe('PostHogMetrics', () => {
|
|
|
349
349
|
expect((logger.warn as jest.Mock).mock.calls.length).toBe(1)
|
|
350
350
|
})
|
|
351
351
|
|
|
352
|
+
it('re-applies the series cap when merging a failed window back', async () => {
|
|
353
|
+
let resolveSend!: (outcome: SendMetricsBatchOutcome) => void
|
|
354
|
+
mockInstance._sendMetricsBatch
|
|
355
|
+
.mockImplementationOnce(() => new Promise<SendMetricsBatchOutcome>((resolve) => (resolveSend = resolve)))
|
|
356
|
+
.mockResolvedValue({ kind: 'ok' })
|
|
357
|
+
|
|
358
|
+
const metrics = createMetrics({ maxSeriesPerFlush: 2 })
|
|
359
|
+
metrics.count('a', 1)
|
|
360
|
+
metrics.count('b', 1)
|
|
361
|
+
const inflight = metrics.flush()
|
|
362
|
+
// New series captured while the failing send is in flight fill the fresh window to the cap.
|
|
363
|
+
metrics.count('c', 1)
|
|
364
|
+
metrics.count('d', 1)
|
|
365
|
+
resolveSend({ kind: 'retry-later', error: new Error('offline') })
|
|
366
|
+
await inflight
|
|
367
|
+
|
|
368
|
+
await metrics.flush()
|
|
369
|
+
|
|
370
|
+
const payloads = sentPayloads()
|
|
371
|
+
expect(payloads).toHaveLength(2)
|
|
372
|
+
const dataPointCount = payloads[1].resourceMetrics[0].scopeMetrics[0].metrics.reduce(
|
|
373
|
+
(n, m) => n + (m.sum?.dataPoints.length ?? 0),
|
|
374
|
+
0
|
|
375
|
+
)
|
|
376
|
+
expect(dataPointCount).toBeLessThanOrEqual(2)
|
|
377
|
+
expect((logger.warn as jest.Mock).mock.calls.some((c) => String(c[0]).includes('series cap'))).toBe(true)
|
|
378
|
+
})
|
|
379
|
+
|
|
352
380
|
it('drops non-finite values and negative counts', async () => {
|
|
353
381
|
const metrics = createMetrics()
|
|
354
382
|
metrics.count('a', NaN)
|
|
@@ -365,6 +393,27 @@ describe('PostHogMetrics', () => {
|
|
|
365
393
|
expect(sent[0].gauge!.dataPoints[0].asDouble).toBe(-5)
|
|
366
394
|
})
|
|
367
395
|
|
|
396
|
+
it('drops samples with unserializable attributes instead of throwing', async () => {
|
|
397
|
+
const metrics = createMetrics()
|
|
398
|
+
const throwingAttrs = {}
|
|
399
|
+
Object.defineProperty(throwingAttrs, 'bad', {
|
|
400
|
+
enumerable: true,
|
|
401
|
+
get() {
|
|
402
|
+
throw new Error('boom')
|
|
403
|
+
},
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
expect(() => metrics.count('good', 1, { attributes: { plan: 'free' } })).not.toThrow()
|
|
407
|
+
expect(() => metrics.count('bigint_attr', 1, { attributes: { jobId: BigInt(1) } as any })).not.toThrow()
|
|
408
|
+
expect(() => metrics.count('throwing_attr', 1, { attributes: throwingAttrs as any })).not.toThrow()
|
|
409
|
+
await metrics.flush()
|
|
410
|
+
|
|
411
|
+
// Only the well-formed sample ships; the malformed ones are dropped with a warning.
|
|
412
|
+
expect(sentMetrics().map((m) => m.name)).toEqual(['good'])
|
|
413
|
+
const attrWarns = (logger.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes('attributes'))
|
|
414
|
+
expect(attrWarns).toHaveLength(2)
|
|
415
|
+
})
|
|
416
|
+
|
|
368
417
|
it('drops samples with an empty name', async () => {
|
|
369
418
|
const metrics = createMetrics()
|
|
370
419
|
metrics.count('', 1)
|
package/src/metrics/index.ts
CHANGED
|
@@ -73,6 +73,10 @@ export class PostHogMetrics {
|
|
|
73
73
|
// types under one name produces charts that blend both series.
|
|
74
74
|
private _typeByName = new Map<string, MetricType>()
|
|
75
75
|
private _typeCollisionWarned = new Set<string>()
|
|
76
|
+
// Bumped by reset(). A flush that was in flight when reset() ran (e.g. it
|
|
77
|
+
// lost a shutdown race) sees a stale generation when its send settles and
|
|
78
|
+
// discards its window instead of merging it back and re-arming the timer.
|
|
79
|
+
private _generation = 0
|
|
76
80
|
|
|
77
81
|
constructor(
|
|
78
82
|
private readonly _instance: MetricsHost,
|
|
@@ -129,8 +133,9 @@ export class PostHogMetrics {
|
|
|
129
133
|
return this._buildPayload(window)
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
/** Clears the flush timer
|
|
136
|
+
/** Clears the flush timer, drops the current window, and invalidates in-flight flushes. */
|
|
133
137
|
reset(): void {
|
|
138
|
+
this._generation++
|
|
134
139
|
this._clearFlushTimer()
|
|
135
140
|
this._series = new Map()
|
|
136
141
|
this._flushPromise = null
|
|
@@ -163,46 +168,70 @@ export class PostHogMetrics {
|
|
|
163
168
|
return
|
|
164
169
|
}
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
171
|
+
// Snapshot: the key is computed from these values, so a caller mutating
|
|
172
|
+
// the object after capture must not change the stored series. Reading and
|
|
173
|
+
// serializing the attributes can throw (BigInt values, throwing
|
|
174
|
+
// getters/proxies) — a malformed sample is dropped, never thrown.
|
|
175
|
+
let attributes: MetricAttributes | undefined
|
|
176
|
+
let key: string
|
|
177
|
+
try {
|
|
178
|
+
attributes = filtered.attributes ? { ...filtered.attributes } : undefined
|
|
179
|
+
key = seriesKey(filtered.type, filtered.name, filtered.unit, attributes)
|
|
180
|
+
} catch (e) {
|
|
181
|
+
this._logger.warn(`Dropping metric '${filtered.name}': attributes could not be serialized`, e)
|
|
182
|
+
return
|
|
175
183
|
}
|
|
176
184
|
|
|
177
|
-
const key = seriesKey(filtered.type, filtered.name, filtered.unit, filtered.attributes)
|
|
178
185
|
let state = this._series.get(key)
|
|
179
186
|
if (!state) {
|
|
180
|
-
if (this.
|
|
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
|
-
}
|
|
187
|
+
if (!this._admitNewSeries()) {
|
|
188
188
|
return
|
|
189
189
|
}
|
|
190
190
|
state = {
|
|
191
191
|
name: filtered.name,
|
|
192
192
|
type: filtered.type,
|
|
193
193
|
unit: filtered.unit,
|
|
194
|
-
|
|
195
|
-
// mutating the object after capture must not change the stored series.
|
|
196
|
-
attributes: filtered.attributes ? { ...filtered.attributes } : undefined,
|
|
194
|
+
attributes,
|
|
197
195
|
windowStartMs: Date.now(),
|
|
198
196
|
}
|
|
199
197
|
this._series.set(key, state)
|
|
200
198
|
}
|
|
201
199
|
|
|
200
|
+
// Bookkeeping only for admitted samples, so name-cardinality misuse (IDs
|
|
201
|
+
// interpolated into metric names) can't grow this map past the series cap.
|
|
202
|
+
const seenType = this._typeByName.get(filtered.name)
|
|
203
|
+
if (seenType === undefined) {
|
|
204
|
+
this._typeByName.set(filtered.name, filtered.type)
|
|
205
|
+
} else if (seenType !== filtered.type && !this._typeCollisionWarned.has(filtered.name)) {
|
|
206
|
+
this._typeCollisionWarned.add(filtered.name)
|
|
207
|
+
this._logger.warn(
|
|
208
|
+
`Metric name '${filtered.name}' is already used as a ${seenType}; ` +
|
|
209
|
+
`recording it as a ${filtered.type} too will blend both series in charts. Use a distinct name.`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
202
213
|
this._fold(state, filtered.value)
|
|
203
214
|
this._armFlushTimer()
|
|
204
215
|
}
|
|
205
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Cardinality gate for adding a series to the live window — warns once per
|
|
219
|
+
* window when the cap is hit. Applied on capture and on merge-back alike.
|
|
220
|
+
*/
|
|
221
|
+
private _admitNewSeries(): boolean {
|
|
222
|
+
if (this._series.size < this._config.maxSeriesPerFlush) {
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
if (!this._seriesCapWarned) {
|
|
226
|
+
this._seriesCapWarned = true
|
|
227
|
+
this._logger.warn(
|
|
228
|
+
`Metric series cap reached (${this._config.maxSeriesPerFlush} per flush window); ` +
|
|
229
|
+
`dropping new series until the next flush. Reduce attribute cardinality.`
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
|
|
206
235
|
private _fold(state: SeriesState, value: number): void {
|
|
207
236
|
switch (state.type) {
|
|
208
237
|
case 'count':
|
|
@@ -261,7 +290,9 @@ export class PostHogMetrics {
|
|
|
261
290
|
}
|
|
262
291
|
this._flushTimer = safeSetTimeout(() => {
|
|
263
292
|
this._flushTimer = undefined
|
|
264
|
-
this.flush().catch(() => {
|
|
293
|
+
this.flush().catch((e) => {
|
|
294
|
+
this._logger.error('Metrics flush failed:', e)
|
|
295
|
+
})
|
|
265
296
|
}, this._config.flushIntervalMs)
|
|
266
297
|
}
|
|
267
298
|
|
|
@@ -285,7 +316,13 @@ export class PostHogMetrics {
|
|
|
285
316
|
this._typeByName = new Map()
|
|
286
317
|
this._typeCollisionWarned = new Set()
|
|
287
318
|
|
|
319
|
+
const generation = this._generation
|
|
288
320
|
const outcome = await this._instance._sendMetricsBatch(this._buildPayload(window))
|
|
321
|
+
if (generation !== this._generation) {
|
|
322
|
+
// reset() ran while the send was in flight — the client was torn down or
|
|
323
|
+
// reconfigured, so this window is dropped whatever the outcome was.
|
|
324
|
+
return
|
|
325
|
+
}
|
|
289
326
|
switch (outcome.kind) {
|
|
290
327
|
case 'ok':
|
|
291
328
|
return
|
|
@@ -379,7 +416,11 @@ export class PostHogMetrics {
|
|
|
379
416
|
for (const [key, old] of window) {
|
|
380
417
|
const current = this._series.get(key)
|
|
381
418
|
if (!current) {
|
|
382
|
-
|
|
419
|
+
// The cap still applies here: an unbounded merge-back would let a
|
|
420
|
+
// failed flush reintroduce series past maxSeriesPerFlush.
|
|
421
|
+
if (this._admitNewSeries()) {
|
|
422
|
+
this._series.set(key, old)
|
|
423
|
+
}
|
|
383
424
|
continue
|
|
384
425
|
}
|
|
385
426
|
current.windowStartMs = Math.min(current.windowStartMs, old.windowStartMs)
|
|
@@ -411,4 +452,5 @@ export class PostHogMetrics {
|
|
|
411
452
|
}
|
|
412
453
|
|
|
413
454
|
export { buildOtlpMetricsPayload, buildMetricsResourceAttributes, DEFAULT_HISTOGRAM_BOUNDS } from './metrics-utils'
|
|
455
|
+
export { resolveMetricsConfig } from './config'
|
|
414
456
|
export type { MetricsHost, PostHogMetricsConfig, ResolvedPostHogMetricsConfig, SendMetricsBatchOutcome } from './types'
|
package/src/metrics/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { OtlpLogsPayload } from '@posthog/types'
|
|
1
|
+
import type { OtlpLogsPayload, OtlpMetricsPayload } from '@posthog/types'
|
|
2
|
+
import type { SendMetricsBatchOutcome } from './metrics/types'
|
|
2
3
|
import { SimpleEventEmitter } from './eventemitter'
|
|
3
4
|
import { getFeatureFlagValue, normalizeFlagsResponse } from './featureFlagUtils'
|
|
4
5
|
import { gzipCompress, isGzipSupported } from './gzip'
|
|
@@ -1534,6 +1535,55 @@ export abstract class PostHogCoreStateless {
|
|
|
1534
1535
|
}
|
|
1535
1536
|
}
|
|
1536
1537
|
|
|
1538
|
+
/**
|
|
1539
|
+
* Sends a pre-built OTLP metrics payload to `/i/v1/metrics`. Same tagged
|
|
1540
|
+
* outcome contract and error classification as `_sendLogsBatch` — this is
|
|
1541
|
+
* the `MetricsHost._sendMetricsBatch` implementation, so `PostHogMetrics`
|
|
1542
|
+
* can use any core-based SDK as its host.
|
|
1543
|
+
*/
|
|
1544
|
+
async _sendMetricsBatch(payload: OtlpMetricsPayload): Promise<SendMetricsBatchOutcome> {
|
|
1545
|
+
if (this.disabled) {
|
|
1546
|
+
return { kind: 'fatal', error: new Error('The client is disabled') }
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const serialized = JSON.stringify(payload)
|
|
1550
|
+
const url = `${this.host}/i/v1/metrics?token=${encodeURIComponent(this.apiKey)}`
|
|
1551
|
+
|
|
1552
|
+
const gzippedPayload = !this.disableCompression ? await gzipCompress(serialized, this.isDebug) : null
|
|
1553
|
+
const fetchOptions: PostHogFetchOptions = {
|
|
1554
|
+
method: 'POST',
|
|
1555
|
+
headers: {
|
|
1556
|
+
...this.getCustomHeaders(),
|
|
1557
|
+
'Content-Type': 'application/json',
|
|
1558
|
+
...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
|
|
1559
|
+
},
|
|
1560
|
+
body: gzippedPayload || serialized,
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
try {
|
|
1564
|
+
await this.fetchWithRetry(url, fetchOptions, {
|
|
1565
|
+
retryCheck: (err) => {
|
|
1566
|
+
if (isPostHogFetchContentTooLargeError(err)) {
|
|
1567
|
+
return false
|
|
1568
|
+
}
|
|
1569
|
+
return isPostHogFetchRetryableError(err)
|
|
1570
|
+
},
|
|
1571
|
+
})
|
|
1572
|
+
return { kind: 'ok' }
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
if (isPostHogFetchContentTooLargeError(err)) {
|
|
1575
|
+
return { kind: 'too-large' }
|
|
1576
|
+
}
|
|
1577
|
+
// Exhausted retries on a retryable failure (network error, 408/429/5xx)
|
|
1578
|
+
// still classify as retry-later so the window rides the next flush; only
|
|
1579
|
+
// non-retryable HTTP errors (and 413 above) drop the batch.
|
|
1580
|
+
if (isPostHogFetchRetryableError(err)) {
|
|
1581
|
+
return { kind: 'retry-later', error: err }
|
|
1582
|
+
}
|
|
1583
|
+
return { kind: 'fatal', error: err }
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1537
1587
|
private async fetchWithRetry(
|
|
1538
1588
|
url: string,
|
|
1539
1589
|
options: PostHogFetchOptions,
|