@posthog/core 1.48.11 → 1.49.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/featureFlagLocalEvaluation.js +4 -6
- package/dist/featureFlagLocalEvaluation.mjs +4 -6
- package/dist/gzip.d.ts.map +1 -1
- package/dist/gzip.js +2 -3
- package/dist/gzip.mjs +2 -3
- package/dist/logs/index.d.ts +18 -1
- package/dist/logs/index.d.ts.map +1 -1
- package/dist/logs/index.js +9 -4
- package/dist/logs/index.mjs +9 -4
- package/dist/logs/logs-utils.d.ts +1 -1
- package/dist/logs/logs-utils.d.ts.map +1 -1
- package/dist/logs/logs-utils.js +6 -6
- package/dist/logs/logs-utils.mjs +7 -7
- package/dist/posthog-core-stateless.d.ts.map +1 -1
- package/dist/posthog-core-stateless.js +3 -6
- package/dist/posthog-core-stateless.mjs +4 -7
- package/dist/utils/index.d.ts +12 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +24 -0
- package/dist/utils/index.mjs +13 -1
- package/dist/vendor/uuidv7.d.ts.map +1 -1
- package/dist/vendor/uuidv7.js +1 -1
- package/dist/vendor/uuidv7.mjs +1 -1
- package/package.json +2 -2
- package/src/featureFlagLocalEvaluation.ts +3 -3
- package/src/gzip.ts +2 -3
- package/src/logs/index.spec.ts +184 -0
- package/src/logs/index.ts +41 -4
- package/src/logs/logs-utils.spec.ts +24 -0
- package/src/logs/logs-utils.ts +13 -8
- package/src/posthog-core-stateless.ts +4 -6
- package/src/utils/index.ts +21 -0
- package/src/utils/named-error.spec.ts +36 -0
- package/src/vendor/uuidv7.spec.ts +26 -0
- package/src/vendor/uuidv7.ts +8 -2
package/dist/utils/index.mjs
CHANGED
|
@@ -16,6 +16,18 @@ function isValidUUID(value) {
|
|
|
16
16
|
function getEventUuid(uuid, generateUuid) {
|
|
17
17
|
return isValidUUID(uuid) ? uuid : generateUuid();
|
|
18
18
|
}
|
|
19
|
+
function createNamedError(name, message) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
try {
|
|
22
|
+
Object.defineProperty(error, 'name', {
|
|
23
|
+
value: name,
|
|
24
|
+
writable: true,
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true
|
|
27
|
+
});
|
|
28
|
+
} catch {}
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
19
31
|
function assert(truthyValue, message) {
|
|
20
32
|
if (!truthyValue || 'string' != typeof truthyValue || isEmpty(truthyValue)) throw new Error(message);
|
|
21
33
|
}
|
|
@@ -88,4 +100,4 @@ function allSettled(promises) {
|
|
|
88
100
|
reason
|
|
89
101
|
}))));
|
|
90
102
|
}
|
|
91
|
-
export { STRING_FORMAT, UUID_REGEX, allSettled, assert, currentISOTime, currentTimestamp, getEventUuid, getFetch, isPromise, isValidUUID, raceWithTimeout, removeTrailingSlash, retriable, safeSetTimeout, stripUrlHash, toJsonSafeValue };
|
|
103
|
+
export { STRING_FORMAT, UUID_REGEX, allSettled, assert, createNamedError, currentISOTime, currentTimestamp, getEventUuid, getFetch, isPromise, isValidUUID, raceWithTimeout, removeTrailingSlash, retriable, safeSetTimeout, stripUrlHash, toJsonSafeValue };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"uuidv7.d.ts","sourceRoot":"","sources":["../../src/vendor/uuidv7.ts"],"names":[],"mappings":"AAaA,iDAAiD;AACjD,qBAAa,IAAI;IAEK,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC;IADxD,4DAA4D;IAC5D,OAAO;IAEP;;;;;;;;OAQG;IACH,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,IAAI;IAQjD;;;;;;;;OAQG;IACH,MAAM,CAAC,YAAY,CACjB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,IAAI;IAsCP;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IA8ChC;;;OAGG;IACH,QAAQ,IAAI,MAAM;IAYlB;;;OAGG;IACH,KAAK,IAAI,MAAM;IASf,2EAA2E;IAC3E,MAAM,IAAI,MAAM;IAIhB;;;;;;;OAOG;IACH,UAAU,IACN,OAAO,GACP,QAAQ,GACR,SAAS,GACT,cAAc,GACd,KAAK,GACL,KAAK;IAiBT;;;OAGG;IACH,UAAU,IAAI,MAAM,GAAG,SAAS;IAIhC,qCAAqC;IACrC,KAAK,IAAI,IAAI;IAIb,uDAAuD;IACvD,MAAM,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO;IAI5B;;;OAGG;IACH,SAAS,CAAC,KAAK,EAAE,IAAI,GAAG,MAAM;CAS/B;AAED;;;;;;;;GAQG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,OAAO,CAAK;IAEpB,yDAAyD;IACzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAElD;;;;OAIG;gBACS,qBAAqB,CAAC,EAAE;QAClC,gDAAgD;QAChD,UAAU,IAAI,MAAM,CAAC;KACtB;IAID;;;;;;;;;;;;;OAaG;IACH,QAAQ,IAAI,IAAI;IAIhB;;;;;;;;;;;;OAYG;IACH,eAAe,IAAI,IAAI,GAAG,SAAS;IAInC;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,IAAI;IAUtE;;;;;;;;;;OAUG;IACH,mBAAmB,CACjB,QAAQ,EAAE,MAAM,EAChB,iBAAiB,EAAE,MAAM,GACxB,IAAI,GAAG,SAAS;IAqCnB,0DAA0D;IAC1D,OAAO,CAAC,YAAY;IAKpB;;;;OAIG;IACH,UAAU,IAAI,IAAI;CAanB;
|
|
1
|
+
{"version":3,"file":"uuidv7.d.ts","sourceRoot":"","sources":["../../src/vendor/uuidv7.ts"],"names":[],"mappings":"AAaA,iDAAiD;AACjD,qBAAa,IAAI;IAEK,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC;IADxD,4DAA4D;IAC5D,OAAO;IAEP;;;;;;;;OAQG;IACH,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,IAAI;IAQjD;;;;;;;;OAQG;IACH,MAAM,CAAC,YAAY,CACjB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,IAAI;IAsCP;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IA8ChC;;;OAGG;IACH,QAAQ,IAAI,MAAM;IAYlB;;;OAGG;IACH,KAAK,IAAI,MAAM;IASf,2EAA2E;IAC3E,MAAM,IAAI,MAAM;IAIhB;;;;;;;OAOG;IACH,UAAU,IACN,OAAO,GACP,QAAQ,GACR,SAAS,GACT,cAAc,GACd,KAAK,GACL,KAAK;IAiBT;;;OAGG;IACH,UAAU,IAAI,MAAM,GAAG,SAAS;IAIhC,qCAAqC;IACrC,KAAK,IAAI,IAAI;IAIb,uDAAuD;IACvD,MAAM,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO;IAI5B;;;OAGG;IACH,SAAS,CAAC,KAAK,EAAE,IAAI,GAAG,MAAM;CAS/B;AAED;;;;;;;;GAQG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,OAAO,CAAK;IAEpB,yDAAyD;IACzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAElD;;;;OAIG;gBACS,qBAAqB,CAAC,EAAE;QAClC,gDAAgD;QAChD,UAAU,IAAI,MAAM,CAAC;KACtB;IAID;;;;;;;;;;;;;OAaG;IACH,QAAQ,IAAI,IAAI;IAIhB;;;;;;;;;;;;OAYG;IACH,eAAe,IAAI,IAAI,GAAG,SAAS;IAInC;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,IAAI;IAUtE;;;;;;;;;;OAUG;IACH,mBAAmB,CACjB,QAAQ,EAAE,MAAM,EAChB,iBAAiB,EAAE,MAAM,GACxB,IAAI,GAAG,SAAS;IAqCnB,0DAA0D;IAC1D,OAAO,CAAC,YAAY;IAKpB;;;;OAIG;IACH,UAAU,IAAI,IAAI;CAanB;AA0DD;;;;;GAKG;AACH,eAAO,MAAM,MAAM,QAAO,MAAgC,CAAC;AAE3D,iCAAiC;AACjC,eAAO,MAAM,SAAS,QAAO,IAC4C,CAAC;AAE1E;;;;;GAKG;AACH,eAAO,MAAM,MAAM,QAAO,MAAgC,CAAC;AAE3D,iCAAiC;AACjC,eAAO,MAAM,SAAS,QAAO,IAC8C,CAAC"}
|
package/dist/vendor/uuidv7.js
CHANGED
|
@@ -193,7 +193,7 @@ class V7Generator {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
const getDefaultRandom = ()=>({
|
|
196
|
-
nextUint32: ()=>0x10000 * Math.trunc(0x10000 * Math.random()) + Math.trunc(0x10000 * Math.random())
|
|
196
|
+
nextUint32: ()=>0x10000 * Math.trunc(0x10000 * Math.random()) + Math.trunc(0x10000 * Math.random()) >>> 0
|
|
197
197
|
});
|
|
198
198
|
let defaultGenerator;
|
|
199
199
|
const uuidv7 = ()=>uuidv7obj().toString();
|
package/dist/vendor/uuidv7.mjs
CHANGED
|
@@ -160,7 +160,7 @@ class V7Generator {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
const getDefaultRandom = ()=>({
|
|
163
|
-
nextUint32: ()=>0x10000 * Math.trunc(0x10000 * Math.random()) + Math.trunc(0x10000 * Math.random())
|
|
163
|
+
nextUint32: ()=>0x10000 * Math.trunc(0x10000 * Math.random()) + Math.trunc(0x10000 * Math.random()) >>> 0
|
|
164
164
|
});
|
|
165
165
|
let defaultGenerator;
|
|
166
166
|
const uuidv7 = ()=>uuidv7obj().toString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.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.407.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@rslib/core": "0.10.6",
|
|
@@ -185,8 +185,8 @@ export function matchFeatureFlagProperty(
|
|
|
185
185
|
const operator = property.operator || 'exact'
|
|
186
186
|
const parsingPolicy = options.semverParsingPolicy ?? 'strict'
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
const hasProperty = Object.prototype.hasOwnProperty.call(propertyValues, key)
|
|
189
|
+
if (!hasProperty) {
|
|
190
190
|
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
|
|
191
191
|
} else if (operator === 'is_not_set') {
|
|
192
192
|
return false
|
|
@@ -219,7 +219,7 @@ export function matchFeatureFlagProperty(
|
|
|
219
219
|
case 'is_not':
|
|
220
220
|
return !computeExactMatch(value, overrideValue)
|
|
221
221
|
case 'is_set':
|
|
222
|
-
return
|
|
222
|
+
return true
|
|
223
223
|
case 'icontains':
|
|
224
224
|
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
225
225
|
case 'not_icontains':
|
package/src/gzip.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Compression } from './types'
|
|
2
|
+
import { createNamedError } from './utils'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Older browsers and some runtimes don't support this yet
|
|
@@ -88,9 +89,7 @@ const crc32 = (bytes: Uint8Array): number => {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
const throwNativeGzipValidationError = (reason: NativeGzipValidationReason): never => {
|
|
91
|
-
|
|
92
|
-
error.name = NATIVE_GZIP_VALIDATION_ERROR
|
|
93
|
-
throw error
|
|
92
|
+
throw createNamedError(NATIVE_GZIP_VALIDATION_ERROR, `Native gzip produced invalid output: ${reason}`)
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
const validateNativeGzip = async (compressed: Blob, inputBytes: Uint8Array): Promise<void> => {
|
package/src/logs/index.spec.ts
CHANGED
|
@@ -171,6 +171,167 @@ describe('PostHogLogs', () => {
|
|
|
171
171
|
expect(logs).toBeDefined()
|
|
172
172
|
})
|
|
173
173
|
|
|
174
|
+
describe('clearQueue', () => {
|
|
175
|
+
it('does not let an in-flight batch drop records captured after the clear', async () => {
|
|
176
|
+
let releaseSend: (v: any) => void = () => {}
|
|
177
|
+
const mockInstance = createMockInstance({
|
|
178
|
+
_sendLogsBatch: jest.fn(() => new Promise((resolve) => (releaseSend = resolve))),
|
|
179
|
+
})
|
|
180
|
+
const logs = new PostHogLogs(
|
|
181
|
+
mockInstance,
|
|
182
|
+
resolveForTest(),
|
|
183
|
+
logger,
|
|
184
|
+
getContextFor(mockInstance),
|
|
185
|
+
immediateOnReady
|
|
186
|
+
)
|
|
187
|
+
logs.captureLog({ body: 'before the clear' })
|
|
188
|
+
|
|
189
|
+
const flushing = logs.flush()
|
|
190
|
+
logs.clearQueue()
|
|
191
|
+
logs.captureLog({ body: 'captured after the clear' })
|
|
192
|
+
|
|
193
|
+
releaseSend({ kind: 'ok' })
|
|
194
|
+
await flushing
|
|
195
|
+
|
|
196
|
+
expect(readQueue(mockInstance).map((e) => e.record.body)).toEqual([{ stringValue: 'captured after the clear' }])
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('a 413 retry that lands after the queue is cleared', () => {
|
|
201
|
+
it('does not re-send records the clear purged', async () => {
|
|
202
|
+
const sentBodies: string[][] = []
|
|
203
|
+
let releaseSend: (v: any) => void = () => {}
|
|
204
|
+
const mockInstance = createMockInstance({
|
|
205
|
+
_sendLogsBatch: jest.fn((payload: any) => {
|
|
206
|
+
sentBodies.push(payload.resourceLogs[0].scopeLogs[0].logRecords.map((r: any) => r.body.stringValue))
|
|
207
|
+
// Only the first send is held open; any retry resolves at once so a
|
|
208
|
+
// regression fails on the assertion rather than by hanging the test.
|
|
209
|
+
return sentBodies.length === 1
|
|
210
|
+
? new Promise((resolve) => (releaseSend = resolve))
|
|
211
|
+
: Promise.resolve({ kind: 'ok' })
|
|
212
|
+
}),
|
|
213
|
+
})
|
|
214
|
+
const logs = new PostHogLogs(
|
|
215
|
+
mockInstance,
|
|
216
|
+
resolveForTest(),
|
|
217
|
+
logger,
|
|
218
|
+
getContextFor(mockInstance),
|
|
219
|
+
immediateOnReady
|
|
220
|
+
)
|
|
221
|
+
logs.captureLog({ body: 'purged one' })
|
|
222
|
+
logs.captureLog({ body: 'purged two' })
|
|
223
|
+
|
|
224
|
+
const flushing = logs.flush()
|
|
225
|
+
logs.clearQueue()
|
|
226
|
+
// A 413 makes `_flushInner` retry the same records with a smaller batch cap.
|
|
227
|
+
releaseSend({ kind: 'too-large' })
|
|
228
|
+
await flushing
|
|
229
|
+
|
|
230
|
+
expect(sentBodies).toEqual([['purged one', 'purged two']])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('a clear that lands between two batches of one flush', () => {
|
|
235
|
+
it('still advances the batch assembled after the clear', async () => {
|
|
236
|
+
const sent: string[][] = []
|
|
237
|
+
let releasePersist: (() => void) | null = null
|
|
238
|
+
const mockInstance = createMockInstance({
|
|
239
|
+
_sendLogsBatch: jest.fn((payload: any) => {
|
|
240
|
+
sent.push(payload.resourceLogs[0].scopeLogs[0].logRecords.map((r: any) => r.body.stringValue))
|
|
241
|
+
return Promise.resolve({ kind: 'ok' })
|
|
242
|
+
}),
|
|
243
|
+
})
|
|
244
|
+
const logs = new PostHogLogs(
|
|
245
|
+
mockInstance,
|
|
246
|
+
resolveForTest({ maxBatchRecordsPerPost: 1 }),
|
|
247
|
+
logger,
|
|
248
|
+
getContextFor(mockInstance),
|
|
249
|
+
immediateOnReady,
|
|
250
|
+
() => new Promise<void>((resolve) => (releasePersist = resolve))
|
|
251
|
+
)
|
|
252
|
+
logs.captureLog({ body: 'a' })
|
|
253
|
+
logs.captureLog({ body: 'b' })
|
|
254
|
+
|
|
255
|
+
const flushing = logs.flush()
|
|
256
|
+
// Let batch ['a'] send and park inside the persist await between batches.
|
|
257
|
+
for (let i = 0; i < 10; i++) {
|
|
258
|
+
await Promise.resolve()
|
|
259
|
+
}
|
|
260
|
+
logs.clearQueue()
|
|
261
|
+
logs.captureLog({ body: 'c' })
|
|
262
|
+
// Each batch installs a fresh persist gate; release whichever is pending until
|
|
263
|
+
// the flush drains.
|
|
264
|
+
for (let i = 0; i < 20; i++) {
|
|
265
|
+
const pending = releasePersist
|
|
266
|
+
releasePersist = null
|
|
267
|
+
pending?.()
|
|
268
|
+
await Promise.resolve()
|
|
269
|
+
}
|
|
270
|
+
await flushing
|
|
271
|
+
|
|
272
|
+
expect({ sent, queue: readQueue(mockInstance).map((e) => e.record.body.stringValue) }).toEqual({
|
|
273
|
+
sent: [['a'], ['c']],
|
|
274
|
+
queue: [],
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('reset during an in-flight flush', () => {
|
|
280
|
+
it('does not let an in-flight batch drop records captured after the reset', async () => {
|
|
281
|
+
let releaseSend: (v: any) => void = () => {}
|
|
282
|
+
const mockInstance = createMockInstance({
|
|
283
|
+
_sendLogsBatch: jest.fn(() => new Promise((resolve) => (releaseSend = resolve))),
|
|
284
|
+
})
|
|
285
|
+
const logs = new PostHogLogs(
|
|
286
|
+
mockInstance,
|
|
287
|
+
resolveForTest(),
|
|
288
|
+
logger,
|
|
289
|
+
getContextFor(mockInstance),
|
|
290
|
+
immediateOnReady
|
|
291
|
+
)
|
|
292
|
+
logs.captureLog({ body: 'before the reset' })
|
|
293
|
+
|
|
294
|
+
const flushing = logs.flush()
|
|
295
|
+
logs.clearQueue()
|
|
296
|
+
logs.reset()
|
|
297
|
+
logs.captureLog({ body: 'captured after the reset' })
|
|
298
|
+
|
|
299
|
+
releaseSend({ kind: 'ok' })
|
|
300
|
+
await flushing
|
|
301
|
+
|
|
302
|
+
expect(readQueue(mockInstance).map((e) => e.record.body)).toEqual([{ stringValue: 'captured after the reset' }])
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('does not let a flush started after the reset run alongside the in-flight one', async () => {
|
|
306
|
+
let releaseSend: (v: any) => void = () => {}
|
|
307
|
+
const mockInstance = createMockInstance({
|
|
308
|
+
_sendLogsBatch: jest.fn(() => new Promise((resolve) => (releaseSend = resolve))),
|
|
309
|
+
})
|
|
310
|
+
const logs = new PostHogLogs(
|
|
311
|
+
mockInstance,
|
|
312
|
+
resolveForTest(),
|
|
313
|
+
logger,
|
|
314
|
+
getContextFor(mockInstance),
|
|
315
|
+
immediateOnReady
|
|
316
|
+
)
|
|
317
|
+
logs.captureLog({ body: 'before the reset' })
|
|
318
|
+
|
|
319
|
+
const flushing = logs.flush()
|
|
320
|
+
logs.clearQueue()
|
|
321
|
+
logs.reset()
|
|
322
|
+
logs.captureLog({ body: 'captured after the reset' })
|
|
323
|
+
const second = logs.flush()
|
|
324
|
+
|
|
325
|
+
expect(mockInstance._sendLogsBatch).toHaveBeenCalledTimes(1)
|
|
326
|
+
|
|
327
|
+
releaseSend({ kind: 'ok' })
|
|
328
|
+
await flushing
|
|
329
|
+
await second
|
|
330
|
+
|
|
331
|
+
expect(readQueue(mockInstance).map((e) => e.record.body)).toEqual([{ stringValue: 'captured after the reset' }])
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
174
335
|
describe('captureLog', () => {
|
|
175
336
|
it('writes a record to the logs queue via setPersistedProperty', () => {
|
|
176
337
|
const logs = new PostHogLogs(
|
|
@@ -191,6 +352,29 @@ describe('PostHogLogs', () => {
|
|
|
191
352
|
)
|
|
192
353
|
})
|
|
193
354
|
|
|
355
|
+
it('stamps a record from `capturedAt` rather than live state', () => {
|
|
356
|
+
const logs = new PostHogLogs(
|
|
357
|
+
mockInstance,
|
|
358
|
+
resolveForTest(),
|
|
359
|
+
logger,
|
|
360
|
+
getContextFor(mockInstance),
|
|
361
|
+
immediateOnReady
|
|
362
|
+
)
|
|
363
|
+
const occurredAtMs = Date.now() - 5000
|
|
364
|
+
|
|
365
|
+
logs.captureLog(
|
|
366
|
+
{ body: 'buffered before identify' },
|
|
367
|
+
{ context: { distinctId: 'anon-1', sessionId: 'session-1' }, occurredAtMs }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
const [{ record }] = readQueue(mockInstance)
|
|
371
|
+
const attributes = Object.fromEntries(record.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
372
|
+
expect(attributes.posthogDistinctId).toBe('anon-1')
|
|
373
|
+
expect(attributes.sessionId).toBe('session-1')
|
|
374
|
+
expect(record.timeUnixNano).toBe(String(occurredAtMs) + '000000')
|
|
375
|
+
expect(record.observedTimeUnixNano).toBe(record.timeUnixNano)
|
|
376
|
+
})
|
|
377
|
+
|
|
194
378
|
it('maps severity levels correctly', () => {
|
|
195
379
|
const logs = new PostHogLogs(
|
|
196
380
|
mockInstance,
|
package/src/logs/index.ts
CHANGED
|
@@ -24,6 +24,9 @@ export class PostHogLogs {
|
|
|
24
24
|
// Head records evicted (FIFO) while a batch is in flight; the queue-advance
|
|
25
25
|
// subtracts these so it drops the sent records, not ones captured mid-send.
|
|
26
26
|
private _evictedSinceAdvance = 0
|
|
27
|
+
// A batch captures this when it is assembled, so it can tell that the records it is
|
|
28
|
+
// holding no longer correspond to anything queued.
|
|
29
|
+
private _queueGeneration = 0
|
|
27
30
|
// Consecutive failed flushes; drives exponential backoff on the retry timer.
|
|
28
31
|
// A successful flush resets it to 0.
|
|
29
32
|
private _consecutiveFlushFailures = 0
|
|
@@ -65,17 +68,28 @@ export class PostHogLogs {
|
|
|
65
68
|
this._maxLogsPerInterval = _config.maxLogsPerInterval
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Drops every queued record. A batch this instance has in flight is retired with
|
|
73
|
+
* them, so it can neither re-send what was just purged nor advance past records
|
|
74
|
+
* captured after this call.
|
|
75
|
+
*/
|
|
76
|
+
clearQueue(): void {
|
|
77
|
+
this._queueGeneration++
|
|
78
|
+
this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, [])
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/**
|
|
69
82
|
* Clears the flush timer and rate-cap state. The host owns the record queue
|
|
70
83
|
* and clears it separately (the browser empties its in-memory store).
|
|
71
84
|
*/
|
|
72
85
|
reset(): void {
|
|
73
86
|
this._clearFlushTimer()
|
|
74
|
-
|
|
87
|
+
// `_flushPromise` is deliberately left alone: clearing it would let a second flush
|
|
88
|
+
// run alongside the in-flight one. Retiring that flush is `clearQueue`'s job,
|
|
89
|
+
// because the queue it holds belongs to the host, not to this state.
|
|
75
90
|
this._intervalWindowStart = 0
|
|
76
91
|
this._intervalLogCount = 0
|
|
77
92
|
this._droppedWarned = false
|
|
78
|
-
this._evictedSinceAdvance = 0
|
|
79
93
|
this._consecutiveFlushFailures = 0
|
|
80
94
|
this._maxBatchRecordsPerPost = this._config.maxBatchRecordsPerPost
|
|
81
95
|
}
|
|
@@ -88,7 +102,14 @@ export class PostHogLogs {
|
|
|
88
102
|
this._flushInBackground()
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Captures one log record.
|
|
107
|
+
*
|
|
108
|
+
* @param capturedAt State snapshotted when the event occurred, for a caller that
|
|
109
|
+
* observes an event earlier than it can build the record. Defaults to the identity,
|
|
110
|
+
* session and flags as of now.
|
|
111
|
+
*/
|
|
112
|
+
captureLog(options: CaptureLogOptions, capturedAt?: { context?: LogSdkContext; occurredAtMs?: number }): void {
|
|
92
113
|
if (this._instance.isDisabled) {
|
|
93
114
|
return
|
|
94
115
|
}
|
|
@@ -119,7 +140,12 @@ export class PostHogLogs {
|
|
|
119
140
|
// Build before deferring so attributes reflect state at capture time, not
|
|
120
141
|
// at drain time (identity/session changes between capture and drain must
|
|
121
142
|
// not corrupt recorded attributes).
|
|
122
|
-
const record = buildOtlpLogRecord(
|
|
143
|
+
const record = buildOtlpLogRecord(
|
|
144
|
+
filtered,
|
|
145
|
+
capturedAt?.context ?? this._getContext(),
|
|
146
|
+
this._logger,
|
|
147
|
+
capturedAt?.occurredAtMs
|
|
148
|
+
)
|
|
123
149
|
const entry: BufferedLogEntry = { record }
|
|
124
150
|
|
|
125
151
|
this._onReady(() => this._enqueue(entry))
|
|
@@ -233,6 +259,10 @@ export class PostHogLogs {
|
|
|
233
259
|
let sentCount = 0
|
|
234
260
|
|
|
235
261
|
while (queue.length > 0 && sentCount < originalQueueLength) {
|
|
262
|
+
// Captured per batch, not per flush: a clear that lands between batches leaves
|
|
263
|
+
// the next one assembled from an already-fresh queue, so that batch advances
|
|
264
|
+
// normally instead of being retired for a purge it never held.
|
|
265
|
+
const generation = this._queueGeneration
|
|
236
266
|
// Reset per batch so the advance below counts only evictions during THIS
|
|
237
267
|
// batch's send. Evictions during the persist await or between iterations
|
|
238
268
|
// are already reflected in the next iteration's queue read, so they must
|
|
@@ -252,6 +282,13 @@ export class PostHogLogs {
|
|
|
252
282
|
|
|
253
283
|
const outcome = await this._instance._sendLogsBatch(payload)
|
|
254
284
|
|
|
285
|
+
if (this._queueGeneration !== generation) {
|
|
286
|
+
// The queue was cleared while this batch was in flight. `queue` is a stale local
|
|
287
|
+
// copy, so retrying it would re-send purged records and advancing it would slice
|
|
288
|
+
// records captured since. Retire the batch instead.
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
if (outcome.kind === 'too-large' && batch.length > 1) {
|
|
256
293
|
this._maxBatchRecordsPerPost = Math.max(1, Math.floor(batch.length / 2))
|
|
257
294
|
this._logger.warn(
|
|
@@ -467,6 +467,30 @@ describe('logs-utils', () => {
|
|
|
467
467
|
expect(record.observedTimeUnixNano).toBe(record.timeUnixNano)
|
|
468
468
|
})
|
|
469
469
|
|
|
470
|
+
it('stamps both timestamps from the event time when the record is built late', () => {
|
|
471
|
+
const occurredAtMs = Date.now() - 5000
|
|
472
|
+
const record = buildOtlpLogRecord({ body: 'buffered' }, minimalSdkContext, undefined, occurredAtMs)
|
|
473
|
+
|
|
474
|
+
expect(record.timeUnixNano).toBe(String(occurredAtMs) + '000000')
|
|
475
|
+
// The logs spec requires the client to keep the two equal.
|
|
476
|
+
expect(record.observedTimeUnixNano).toBe(record.timeUnixNano)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it.each([undefined, NaN, 'nope' as unknown as number])(
|
|
480
|
+
'falls back to now when the event time is %p',
|
|
481
|
+
(occurredAtMs) => {
|
|
482
|
+
const before = Date.now()
|
|
483
|
+
const record = buildOtlpLogRecord({ body: 'x' }, minimalSdkContext, undefined, occurredAtMs)
|
|
484
|
+
// Divide and the 19-digit nanosecond value loses its last digits to float.
|
|
485
|
+
const stampedMs = Number(record.timeUnixNano.slice(0, -6))
|
|
486
|
+
|
|
487
|
+
expect(record.timeUnixNano).toMatch(/^\d+000000$/)
|
|
488
|
+
expect(stampedMs).toBeGreaterThanOrEqual(before)
|
|
489
|
+
expect(stampedMs).toBeLessThanOrEqual(Date.now())
|
|
490
|
+
expect(record.observedTimeUnixNano).toBe(record.timeUnixNano)
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
470
494
|
it('maps severity levels correctly', () => {
|
|
471
495
|
const record = buildOtlpLogRecord({ body: 'test', level: 'error' }, minimalSdkContext)
|
|
472
496
|
expect(record.severityText).toBe('ERROR')
|
package/src/logs/logs-utils.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from '@posthog/types'
|
|
12
12
|
import type { Logger } from '../types'
|
|
13
13
|
import type { LogSdkContext, ResolvedPostHogLogsConfig } from './types'
|
|
14
|
-
import { isArray, isBoolean, isNull, isNullish, isUndefined } from '../utils'
|
|
14
|
+
import { isArray, isBoolean, isNull, isNullish, isNumber, isUndefined } from '../utils'
|
|
15
15
|
import {
|
|
16
16
|
CIRCULAR_VALUE,
|
|
17
17
|
FUNCTION_VALUE,
|
|
@@ -245,14 +245,14 @@ function encodeKeyValueList(
|
|
|
245
245
|
// ============================================================================
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* Returns
|
|
248
|
+
* Returns `timestampMs` (default: now) as a unix-nanos string.
|
|
249
249
|
*
|
|
250
250
|
* OTLP requires nanoseconds as a string (uint64 doesn't fit in JS Number).
|
|
251
251
|
* `Date.now() * 1_000_000` would exceed Number.MAX_SAFE_INTEGER, so we
|
|
252
252
|
* concatenate instead of multiplying.
|
|
253
253
|
*/
|
|
254
|
-
function timestampToUnixNano(): string {
|
|
255
|
-
return String(
|
|
254
|
+
function timestampToUnixNano(timestampMs: number = Date.now()): string {
|
|
255
|
+
return String(timestampMs) + '000000'
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/**
|
|
@@ -264,6 +264,10 @@ function timestampToUnixNano(): string {
|
|
|
264
264
|
* `screenName` / `appState`), so a missing field never adds a stray attribute.
|
|
265
265
|
*
|
|
266
266
|
* User-provided `options.attributes` always wins on conflicts.
|
|
267
|
+
*
|
|
268
|
+
* `occurredAtMs` is when the event happened, for a host that records an event earlier
|
|
269
|
+
* than it can build the record; it stamps both OTLP timestamps, which the logs spec
|
|
270
|
+
* requires to be equal.
|
|
267
271
|
*/
|
|
268
272
|
// `body` is only typed as a string. An untyped caller — or a `beforeSend` that
|
|
269
273
|
// rewrites it — can pass anything, and `String()` throws on a value whose
|
|
@@ -279,11 +283,12 @@ function encodeBody(body: string): string {
|
|
|
279
283
|
export function buildOtlpLogRecord(
|
|
280
284
|
options: CaptureLogOptions,
|
|
281
285
|
sdkContext: LogSdkContext,
|
|
282
|
-
logger?: Logger
|
|
286
|
+
logger?: Logger,
|
|
287
|
+
occurredAtMs?: number
|
|
283
288
|
): OtlpLogRecord {
|
|
284
289
|
const level: LogSeverityLevel = options.level || 'info'
|
|
285
290
|
const { text: severityText, number: severityNumber } = OTLP_SEVERITY_MAP[level] || DEFAULT_OTLP_SEVERITY
|
|
286
|
-
const
|
|
291
|
+
const eventTimeNano = timestampToUnixNano(isNumber(occurredAtMs) ? occurredAtMs : undefined)
|
|
287
292
|
|
|
288
293
|
const autoAttributes: Record<string, LogAttributeValue> = {}
|
|
289
294
|
|
|
@@ -340,8 +345,8 @@ export function buildOtlpLogRecord(
|
|
|
340
345
|
}
|
|
341
346
|
|
|
342
347
|
const record: OtlpLogRecord = {
|
|
343
|
-
timeUnixNano:
|
|
344
|
-
observedTimeUnixNano:
|
|
348
|
+
timeUnixNano: eventTimeNano,
|
|
349
|
+
observedTimeUnixNano: eventTimeNano,
|
|
345
350
|
severityNumber,
|
|
346
351
|
severityText,
|
|
347
352
|
body: { stringValue: encodeBody(options.body) },
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from './types'
|
|
28
28
|
import {
|
|
29
29
|
allSettled,
|
|
30
|
+
createNamedError,
|
|
30
31
|
currentISOTime,
|
|
31
32
|
PromiseQueue,
|
|
32
33
|
removeTrailingSlash,
|
|
@@ -76,16 +77,14 @@ class PostHogFetchHttpError extends Error {
|
|
|
76
77
|
if (!this.responseBodyTextPromise) {
|
|
77
78
|
if (Date.now() >= this.responseBodyDeadline) {
|
|
78
79
|
this._bodyReadTimedOut = true
|
|
79
|
-
const timeoutError =
|
|
80
|
-
timeoutError.name = 'AbortError'
|
|
80
|
+
const timeoutError = createNamedError('AbortError', 'Response body read timed out')
|
|
81
81
|
this.cancelResponseBody(timeoutError)
|
|
82
82
|
this.responseBodyTextPromise = Promise.reject(timeoutError)
|
|
83
83
|
} else {
|
|
84
84
|
const responseBodyTimeout = new Promise<never>((_resolve, reject) => {
|
|
85
85
|
this.responseBodyTimer = safeSetTimeout(() => {
|
|
86
86
|
this._bodyReadTimedOut = true
|
|
87
|
-
const timeoutError =
|
|
88
|
-
timeoutError.name = 'AbortError'
|
|
87
|
+
const timeoutError = createNamedError('AbortError', 'Response body read timed out')
|
|
89
88
|
// Reject first so the timeout remains the diagnostic if abort rejects the body read synchronously.
|
|
90
89
|
reject(timeoutError)
|
|
91
90
|
this.cancelResponseBody(timeoutError)
|
|
@@ -1768,8 +1767,7 @@ export abstract class PostHogCoreStateless {
|
|
|
1768
1767
|
// relying only on standards-compliant AbortSignal behavior.
|
|
1769
1768
|
const deadline = new Promise<never>((_resolve, reject) => {
|
|
1770
1769
|
timer = safeSetTimeout(() => {
|
|
1771
|
-
const timeoutError =
|
|
1772
|
-
timeoutError.name = 'AbortError'
|
|
1770
|
+
const timeoutError = createNamedError('AbortError', `Request timed out after ${timeoutMs}ms`)
|
|
1773
1771
|
// Reject first so this error wins if an abort-aware injected fetch rejects synchronously.
|
|
1774
1772
|
reject(timeoutError)
|
|
1775
1773
|
ctrl.abort(timeoutError)
|
package/src/utils/index.ts
CHANGED
|
@@ -25,6 +25,27 @@ export function getEventUuid(uuid: unknown, generateUuid: () => string): string
|
|
|
25
25
|
return isValidUUID(uuid) ? uuid : generateUuid()
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Creates an `Error` with the given `name`, in a way that also holds on pages where a browser
|
|
30
|
+
* extension has made `Error.prototype.name` non-writable. A plain `error.name = ...` throws
|
|
31
|
+
* there in strict mode, and swallowing that throw leaves the name as `Error`. Defining an own
|
|
32
|
+
* property on the instance shadows the prototype property instead. The descriptor matches what
|
|
33
|
+
* a plain assignment produces, so the resulting error is unchanged everywhere else.
|
|
34
|
+
*
|
|
35
|
+
* @param name the value for `error.name`, e.g. `'AbortError'`
|
|
36
|
+
* @param message the error message
|
|
37
|
+
* @internal Exposed for cross-package use within this SDK; not part of the stable public API.
|
|
38
|
+
*/
|
|
39
|
+
export function createNamedError(name: string, message?: string): Error {
|
|
40
|
+
const error = new Error(message)
|
|
41
|
+
try {
|
|
42
|
+
Object.defineProperty(error, 'name', { value: name, writable: true, enumerable: true, configurable: true })
|
|
43
|
+
} catch {
|
|
44
|
+
// a page hostile enough to harden `Error.prototype` can also patch `Object.defineProperty`
|
|
45
|
+
}
|
|
46
|
+
return error
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
export function assert(truthyValue: any, message: string): void {
|
|
29
50
|
if (!truthyValue || typeof truthyValue !== 'string' || isEmpty(truthyValue)) {
|
|
30
51
|
throw new Error(message)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createNamedError } from './index'
|
|
2
|
+
|
|
3
|
+
describe('createNamedError', () => {
|
|
4
|
+
it('sets the name and message', () => {
|
|
5
|
+
const error = createNamedError('AbortError', 'timed out')
|
|
6
|
+
expect(error.name).toBe('AbortError')
|
|
7
|
+
expect(error.message).toBe('timed out')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('defines name with the same descriptor a plain assignment would produce', () => {
|
|
11
|
+
const error = createNamedError('AbortError', 'timed out')
|
|
12
|
+
expect(Object.getOwnPropertyDescriptor(error, 'name')).toEqual({
|
|
13
|
+
value: 'AbortError',
|
|
14
|
+
writable: true,
|
|
15
|
+
enumerable: true,
|
|
16
|
+
configurable: true,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('preserves the name when Error.prototype.name is non-writable', () => {
|
|
21
|
+
const descriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'name') ?? {
|
|
22
|
+
value: 'Error',
|
|
23
|
+
writable: true,
|
|
24
|
+
enumerable: false,
|
|
25
|
+
configurable: true,
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(Error.prototype, 'name', { value: 'Error', writable: false, configurable: true })
|
|
28
|
+
try {
|
|
29
|
+
const error = createNamedError('AbortError', 'timed out')
|
|
30
|
+
expect(error.name).toBe('AbortError')
|
|
31
|
+
expect(error.message).toBe('timed out')
|
|
32
|
+
} finally {
|
|
33
|
+
Object.defineProperty(Error.prototype, 'name', descriptor)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { V7Generator } from './uuidv7'
|
|
2
|
+
|
|
3
|
+
describe('uuidv7 default RNG', () => {
|
|
4
|
+
const realRandom = Math.random
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
Math.random = realRandom
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
['exactly 1.0', 1.0],
|
|
12
|
+
['greater than 1', 1.5],
|
|
13
|
+
['NaN', NaN],
|
|
14
|
+
])('does not throw when Math.random() returns %s', (_label, value) => {
|
|
15
|
+
Math.random = () => value
|
|
16
|
+
const generator = new V7Generator()
|
|
17
|
+
const uuid = generator.generate().toString()
|
|
18
|
+
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('still generates well-formed v7 UUIDs with the real Math.random', () => {
|
|
22
|
+
const generator = new V7Generator()
|
|
23
|
+
const uuid = generator.generate().toString()
|
|
24
|
+
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/vendor/uuidv7.ts
CHANGED
|
@@ -429,9 +429,15 @@ const getDefaultRandom = (): { nextUint32(): number } => {
|
|
|
429
429
|
// };
|
|
430
430
|
// }
|
|
431
431
|
return {
|
|
432
|
+
// Clamp to a valid uint32: a nonconformant Math.random() that returns >= 1 or NaN
|
|
433
|
+
// (e.g. Hermes on Android implements Math.random with C++ std::uniform_real_distribution,
|
|
434
|
+
// which is documented to occasionally return its upper bound) would otherwise overflow the
|
|
435
|
+
// field ranges and make fromFieldsV7 throw `RangeError: invalid field value` on every
|
|
436
|
+
// generate call, crashing React Native apps during the internal event-queue flush.
|
|
432
437
|
nextUint32: (): number =>
|
|
433
|
-
Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 +
|
|
434
|
-
|
|
438
|
+
(Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 +
|
|
439
|
+
Math.trunc(Math.random() * 0x1_0000)) >>>
|
|
440
|
+
0,
|
|
435
441
|
};
|
|
436
442
|
};
|
|
437
443
|
|