@posthog/core 1.25.3 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ export declare const EXCEPTION_STEP_INTERNAL_FIELDS: {
2
+ readonly MESSAGE: "$message";
3
+ readonly TIMESTAMP: "$timestamp";
4
+ };
5
+ export type ExceptionStep = {
6
+ [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]: string;
7
+ [EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]: string | number;
8
+ [key: string]: unknown;
9
+ };
10
+ /** NOTE: This type is also defined in `@posthog/types` (posthog-config.ts). Keep both in sync. */
11
+ export type ExceptionStepsConfig = {
12
+ enabled?: boolean;
13
+ max_bytes?: number;
14
+ };
15
+ export type ResolvedExceptionStepsConfig = {
16
+ enabled: boolean;
17
+ max_bytes: number;
18
+ };
19
+ export declare const DEFAULT_EXCEPTION_STEPS_CONFIG: ResolvedExceptionStepsConfig;
20
+ export declare function resolveExceptionStepsConfig(config?: ExceptionStepsConfig | null): ResolvedExceptionStepsConfig;
21
+ export declare function stripReservedExceptionStepFields(properties?: Record<string, unknown> | null): {
22
+ sanitizedProperties: Record<string, unknown>;
23
+ droppedKeys: string[];
24
+ };
25
+ export declare class ExceptionStepsBuffer {
26
+ private _entries;
27
+ private _totalBytes;
28
+ private _config;
29
+ constructor(config?: ExceptionStepsConfig | null);
30
+ setConfig(config?: ExceptionStepsConfig | null): void;
31
+ add(step: ExceptionStep): void;
32
+ getAttachable(): ExceptionStep[];
33
+ clear(): void;
34
+ size(): number;
35
+ private _trimToMaxBytes;
36
+ }
37
+ export declare function getUtf8ByteLength(value: string): number;
38
+ //# sourceMappingURL=exception-steps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exception-steps.d.ts","sourceRoot":"","sources":["../../src/error-tracking/exception-steps.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,8BAA8B;;;CAGjC,CAAA;AAOV,MAAM,MAAM,aAAa,GAAG;IAC1B,CAAC,8BAA8B,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChD,CAAC,8BAA8B,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IAC3D,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB,CAAA;AAED,kGAAkG;AAClG,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,4BAA4B,GAAG;IACzC,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,eAAO,MAAM,8BAA8B,EAAE,4BAG5C,CAAA;AAED,wBAAgB,2BAA2B,CAAC,MAAM,CAAC,EAAE,oBAAoB,GAAG,IAAI,GAAG,4BAA4B,CAS9G;AAED,wBAAgB,gCAAgC,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG;IAC7F,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC5C,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB,CAmBA;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAA+C;IAC/D,OAAO,CAAC,WAAW,CAAY;IAC/B,OAAO,CAAC,OAAO,CAA8B;gBAEjC,MAAM,CAAC,EAAE,oBAAoB,GAAG,IAAI;IAIzC,SAAS,CAAC,MAAM,CAAC,EAAE,oBAAoB,GAAG,IAAI,GAAG,IAAI;IAKrD,GAAG,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI;IAgB9B,aAAa,IAAI,aAAa,EAAE;IAIhC,KAAK,IAAI,IAAI;IAKb,IAAI,IAAI,MAAM;IAIrB,OAAO,CAAC,eAAe;CAQxB;AAuFD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAiBvD"}
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ var __webpack_require__ = {};
3
+ (()=>{
4
+ __webpack_require__.d = (exports1, definition)=>{
5
+ for(var key in definition)if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
6
+ enumerable: true,
7
+ get: definition[key]
8
+ });
9
+ };
10
+ })();
11
+ (()=>{
12
+ __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
13
+ })();
14
+ (()=>{
15
+ __webpack_require__.r = (exports1)=>{
16
+ if ('undefined' != typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
17
+ value: 'Module'
18
+ });
19
+ Object.defineProperty(exports1, '__esModule', {
20
+ value: true
21
+ });
22
+ };
23
+ })();
24
+ var __webpack_exports__ = {};
25
+ __webpack_require__.r(__webpack_exports__);
26
+ __webpack_require__.d(__webpack_exports__, {
27
+ stripReservedExceptionStepFields: ()=>stripReservedExceptionStepFields,
28
+ ExceptionStepsBuffer: ()=>ExceptionStepsBuffer,
29
+ EXCEPTION_STEP_INTERNAL_FIELDS: ()=>EXCEPTION_STEP_INTERNAL_FIELDS,
30
+ resolveExceptionStepsConfig: ()=>resolveExceptionStepsConfig,
31
+ getUtf8ByteLength: ()=>getUtf8ByteLength,
32
+ DEFAULT_EXCEPTION_STEPS_CONFIG: ()=>DEFAULT_EXCEPTION_STEPS_CONFIG
33
+ });
34
+ const index_js_namespaceObject = require("../utils/index.js");
35
+ const EXCEPTION_STEP_INTERNAL_FIELDS = {
36
+ MESSAGE: '$message',
37
+ TIMESTAMP: '$timestamp'
38
+ };
39
+ const RESERVED_EXCEPTION_STEP_KEYS = new Set([
40
+ EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE,
41
+ EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP
42
+ ]);
43
+ const DEFAULT_EXCEPTION_STEPS_CONFIG = {
44
+ enabled: true,
45
+ max_bytes: 32768
46
+ };
47
+ function resolveExceptionStepsConfig(config) {
48
+ if (!config) return {
49
+ ...DEFAULT_EXCEPTION_STEPS_CONFIG
50
+ };
51
+ return {
52
+ enabled: config.enabled ?? DEFAULT_EXCEPTION_STEPS_CONFIG.enabled,
53
+ max_bytes: normalizePositiveInteger(config.max_bytes, DEFAULT_EXCEPTION_STEPS_CONFIG.max_bytes)
54
+ };
55
+ }
56
+ function stripReservedExceptionStepFields(properties) {
57
+ if (!properties) return {
58
+ sanitizedProperties: {},
59
+ droppedKeys: []
60
+ };
61
+ const droppedKeys = [];
62
+ const sanitizedProperties = Object.keys(properties).reduce((acc, key)=>{
63
+ if (RESERVED_EXCEPTION_STEP_KEYS.has(key)) {
64
+ droppedKeys.push(key);
65
+ return acc;
66
+ }
67
+ acc[key] = properties[key];
68
+ return acc;
69
+ }, {});
70
+ return {
71
+ sanitizedProperties,
72
+ droppedKeys
73
+ };
74
+ }
75
+ class ExceptionStepsBuffer {
76
+ constructor(config){
77
+ this._entries = [];
78
+ this._totalBytes = 0;
79
+ this._config = resolveExceptionStepsConfig(config);
80
+ }
81
+ setConfig(config) {
82
+ this._config = resolveExceptionStepsConfig(config);
83
+ this._trimToMaxBytes();
84
+ }
85
+ add(step) {
86
+ const serialized = normalizeAndSerializeStep(step);
87
+ if (!serialized) return;
88
+ const bytes = getUtf8ByteLength(serialized.json);
89
+ if (bytes > this._config.max_bytes) return;
90
+ this._entries.push({
91
+ step: serialized.step,
92
+ bytes
93
+ });
94
+ this._totalBytes += bytes;
95
+ this._trimToMaxBytes();
96
+ }
97
+ getAttachable() {
98
+ return this._entries.map((e)=>e.step);
99
+ }
100
+ clear() {
101
+ this._entries = [];
102
+ this._totalBytes = 0;
103
+ }
104
+ size() {
105
+ return this._entries.length;
106
+ }
107
+ _trimToMaxBytes() {
108
+ while(this._totalBytes > this._config.max_bytes && this._entries.length > 0){
109
+ const evicted = this._entries.shift();
110
+ if (evicted) this._totalBytes -= evicted.bytes;
111
+ }
112
+ }
113
+ }
114
+ function normalizePositiveInteger(input, fallback) {
115
+ if (!(0, index_js_namespaceObject.isNumber)(input) || input === 1 / 0 || input === -1 / 0) return fallback;
116
+ const normalized = Math.floor(input);
117
+ if (normalized < 0) return fallback;
118
+ return normalized;
119
+ }
120
+ function normalizeAndSerializeStep(step) {
121
+ const json = safeStringify(step);
122
+ if (!json) return;
123
+ try {
124
+ const parsed = JSON.parse(json);
125
+ if (!(0, index_js_namespaceObject.isObject)(parsed)) return;
126
+ const parsedStep = parsed;
127
+ const message = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE];
128
+ const timestamp = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP];
129
+ if (!(0, index_js_namespaceObject.isString)(message) || 0 === message.trim().length) return;
130
+ if (!(0, index_js_namespaceObject.isString)(timestamp) && !(0, index_js_namespaceObject.isNumber)(timestamp)) return;
131
+ return {
132
+ step: parsedStep,
133
+ json
134
+ };
135
+ } catch {
136
+ return;
137
+ }
138
+ }
139
+ function safeStringify(value) {
140
+ const seen = new WeakSet();
141
+ try {
142
+ return JSON.stringify(value, (_key, replacementValue)=>{
143
+ if ('bigint' == typeof replacementValue) return replacementValue.toString();
144
+ if ('function' == typeof replacementValue || 'symbol' == typeof replacementValue) return;
145
+ if (replacementValue instanceof Date) return replacementValue.toISOString();
146
+ if (replacementValue instanceof Error) return {
147
+ name: replacementValue.name,
148
+ message: replacementValue.message,
149
+ stack: replacementValue.stack
150
+ };
151
+ if (replacementValue && 'object' == typeof replacementValue) {
152
+ if (seen.has(replacementValue)) return '[Circular]';
153
+ seen.add(replacementValue);
154
+ }
155
+ return replacementValue;
156
+ });
157
+ } catch {
158
+ return;
159
+ }
160
+ }
161
+ function getUtf8ByteLength(value) {
162
+ if ('undefined' != typeof TextEncoder) return new TextEncoder().encode(value).length;
163
+ const encoded = encodeURIComponent(value);
164
+ let byteLength = 0;
165
+ for(let i = 0; i < encoded.length; i++)if ('%' === encoded[i]) {
166
+ byteLength += 1;
167
+ i += 2;
168
+ } else byteLength += 1;
169
+ return byteLength;
170
+ }
171
+ exports.DEFAULT_EXCEPTION_STEPS_CONFIG = __webpack_exports__.DEFAULT_EXCEPTION_STEPS_CONFIG;
172
+ exports.EXCEPTION_STEP_INTERNAL_FIELDS = __webpack_exports__.EXCEPTION_STEP_INTERNAL_FIELDS;
173
+ exports.ExceptionStepsBuffer = __webpack_exports__.ExceptionStepsBuffer;
174
+ exports.getUtf8ByteLength = __webpack_exports__.getUtf8ByteLength;
175
+ exports.resolveExceptionStepsConfig = __webpack_exports__.resolveExceptionStepsConfig;
176
+ exports.stripReservedExceptionStepFields = __webpack_exports__.stripReservedExceptionStepFields;
177
+ for(var __webpack_i__ in __webpack_exports__)if (-1 === [
178
+ "DEFAULT_EXCEPTION_STEPS_CONFIG",
179
+ "EXCEPTION_STEP_INTERNAL_FIELDS",
180
+ "ExceptionStepsBuffer",
181
+ "getUtf8ByteLength",
182
+ "resolveExceptionStepsConfig",
183
+ "stripReservedExceptionStepFields"
184
+ ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
185
+ Object.defineProperty(exports, '__esModule', {
186
+ value: true
187
+ });
@@ -0,0 +1,138 @@
1
+ import { isNumber, isObject, isString } from "../utils/index.mjs";
2
+ const EXCEPTION_STEP_INTERNAL_FIELDS = {
3
+ MESSAGE: '$message',
4
+ TIMESTAMP: '$timestamp'
5
+ };
6
+ const RESERVED_EXCEPTION_STEP_KEYS = new Set([
7
+ EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE,
8
+ EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP
9
+ ]);
10
+ const DEFAULT_EXCEPTION_STEPS_CONFIG = {
11
+ enabled: true,
12
+ max_bytes: 32768
13
+ };
14
+ function resolveExceptionStepsConfig(config) {
15
+ if (!config) return {
16
+ ...DEFAULT_EXCEPTION_STEPS_CONFIG
17
+ };
18
+ return {
19
+ enabled: config.enabled ?? DEFAULT_EXCEPTION_STEPS_CONFIG.enabled,
20
+ max_bytes: normalizePositiveInteger(config.max_bytes, DEFAULT_EXCEPTION_STEPS_CONFIG.max_bytes)
21
+ };
22
+ }
23
+ function stripReservedExceptionStepFields(properties) {
24
+ if (!properties) return {
25
+ sanitizedProperties: {},
26
+ droppedKeys: []
27
+ };
28
+ const droppedKeys = [];
29
+ const sanitizedProperties = Object.keys(properties).reduce((acc, key)=>{
30
+ if (RESERVED_EXCEPTION_STEP_KEYS.has(key)) {
31
+ droppedKeys.push(key);
32
+ return acc;
33
+ }
34
+ acc[key] = properties[key];
35
+ return acc;
36
+ }, {});
37
+ return {
38
+ sanitizedProperties,
39
+ droppedKeys
40
+ };
41
+ }
42
+ class ExceptionStepsBuffer {
43
+ constructor(config){
44
+ this._entries = [];
45
+ this._totalBytes = 0;
46
+ this._config = resolveExceptionStepsConfig(config);
47
+ }
48
+ setConfig(config) {
49
+ this._config = resolveExceptionStepsConfig(config);
50
+ this._trimToMaxBytes();
51
+ }
52
+ add(step) {
53
+ const serialized = normalizeAndSerializeStep(step);
54
+ if (!serialized) return;
55
+ const bytes = getUtf8ByteLength(serialized.json);
56
+ if (bytes > this._config.max_bytes) return;
57
+ this._entries.push({
58
+ step: serialized.step,
59
+ bytes
60
+ });
61
+ this._totalBytes += bytes;
62
+ this._trimToMaxBytes();
63
+ }
64
+ getAttachable() {
65
+ return this._entries.map((e)=>e.step);
66
+ }
67
+ clear() {
68
+ this._entries = [];
69
+ this._totalBytes = 0;
70
+ }
71
+ size() {
72
+ return this._entries.length;
73
+ }
74
+ _trimToMaxBytes() {
75
+ while(this._totalBytes > this._config.max_bytes && this._entries.length > 0){
76
+ const evicted = this._entries.shift();
77
+ if (evicted) this._totalBytes -= evicted.bytes;
78
+ }
79
+ }
80
+ }
81
+ function normalizePositiveInteger(input, fallback) {
82
+ if (!isNumber(input) || input === 1 / 0 || input === -1 / 0) return fallback;
83
+ const normalized = Math.floor(input);
84
+ if (normalized < 0) return fallback;
85
+ return normalized;
86
+ }
87
+ function normalizeAndSerializeStep(step) {
88
+ const json = safeStringify(step);
89
+ if (!json) return;
90
+ try {
91
+ const parsed = JSON.parse(json);
92
+ if (!isObject(parsed)) return;
93
+ const parsedStep = parsed;
94
+ const message = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE];
95
+ const timestamp = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP];
96
+ if (!isString(message) || 0 === message.trim().length) return;
97
+ if (!isString(timestamp) && !isNumber(timestamp)) return;
98
+ return {
99
+ step: parsedStep,
100
+ json
101
+ };
102
+ } catch {
103
+ return;
104
+ }
105
+ }
106
+ function safeStringify(value) {
107
+ const seen = new WeakSet();
108
+ try {
109
+ return JSON.stringify(value, (_key, replacementValue)=>{
110
+ if ('bigint' == typeof replacementValue) return replacementValue.toString();
111
+ if ('function' == typeof replacementValue || 'symbol' == typeof replacementValue) return;
112
+ if (replacementValue instanceof Date) return replacementValue.toISOString();
113
+ if (replacementValue instanceof Error) return {
114
+ name: replacementValue.name,
115
+ message: replacementValue.message,
116
+ stack: replacementValue.stack
117
+ };
118
+ if (replacementValue && 'object' == typeof replacementValue) {
119
+ if (seen.has(replacementValue)) return '[Circular]';
120
+ seen.add(replacementValue);
121
+ }
122
+ return replacementValue;
123
+ });
124
+ } catch {
125
+ return;
126
+ }
127
+ }
128
+ function getUtf8ByteLength(value) {
129
+ if ('undefined' != typeof TextEncoder) return new TextEncoder().encode(value).length;
130
+ const encoded = encodeURIComponent(value);
131
+ let byteLength = 0;
132
+ for(let i = 0; i < encoded.length; i++)if ('%' === encoded[i]) {
133
+ byteLength += 1;
134
+ i += 2;
135
+ } else byteLength += 1;
136
+ return byteLength;
137
+ }
138
+ export { DEFAULT_EXCEPTION_STEPS_CONFIG, EXCEPTION_STEP_INTERNAL_FIELDS, ExceptionStepsBuffer, getUtf8ByteLength, resolveExceptionStepsConfig, stripReservedExceptionStepFields };
@@ -3,4 +3,5 @@ export type * from './types';
3
3
  export * from './parsers';
4
4
  export * from './coercers';
5
5
  export * from './utils';
6
+ export * from './exception-steps';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/error-tracking/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAA;AAC1C,mBAAmB,SAAS,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/error-tracking/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAA;AAC1C,mBAAmB,SAAS,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA"}
@@ -6,6 +6,9 @@ var __webpack_modules__ = {
6
6
  "./error-properties-builder": function(module) {
7
7
  module.exports = require("./error-properties-builder.js");
8
8
  },
9
+ "./exception-steps": function(module) {
10
+ module.exports = require("./exception-steps.js");
11
+ },
9
12
  "./parsers": function(module) {
10
13
  module.exports = require("./parsers/index.js");
11
14
  },
@@ -80,6 +83,12 @@ var __webpack_exports__ = {};
80
83
  return _utils__WEBPACK_IMPORTED_MODULE_3__[key];
81
84
  }).bind(0, __WEBPACK_IMPORT_KEY__);
82
85
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
86
+ var _exception_steps__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./exception-steps");
87
+ var __WEBPACK_REEXPORT_OBJECT__ = {};
88
+ for(var __WEBPACK_IMPORT_KEY__ in _exception_steps__WEBPACK_IMPORTED_MODULE_4__)if ("default" !== __WEBPACK_IMPORT_KEY__) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
89
+ return _exception_steps__WEBPACK_IMPORTED_MODULE_4__[key];
90
+ }).bind(0, __WEBPACK_IMPORT_KEY__);
91
+ __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
83
92
  })();
84
93
  for(var __webpack_i__ in __webpack_exports__)exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
85
94
  Object.defineProperty(exports, '__esModule', {
@@ -2,3 +2,4 @@ export * from "./error-properties-builder.mjs";
2
2
  export * from "./parsers/index.mjs";
3
3
  export * from "./coercers/index.mjs";
4
4
  export * from "./utils.mjs";
5
+ export * from "./exception-steps.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/core",
3
- "version": "1.25.3",
3
+ "version": "1.26.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,90 @@
1
+ import {
2
+ EXCEPTION_STEP_INTERNAL_FIELDS,
3
+ ExceptionStep,
4
+ ExceptionStepsBuffer,
5
+ getUtf8ByteLength,
6
+ resolveExceptionStepsConfig,
7
+ stripReservedExceptionStepFields,
8
+ } from './exception-steps'
9
+
10
+ describe('exception steps', () => {
11
+ describe('resolveExceptionStepsConfig', () => {
12
+ it('uses defaults when no config is passed', () => {
13
+ expect(resolveExceptionStepsConfig()).toEqual({
14
+ enabled: true,
15
+ max_bytes: 32768,
16
+ })
17
+ })
18
+
19
+ it('falls back to defaults for invalid values', () => {
20
+ expect(resolveExceptionStepsConfig({ max_bytes: Number.NaN })).toEqual({
21
+ enabled: true,
22
+ max_bytes: 32768,
23
+ })
24
+ })
25
+ })
26
+
27
+ describe('stripReservedExceptionStepFields', () => {
28
+ it('strips reserved fields and keeps custom properties', () => {
29
+ const result = stripReservedExceptionStepFields({
30
+ [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]: 'should-strip',
31
+ [EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]: 'should-strip',
32
+ custom: true,
33
+ })
34
+
35
+ expect(result).toEqual({
36
+ sanitizedProperties: { custom: true },
37
+ droppedKeys: [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE, EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP],
38
+ })
39
+ })
40
+ })
41
+
42
+ describe('ExceptionStepsBuffer', () => {
43
+ const makeStep = (message: string): ExceptionStep => ({
44
+ [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]: message,
45
+ [EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]: '2026-01-01T00:00:00.000Z',
46
+ })
47
+
48
+ const bytesOf = (step: ExceptionStep) => getUtf8ByteLength(JSON.stringify(step))
49
+
50
+ it.each([
51
+ {
52
+ desc: 'evicts oldest steps when max bytes are exceeded',
53
+ config: { max_bytes: bytesOf(makeStep('two')) + bytesOf(makeStep('three')) },
54
+ steps: [makeStep('one'), makeStep('two'), makeStep('three')],
55
+ expected: ['two', 'three'],
56
+ },
57
+ {
58
+ desc: 'keeps the most recent step when budget fits only one',
59
+ config: { max_bytes: bytesOf(makeStep('one')) },
60
+ steps: [makeStep('one'), makeStep('two')],
61
+ expected: ['two'],
62
+ },
63
+ {
64
+ desc: 'skips malformed steps that cannot be normalized',
65
+ config: { max_bytes: 10000 },
66
+ steps: [{ $message: '', $timestamp: '2026-01-01T00:00:00.000Z' } as ExceptionStep, makeStep('valid')],
67
+ expected: ['valid'],
68
+ },
69
+ {
70
+ desc: 'drops a step that exceeds the entire budget on its own',
71
+ config: { max_bytes: 10 },
72
+ steps: [makeStep('this message is way too long for the tiny budget')],
73
+ expected: [],
74
+ },
75
+ ])('$desc', ({ config, steps, expected }) => {
76
+ const buffer = new ExceptionStepsBuffer(config)
77
+ for (const step of steps) {
78
+ buffer.add(step)
79
+ }
80
+ expect(buffer.getAttachable().map((s) => s.$message)).toEqual(expected)
81
+ })
82
+
83
+ it('clears all steps', () => {
84
+ const buffer = new ExceptionStepsBuffer({ max_bytes: 10000 })
85
+ buffer.add(makeStep('one'))
86
+ buffer.clear()
87
+ expect(buffer.size()).toBe(0)
88
+ })
89
+ })
90
+ })
@@ -0,0 +1,225 @@
1
+ import { isArray, isNumber, isObject, isString } from '@/utils'
2
+
3
+ export const EXCEPTION_STEP_INTERNAL_FIELDS = {
4
+ MESSAGE: '$message',
5
+ TIMESTAMP: '$timestamp',
6
+ } as const
7
+
8
+ const RESERVED_EXCEPTION_STEP_KEYS = new Set<string>([
9
+ EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE,
10
+ EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP,
11
+ ])
12
+
13
+ export type ExceptionStep = {
14
+ [EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]: string
15
+ [EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]: string | number
16
+ [key: string]: unknown
17
+ }
18
+
19
+ /** NOTE: This type is also defined in `@posthog/types` (posthog-config.ts). Keep both in sync. */
20
+ export type ExceptionStepsConfig = {
21
+ enabled?: boolean
22
+ max_bytes?: number
23
+ }
24
+
25
+ export type ResolvedExceptionStepsConfig = {
26
+ enabled: boolean
27
+ max_bytes: number
28
+ }
29
+
30
+ export const DEFAULT_EXCEPTION_STEPS_CONFIG: ResolvedExceptionStepsConfig = {
31
+ enabled: true,
32
+ max_bytes: 32768, // ~32KB
33
+ }
34
+
35
+ export function resolveExceptionStepsConfig(config?: ExceptionStepsConfig | null): ResolvedExceptionStepsConfig {
36
+ if (!config) {
37
+ return { ...DEFAULT_EXCEPTION_STEPS_CONFIG }
38
+ }
39
+
40
+ return {
41
+ enabled: config.enabled ?? DEFAULT_EXCEPTION_STEPS_CONFIG.enabled,
42
+ max_bytes: normalizePositiveInteger(config.max_bytes, DEFAULT_EXCEPTION_STEPS_CONFIG.max_bytes),
43
+ }
44
+ }
45
+
46
+ export function stripReservedExceptionStepFields(properties?: Record<string, unknown> | null): {
47
+ sanitizedProperties: Record<string, unknown>
48
+ droppedKeys: string[]
49
+ } {
50
+ if (!properties) {
51
+ return { sanitizedProperties: {}, droppedKeys: [] }
52
+ }
53
+
54
+ const droppedKeys: string[] = []
55
+ const sanitizedProperties = Object.keys(properties).reduce<Record<string, unknown>>((acc, key) => {
56
+ if (RESERVED_EXCEPTION_STEP_KEYS.has(key)) {
57
+ droppedKeys.push(key)
58
+ return acc
59
+ }
60
+ acc[key] = properties[key]
61
+ return acc
62
+ }, {})
63
+
64
+ return {
65
+ sanitizedProperties,
66
+ droppedKeys,
67
+ }
68
+ }
69
+
70
+ export class ExceptionStepsBuffer {
71
+ private _entries: { step: ExceptionStep; bytes: number }[] = []
72
+ private _totalBytes: number = 0
73
+ private _config: ResolvedExceptionStepsConfig
74
+
75
+ constructor(config?: ExceptionStepsConfig | null) {
76
+ this._config = resolveExceptionStepsConfig(config)
77
+ }
78
+
79
+ public setConfig(config?: ExceptionStepsConfig | null): void {
80
+ this._config = resolveExceptionStepsConfig(config)
81
+ this._trimToMaxBytes()
82
+ }
83
+
84
+ public add(step: ExceptionStep): void {
85
+ const serialized = normalizeAndSerializeStep(step)
86
+ if (!serialized) {
87
+ return
88
+ }
89
+
90
+ const bytes = getUtf8ByteLength(serialized.json)
91
+ if (bytes > this._config.max_bytes) {
92
+ return
93
+ }
94
+
95
+ this._entries.push({ step: serialized.step, bytes })
96
+ this._totalBytes += bytes
97
+ this._trimToMaxBytes()
98
+ }
99
+
100
+ public getAttachable(): ExceptionStep[] {
101
+ return this._entries.map((e) => e.step)
102
+ }
103
+
104
+ public clear(): void {
105
+ this._entries = []
106
+ this._totalBytes = 0
107
+ }
108
+
109
+ public size(): number {
110
+ return this._entries.length
111
+ }
112
+
113
+ private _trimToMaxBytes(): void {
114
+ while (this._totalBytes > this._config.max_bytes && this._entries.length > 0) {
115
+ const evicted = this._entries.shift()
116
+ if (evicted) {
117
+ this._totalBytes -= evicted.bytes
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ function normalizePositiveInteger(input: number | undefined, fallback: number): number {
124
+ if (!isNumber(input) || input === Infinity || input === -Infinity) {
125
+ return fallback
126
+ }
127
+
128
+ const normalized = Math.floor(input)
129
+ if (normalized < 0) {
130
+ return fallback
131
+ }
132
+
133
+ return normalized
134
+ }
135
+
136
+ function normalizeAndSerializeStep(step: ExceptionStep): { step: ExceptionStep; json: string } | undefined {
137
+ const json = safeStringify(step)
138
+ if (!json) {
139
+ return undefined
140
+ }
141
+
142
+ try {
143
+ const parsed = JSON.parse(json)
144
+ if (!isObject(parsed)) {
145
+ return undefined
146
+ }
147
+
148
+ const parsedStep = parsed as Record<string, unknown>
149
+ const message = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.MESSAGE]
150
+ const timestamp = parsedStep[EXCEPTION_STEP_INTERNAL_FIELDS.TIMESTAMP]
151
+
152
+ if (!isString(message) || message.trim().length === 0) {
153
+ return undefined
154
+ }
155
+
156
+ if (!isString(timestamp) && !isNumber(timestamp)) {
157
+ return undefined
158
+ }
159
+
160
+ return {
161
+ step: parsedStep as ExceptionStep,
162
+ json,
163
+ }
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
168
+
169
+ function safeStringify(value: unknown): string | undefined {
170
+ const seen = new WeakSet<object>()
171
+
172
+ try {
173
+ return JSON.stringify(value, (_key, replacementValue: unknown) => {
174
+ if (typeof replacementValue === 'bigint') {
175
+ return replacementValue.toString()
176
+ }
177
+
178
+ if (typeof replacementValue === 'function' || typeof replacementValue === 'symbol') {
179
+ return undefined
180
+ }
181
+
182
+ if (replacementValue instanceof Date) {
183
+ return replacementValue.toISOString()
184
+ }
185
+
186
+ if (replacementValue instanceof Error) {
187
+ return {
188
+ name: replacementValue.name,
189
+ message: replacementValue.message,
190
+ stack: replacementValue.stack,
191
+ }
192
+ }
193
+
194
+ if (replacementValue && typeof replacementValue === 'object') {
195
+ if (seen.has(replacementValue)) {
196
+ return '[Circular]'
197
+ }
198
+ seen.add(replacementValue)
199
+ }
200
+
201
+ return replacementValue
202
+ })
203
+ } catch {
204
+ return undefined
205
+ }
206
+ }
207
+
208
+ export function getUtf8ByteLength(value: string): number {
209
+ if (typeof TextEncoder !== 'undefined') {
210
+ return new TextEncoder().encode(value).length
211
+ }
212
+
213
+ const encoded = encodeURIComponent(value)
214
+ let byteLength = 0
215
+ for (let i = 0; i < encoded.length; i++) {
216
+ if (encoded[i] === '%') {
217
+ byteLength += 1
218
+ i += 2
219
+ } else {
220
+ byteLength += 1
221
+ }
222
+ }
223
+
224
+ return byteLength
225
+ }
@@ -3,3 +3,4 @@ export type * from './types'
3
3
  export * from './parsers'
4
4
  export * from './coercers'
5
5
  export * from './utils'
6
+ export * from './exception-steps'