@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.
@@ -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;AAoDD;;;;;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"}
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"}
@@ -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();
@@ -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.48.11",
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.405.3"
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
- if (!(key in propertyValues)) {
189
- if (operator === 'is_not_set') return true
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 key in propertyValues
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
- const error = new Error(`Native gzip produced invalid output: ${reason}`)
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> => {
@@ -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
- this._flushPromise = null
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
- captureLog(options: CaptureLogOptions): void {
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(filtered, this._getContext(), this._logger)
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')
@@ -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 the current wall-clock time as a unix-nanos string.
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(Date.now()) + '000000'
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 now = timestampToUnixNano()
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: now,
344
- observedTimeUnixNano: now,
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 = new Error('Response body read timed out')
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 = new Error('Response body read timed out')
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 = new Error(`Request timed out after ${timeoutMs}ms`)
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)
@@ -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
+ })
@@ -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
- Math.trunc(Math.random() * 0x1_0000),
438
+ (Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 +
439
+ Math.trunc(Math.random() * 0x1_0000)) >>>
440
+ 0,
435
441
  };
436
442
  };
437
443