@genn-inc/cluebase-frontend-sdk 0.0.2 → 0.0.6

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.
Files changed (76) hide show
  1. package/README.md +21 -0
  2. package/dist/_generated/{identity-pii-policy.d.ts → identity-pii-policy-text-contains.d.ts} +1 -1
  3. package/dist/_generated/{identity-pii-policy.js → identity-pii-policy-text-contains.js} +1 -1
  4. package/dist/_generated/identity-pii-policy-whole-value.d.ts +37 -0
  5. package/dist/_generated/identity-pii-policy-whole-value.js +106 -0
  6. package/dist/_generated/otel-span-conversion.js +1 -9
  7. package/dist/context/action-context.js +27 -7
  8. package/dist/context/browser-storage.js +1 -2
  9. package/dist/context/environment.js +5 -3
  10. package/dist/context/identity.js +5 -9
  11. package/dist/context/organization.d.ts +1 -1
  12. package/dist/context/organization.js +6 -8
  13. package/dist/context/session.js +6 -8
  14. package/dist/context/tab.js +8 -12
  15. package/dist/context/trace.js +2 -1
  16. package/dist/exporter/batch.d.ts +3 -1
  17. package/dist/exporter/batch.js +6 -10
  18. package/dist/exporter/browser-outbox-records.d.ts +1 -1
  19. package/dist/exporter/browser-outbox-records.js +15 -6
  20. package/dist/exporter/browser-outbox.d.ts +5 -2
  21. package/dist/exporter/browser-outbox.js +48 -11
  22. package/dist/exporter/client.d.ts +2 -2
  23. package/dist/exporter/client.js +15 -30
  24. package/dist/exporter/delivery-response-contract.js +67 -17
  25. package/dist/exporter/delivery-response.d.ts +3 -1
  26. package/dist/exporter/delivery-response.js +31 -20
  27. package/dist/exporter/diagnostics.d.ts +1 -1
  28. package/dist/exporter/diagnostics.js +4 -1
  29. package/dist/exporter/event-id.js +1 -3
  30. package/dist/exporter/frontend-resource.js +5 -4
  31. package/dist/exporter/observation-source-event-exporter.d.ts +1 -0
  32. package/dist/exporter/observation-source-event-exporter.js +5 -2
  33. package/dist/exporter/path-template.js +3 -1
  34. package/dist/exporter/raw-event-to-source-signal.js +4 -2
  35. package/dist/exporter/sanitize.d.ts +3 -2
  36. package/dist/exporter/sanitize.js +4 -3
  37. package/dist/exporter/sdk-core-wasm.d.ts +5 -0
  38. package/dist/exporter/sdk-core-wasm.js +18 -0
  39. package/dist/exporter/source-event.d.ts +1 -0
  40. package/dist/exporter/span-to-source-signal.d.ts +1 -1
  41. package/dist/exporter/span-to-source-signal.js +122 -82
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.js +23 -11
  44. package/dist/instrumentation/action.js +9 -8
  45. package/dist/instrumentation/dom.js +13 -21
  46. package/dist/instrumentation/drag.js +0 -3
  47. package/dist/instrumentation/error.js +3 -1
  48. package/dist/instrumentation/form.js +5 -6
  49. package/dist/instrumentation/input.js +4 -8
  50. package/dist/instrumentation/location.js +3 -1
  51. package/dist/instrumentation/network-request.js +1 -1
  52. package/dist/instrumentation/network-span-attributes.js +2 -2
  53. package/dist/instrumentation/network.js +7 -3
  54. package/dist/instrumentation/performance.js +1 -2
  55. package/dist/internal/internal-log.js +2 -1
  56. package/dist/provider/_version.d.ts +1 -1
  57. package/dist/provider/_version.js +1 -1
  58. package/dist/provider/config.js +0 -1
  59. package/dist/provider/constants.d.ts +1 -1
  60. package/dist/provider/constants.js +0 -1
  61. package/dist/provider/sdk-error.d.ts +7 -0
  62. package/dist/provider/sdk-error.js +48 -0
  63. package/dist/provider/sdk-event-pipeline.d.ts +2 -1
  64. package/dist/provider/sdk-event-pipeline.js +6 -3
  65. package/dist/provider/sdk-input-normalization.js +2 -2
  66. package/dist/provider/sdk-otel-pipeline.js +5 -4
  67. package/dist/provider/sdk-runtime-surfaces.d.ts +15 -12
  68. package/dist/provider/sdk-runtime-surfaces.js +8 -7
  69. package/dist/provider/sdk.js +81 -12
  70. package/dist/provider/types.d.ts +6 -5
  71. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm.d.ts +41 -0
  72. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm.js +267 -0
  73. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm_bg.wasm +0 -0
  74. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm_bg.wasm.d.ts +12 -0
  75. package/dist/wasm/pkg-web/package.json +15 -0
  76. package/package.json +10 -3
@@ -188,7 +188,9 @@ export const withoutChecksum = (record) => {
188
188
  export const parseRecord = (raw, projectHash) => {
189
189
  try {
190
190
  const value = JSON.parse(raw);
191
- if (!isRecord(value) || value.storageVersion !== 2 || value.projectHash !== projectHash)
191
+ if (!isRecord(value) ||
192
+ value.storageVersion !== 2 ||
193
+ value.projectHash !== projectHash)
192
194
  return null;
193
195
  const checksumSha256 = value.checksumSha256;
194
196
  if (typeof checksumSha256 !== "string")
@@ -204,11 +206,14 @@ export const parseRecord = (raw, projectHash) => {
204
206
  };
205
207
  const isValidRecord = (value) => {
206
208
  const base = typeof value.createdAt === "string" &&
207
- (typeof value.recordVerifierSha256 === "string" || value.recordVerifierSha256 === null);
209
+ (typeof value.recordVerifierSha256 === "string" ||
210
+ value.recordVerifierSha256 === null);
208
211
  if (!base)
209
212
  return false;
210
213
  if (value.kind === "consent") {
211
- return ((value.state === "granted" || value.state === "denied" || value.state === "unknown") &&
214
+ return ((value.state === "granted" ||
215
+ value.state === "denied" ||
216
+ value.state === "unknown") &&
212
217
  typeof value.collectionGeneration === "string" &&
213
218
  typeof value.legacyImportBlocked === "boolean" &&
214
219
  typeof value.updatedAt === "string" &&
@@ -225,7 +230,8 @@ const isValidRecord = (value) => {
225
230
  isBrowserSourceSignalEvent(value.event));
226
231
  }
227
232
  if (value.kind === "legacy_tombstone") {
228
- return typeof value.eventKeyHash === "string" && typeof value.importedAt === "string";
233
+ return (typeof value.eventKeyHash === "string" &&
234
+ typeof value.importedAt === "string");
229
235
  }
230
236
  if (value.kind === "pending" || value.kind === "split_plan") {
231
237
  if (typeof value.batchId !== "string" ||
@@ -291,7 +297,8 @@ export const draftRecordKey = (prefix, eventKeyHash) => `${prefix}draft:${eventK
291
297
  export const pendingRecordKey = (prefix, batchId) => `${prefix}pending:${keyHash(batchId)}`;
292
298
  export const tombstoneRecordKey = (prefix, eventKeyHash) => `${prefix}legacy-tombstone:${eventKeyHash}`;
293
299
  export const browserEventsByteLength = (events) => events.reduce((total, event) => total + new TextEncoder().encode(JSON.stringify(event)).byteLength, 0);
294
- export const outboxEventCount = (drafts, pending) => drafts.length + pending.reduce((count, record) => count + record.events.length, 0);
300
+ export const outboxEventCount = (drafts, pending) => drafts.length +
301
+ pending.reduce((count, record) => count + record.events.length, 0);
295
302
  export const hasLegacyTombstone = (draft, tombstones) => tombstones.some((record) => record.eventKeyHash === draft.eventKeyHash &&
296
303
  record.recordVerifierSha256 === draft.recordVerifierSha256);
297
304
  export const createGeneration = () => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
@@ -299,7 +306,9 @@ export const createGeneration = () => typeof crypto !== "undefined" && typeof cr
299
306
  : `${Date.now()}-${Math.random()}`;
300
307
  export const readStorage = () => {
301
308
  try {
302
- return typeof localStorage === "undefined" ? null : localStorage;
309
+ return typeof localStorage === "undefined"
310
+ ? null
311
+ : localStorage;
303
312
  }
304
313
  catch {
305
314
  return null;
@@ -1,10 +1,10 @@
1
1
  import type { InternalMetricsStore } from "../internal/metrics.js";
2
2
  import type { CluebaseConsentState } from "../provider/types.js";
3
+ import { type CollectionPermit, type PendingBrowserBatch, type StorageLike } from "./browser-outbox-records.js";
3
4
  import type { BatchEnvelopeMetadata } from "./client.js";
4
5
  import { DiagnosticStore } from "./diagnostics.js";
5
6
  import type { BrowserSourceEvent } from "./source-event.js";
6
- import { type CollectionPermit, type PendingBrowserBatch, type StorageLike } from "./browser-outbox-records.js";
7
- export type { CollectionPermit, OwnedBrowserEvent, PendingBrowserBatch } from "./browser-outbox-records.js";
7
+ export type { CollectionPermit, OwnedBrowserEvent, PendingBrowserBatch, } from "./browser-outbox-records.js";
8
8
  export type ConsentTransition = {
9
9
  readonly state: CluebaseConsentState;
10
10
  readonly generation: string;
@@ -62,6 +62,7 @@ export declare class BrowserOutbox {
62
62
  private persistCommittedConsent;
63
63
  addDraft(event: BrowserSourceEvent, permit?: CollectionPermit): boolean;
64
64
  estimatedDraftBytes(measure: (events: readonly BrowserSourceEvent[], metadata: BatchEnvelopeMetadata) => number, metadata: BatchEnvelopeMetadata): number;
65
+ draftCount(): number;
65
66
  prepareFence(input: {
66
67
  readonly maxPayloadBytes: number;
67
68
  readonly createMetadata: () => BatchEnvelopeMetadata;
@@ -85,6 +86,8 @@ export declare class BrowserOutbox {
85
86
  private resolvePermit;
86
87
  private initializeConsentForDelivery;
87
88
  private enforceCapacity;
89
+ private dropOldestPendingForCapacity;
90
+ private forciblyRemovePendingRecord;
88
91
  private purgeAll;
89
92
  clearMemory(): void;
90
93
  private restore;
@@ -1,7 +1,7 @@
1
+ import { canonicalize as canonicalJsonStringify } from "json-canonicalize";
2
+ import { addStorageListener, browserEventsByteLength, consentRecordKey, createConsentChannel, createConsentRecord, createDraft, createGeneration, createLegacyTombstone, createPending, draftRecordKey, hasLegacyTombstone, LEGACY_TOMBSTONE_LIMIT, largestFittingPrefix, nowIso, outboxEventCount, parseRecord, pendingRecordKey, readLocks, readStorage, recordBatchId, sha256Hex, splitPendingRecord, toHeadBatch, tombstoneRecordKey, UNANCHORED_COLLECTION_GENERATION, verifyHash, withChecksum, withoutChecksum, } from "./browser-outbox-records.js";
1
3
  import { DiagnosticStore } from "./diagnostics.js";
2
4
  import { isBrowserSourceSignalEvent } from "./source-event.js";
3
- import { canonicalize as canonicalJsonStringify } from "json-canonicalize";
4
- import { addStorageListener, browserEventsByteLength, createConsentChannel, createConsentRecord, createDraft, createGeneration, createLegacyTombstone, createPending, consentRecordKey, draftRecordKey, hasLegacyTombstone, largestFittingPrefix, nowIso, outboxEventCount, parseRecord, readLocks, readStorage, recordBatchId, sha256Hex, toHeadBatch, splitPendingRecord, pendingRecordKey, tombstoneRecordKey, UNANCHORED_COLLECTION_GENERATION, verifyHash, withChecksum, withoutChecksum, LEGACY_TOMBSTONE_LIMIT, } from "./browser-outbox-records.js";
5
5
  export class BrowserOutbox {
6
6
  constructor(options) {
7
7
  this.drafts = [];
@@ -21,7 +21,8 @@ export class BrowserOutbox {
21
21
  this.consentKey = consentRecordKey(this.prefix);
22
22
  this.legacyKey = `cluebase:source_event_outbox:${options.projectKey}`;
23
23
  this.lockName = `cluebase:source-event-outbox:v2:${this.projectHash}`;
24
- this.storage = "storage" in options ? options.storage ?? null : readStorage();
24
+ this.storage =
25
+ "storage" in options ? (options.storage ?? null) : readStorage();
25
26
  this.durableOutboxSupported = readLocks() !== null;
26
27
  this.maxBytes = Math.max(1024, options.maxBytes);
27
28
  this.maxEvents = Math.max(1, options.maxEvents);
@@ -78,7 +79,10 @@ export class BrowserOutbox {
78
79
  this.generation = createGeneration();
79
80
  this.legacyImportBlocked = true;
80
81
  this.diagnostics.rotateForConsentPurge(purged);
81
- this.consentChannel?.postMessage({ type: "denied", generation: this.generation });
82
+ this.consentChannel?.postMessage({
83
+ type: "denied",
84
+ generation: this.generation,
85
+ });
82
86
  this.clearMemory();
83
87
  return Object.freeze({
84
88
  state: this.consent,
@@ -214,6 +218,9 @@ export class BrowserOutbox {
214
218
  estimatedDraftBytes(measure, metadata) {
215
219
  return measure(this.drafts.map((draft) => draft.event), metadata);
216
220
  }
221
+ draftCount() {
222
+ return this.drafts.length;
223
+ }
217
224
  prepareFence(input) {
218
225
  const claimedEventKeyHashes = new Set(this.pending.flatMap((record) => record.eventKeyHashes));
219
226
  const candidates = this.drafts.filter((draft) => !claimedEventKeyHashes.has(draft.eventKeyHash) &&
@@ -438,16 +445,46 @@ export class BrowserOutbox {
438
445
  while (this.pendingEventCount() > this.maxEvents ||
439
446
  this.pendingBytes() > this.maxBytes) {
440
447
  const dropped = this.drafts.shift();
441
- if (!dropped)
448
+ if (dropped) {
449
+ this.removeRecord(draftRecordKey(this.prefix, dropped.eventKeyHash));
450
+ this.metrics?.increment("dropped_count");
451
+ this.diagnostics.record({
452
+ code: "queue_overflow",
453
+ event_fingerprint: this.diagnostics.eventFingerprint(canonicalJsonStringify(dropped.event)),
454
+ });
455
+ continue;
456
+ }
457
+ if (!this.dropOldestPendingForCapacity())
442
458
  return;
443
- this.removeRecord(draftRecordKey(this.prefix, dropped.eventKeyHash));
444
- this.metrics?.increment("dropped_count");
445
- this.diagnostics.record({
446
- code: "queue_overflow",
447
- event_fingerprint: this.diagnostics.eventFingerprint(canonicalJsonStringify(dropped.event)),
448
- });
449
459
  }
450
460
  }
461
+ dropOldestPendingForCapacity() {
462
+ if (this.pending.length === 0)
463
+ return false;
464
+ const oldest = this.pending.reduce((oldestSoFar, item) => item.ordinal < oldestSoFar.ordinal ? item : oldestSoFar);
465
+ const headBatch = toHeadBatch(oldest, pendingRecordKey(this.prefix, oldest.batchId));
466
+ const droppedCount = oldest.events.length;
467
+ this.forciblyRemovePendingRecord(oldest);
468
+ if (droppedCount > 0) {
469
+ this.metrics?.increment("dropped_count", droppedCount);
470
+ }
471
+ this.diagnostics.record({
472
+ code: "queue_overflow",
473
+ count: droppedCount,
474
+ batch_fingerprint: headBatch
475
+ ? this.diagnostics.batchFingerprint(headBatch.diagnosticCanonicalBytes)
476
+ : undefined,
477
+ });
478
+ return true;
479
+ }
480
+ forciblyRemovePendingRecord(record) {
481
+ let removed = this.removeClaimedDraftRecords(record);
482
+ removed =
483
+ this.removeRecord(pendingRecordKey(this.prefix, record.batchId)) &&
484
+ removed;
485
+ this.pending = this.pending.filter((item) => item !== record);
486
+ return removed;
487
+ }
451
488
  purgeAll() {
452
489
  const count = this.pendingEventCount();
453
490
  let removed = this.clear();
@@ -1,8 +1,8 @@
1
1
  import type { InternalMetricsStore } from "../internal/metrics.js";
2
- import { type BrowserSourceEvent } from "./source-event.js";
2
+ import type { IngestUsageGovernorFeedback } from "../provider/types.js";
3
3
  import { type IngestRetryStrategy } from "./delivery-response.js";
4
4
  import type { DiagnosticStore } from "./diagnostics.js";
5
- import type { IngestUsageGovernorFeedback } from "../provider/types.js";
5
+ import { type BrowserSourceEvent } from "./source-event.js";
6
6
  export declare const CLUEBASE_SDK_REQUEST_HEADER = "x-cluebase-sdk-request";
7
7
  export declare const CLUEBASE_SDK_VERSION_HEADER = "x-cluebase-sdk-version";
8
8
  export declare const CLUEBASE_SOURCE_SCHEMA_VERSION_HEADER = "x-cluebase-source-schema-version";
@@ -1,10 +1,10 @@
1
1
  import { canonicalize as canonicalJsonStringify } from "json-canonicalize";
2
2
  import { CONSENT_DENIED_ABORT_REASON, DEFAULT_FRONTEND_SOURCE_IDENTIFIER, } from "../provider/constants.js";
3
- import { SDK_SOURCE_SIGNAL_EVENT_NAME, SDK_SOURCE_SIGNAL_EVENT_TYPE, isBrowserSourceSignalEvent, } from "./source-event.js";
4
- import { generateEventId } from "./event-id.js";
3
+ import { appliedPrivacyPolicyToken, applyDeliveredPrivacyPolicy, } from "../provider/privacy-policy-store.js";
5
4
  import { classifyIngestHttpStatus, evaluateBrowserDeliveryResponse, } from "./delivery-response.js";
5
+ import { generateEventId } from "./event-id.js";
6
6
  import { computeRetryDelay, RETRY_CONFIG } from "./retry.js";
7
- import { appliedPrivacyPolicyToken, applyDeliveredPrivacyPolicy, } from "../provider/privacy-policy-store.js";
7
+ import { isBrowserSourceSignalEvent, SDK_SOURCE_SIGNAL_EVENT_NAME, SDK_SOURCE_SIGNAL_EVENT_TYPE, } from "./source-event.js";
8
8
  export const CLUEBASE_SDK_REQUEST_HEADER = "x-cluebase-sdk-request";
9
9
  export const CLUEBASE_SDK_VERSION_HEADER = "x-cluebase-sdk-version";
10
10
  export const CLUEBASE_SOURCE_SCHEMA_VERSION_HEADER = "x-cluebase-source-schema-version";
@@ -32,8 +32,7 @@ export const resolveCanonicalIngestEndpoint = (endpoint) => {
32
32
  return parsed.toString();
33
33
  }
34
34
  }
35
- catch {
36
- }
35
+ catch { }
37
36
  return endpoint;
38
37
  };
39
38
  const BROWSER_TOKEN_REFRESH_LEEWAY_MS = 60000;
@@ -105,16 +104,7 @@ export class IngestClient {
105
104
  const payloadSize = new TextEncoder().encode(payload).byteLength;
106
105
  this.metrics?.increment("payload_size_bytes", payloadSize);
107
106
  if (payloadSize > maxPayloadBytes) {
108
- this.metrics?.increment("dropped_count", events.length);
109
- this.metrics?.increment("send_failure_count");
110
- this.metrics?.increment("transport_failed_count");
111
- return {
112
- ok: false,
113
- status: null,
114
- attempts: 0,
115
- governor: null,
116
- retryStrategy: null,
117
- };
107
+ return this.failedSend(null, 0, "split_batch");
118
108
  }
119
109
  const browserTokenResult = await this.safeEnsureBrowserToken({
120
110
  signal: options.signal,
@@ -269,23 +259,16 @@ export class IngestClient {
269
259
  return { ok: false, status, attempts, governor, retryStrategy };
270
260
  }
271
261
  recordDecision(decision, status, attempt, payload) {
272
- if (!this.diagnostics)
262
+ const diagnostics = this.diagnostics;
263
+ if (!diagnostics)
273
264
  return;
274
- if (decision.reason === "invalid_response") {
275
- this.diagnostics.record({
276
- code: "invalid_response",
265
+ for (const code of decision.diagnosticCodes) {
266
+ diagnostics.record({
267
+ code,
268
+ count: code === "intentional_drop" ? decision.droppedEventCount : 1,
277
269
  http_status: status ?? undefined,
278
270
  attempt,
279
- batch_fingerprint: this.diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
280
- });
281
- return;
282
- }
283
- if (status !== null && status >= 300 && status < 400) {
284
- this.diagnostics.record({
285
- code: "protocol_redirect",
286
- http_status: status,
287
- attempt,
288
- batch_fingerprint: this.diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
271
+ batch_fingerprint: diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
289
272
  });
290
273
  }
291
274
  }
@@ -334,6 +317,7 @@ export class IngestClient {
334
317
  reason: isConsentAbort(input.signal)
335
318
  ? "consent_denied"
336
319
  : "transport_timeout",
320
+ diagnosticCodes: [],
337
321
  },
338
322
  };
339
323
  }
@@ -416,7 +400,8 @@ export class IngestClient {
416
400
  if (isConsentAbort(options.signal)) {
417
401
  return { ok: false, retryStrategy: "drop_event" };
418
402
  }
419
- if (!(error instanceof BrowserTokenRequestError) || error.status === null) {
403
+ if (!(error instanceof BrowserTokenRequestError) ||
404
+ error.status === null) {
420
405
  return { ok: false, retryStrategy: "retry_same_batch" };
421
406
  }
422
407
  return {
@@ -1,5 +1,5 @@
1
- import { COST_CAPABILITIES, LIMIT_KINDS } from "../_generated/usage-governor-tables.js";
2
1
  import { z } from "zod";
2
+ import { COST_CAPABILITIES, LIMIT_KINDS, } from "../_generated/usage-governor-tables.js";
3
3
  const MAX_INGEST_EVENTS_PER_BATCH = 10000;
4
4
  const ingestRetryStrategySchema = z.enum([
5
5
  "split_batch",
@@ -30,7 +30,11 @@ const ingestUsageGovernorFeedbackSchema = z.object({
30
30
  })),
31
31
  retryAfterAt: z.string().datetime({ offset: true }).nullable(),
32
32
  });
33
- const boundedCount = z.number().int().nonnegative().max(MAX_INGEST_EVENTS_PER_BATCH);
33
+ const boundedCount = z
34
+ .number()
35
+ .int()
36
+ .nonnegative()
37
+ .max(MAX_INGEST_EVENTS_PER_BATCH);
34
38
  const positiveCount = boundedCount.refine((count) => count > 0);
35
39
  export const ingestPrivacyPolicySchema = z
36
40
  .object({
@@ -55,13 +59,16 @@ const observationSourceEventBatchAcceptedResponseSchema = z
55
59
  submitted_event_count: positiveCount,
56
60
  accepted_event_count: positiveCount,
57
61
  dropped_event_count: boundedCount,
58
- dropped_events: z.array(ingestDroppedEventSchema).max(MAX_INGEST_EVENTS_PER_BATCH),
62
+ dropped_events: z
63
+ .array(ingestDroppedEventSchema)
64
+ .max(MAX_INGEST_EVENTS_PER_BATCH),
59
65
  duplicate: z.boolean(),
60
66
  governor: ingestUsageGovernorFeedbackSchema.optional(),
61
67
  privacy_policy: ingestPrivacyPolicySchema.optional(),
62
68
  })
63
69
  .superRefine((value, ctx) => {
64
- if (value.submitted_event_count !== value.accepted_event_count + value.dropped_event_count) {
70
+ if (value.submitted_event_count !==
71
+ value.accepted_event_count + value.dropped_event_count) {
65
72
  ctx.addIssue({
66
73
  code: z.ZodIssueCode.custom,
67
74
  path: ["submitted_event_count"],
@@ -81,7 +88,10 @@ const observationSourceEventBatchRejectedResponseSchema = z
81
88
  submitted_event_count: positiveCount.optional(),
82
89
  accepted_event_count: boundedCount.optional(),
83
90
  dropped_event_count: boundedCount.optional(),
84
- dropped_events: z.array(ingestDroppedEventSchema).max(MAX_INGEST_EVENTS_PER_BATCH).optional(),
91
+ dropped_events: z
92
+ .array(ingestDroppedEventSchema)
93
+ .max(MAX_INGEST_EVENTS_PER_BATCH)
94
+ .optional(),
85
95
  limits: z
86
96
  .object({
87
97
  hard_max_payload_bytes: z.number().int().positive(),
@@ -91,36 +101,67 @@ const observationSourceEventBatchRejectedResponseSchema = z
91
101
  .optional(),
92
102
  })
93
103
  .superRefine((value, ctx) => {
94
- const counts = [value.submitted_event_count, value.accepted_event_count, value.dropped_event_count];
104
+ const counts = [
105
+ value.submitted_event_count,
106
+ value.accepted_event_count,
107
+ value.dropped_event_count,
108
+ ];
95
109
  const hasAnyCount = counts.some((count) => count !== undefined);
96
110
  const hasAllCounts = counts.every((count) => count !== undefined);
97
111
  if (hasAnyCount !== hasAllCounts) {
98
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["submitted_event_count"], message: "rejected response counts must be all present or all absent" });
112
+ ctx.addIssue({
113
+ code: z.ZodIssueCode.custom,
114
+ path: ["submitted_event_count"],
115
+ message: "rejected response counts must be all present or all absent",
116
+ });
99
117
  return;
100
118
  }
101
119
  if (hasAllCounts) {
102
120
  const [submitted, accepted, dropped] = counts;
103
- if (accepted > submitted || dropped > submitted || accepted + dropped > submitted) {
104
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["dropped_event_count"], message: "rejected counts must not exceed submitted count" });
121
+ if (accepted > submitted ||
122
+ dropped > submitted ||
123
+ accepted + dropped > submitted) {
124
+ ctx.addIssue({
125
+ code: z.ZodIssueCode.custom,
126
+ path: ["dropped_event_count"],
127
+ message: "rejected counts must not exceed submitted count",
128
+ });
105
129
  }
106
130
  }
107
131
  if (value.retry_strategy === "drop_event") {
108
132
  if (!hasAllCounts) {
109
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["submitted_event_count"], message: "drop rejection requires all counts" });
133
+ ctx.addIssue({
134
+ code: z.ZodIssueCode.custom,
135
+ path: ["submitted_event_count"],
136
+ message: "drop rejection requires all counts",
137
+ });
110
138
  }
111
139
  else {
112
140
  const [submitted, accepted, dropped] = counts;
113
141
  if (accepted !== 0 || dropped !== submitted) {
114
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["dropped_event_count"], message: "drop rejection must reject every submitted event" });
142
+ ctx.addIssue({
143
+ code: z.ZodIssueCode.custom,
144
+ path: ["dropped_event_count"],
145
+ message: "drop rejection must reject every submitted event",
146
+ });
115
147
  }
116
148
  validateDroppedEventDetails(submitted, dropped, value.dropped_events ?? [], ctx);
117
149
  }
118
150
  }
119
151
  else if ((value.dropped_events?.length ?? 0) > 0) {
120
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["dropped_events"], message: "non-drop rejection must not contain dropped event details" });
152
+ ctx.addIssue({
153
+ code: z.ZodIssueCode.custom,
154
+ path: ["dropped_events"],
155
+ message: "non-drop rejection must not contain dropped event details",
156
+ });
121
157
  }
122
- if ((value.retry_strategy === "split_batch") !== (value.limits !== undefined)) {
123
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["limits"], message: "byte limits are valid only for split rejection" });
158
+ if ((value.retry_strategy === "split_batch") !==
159
+ (value.limits !== undefined)) {
160
+ ctx.addIssue({
161
+ code: z.ZodIssueCode.custom,
162
+ path: ["limits"],
163
+ message: "byte limits are valid only for split rejection",
164
+ });
124
165
  }
125
166
  });
126
167
  export const observationSourceEventBatchResponseSchema = z.discriminatedUnion("accepted", [
@@ -129,12 +170,21 @@ export const observationSourceEventBatchResponseSchema = z.discriminatedUnion("a
129
170
  ]);
130
171
  function validateDroppedEventDetails(submittedCount, droppedCount, droppedEvents, ctx) {
131
172
  if (droppedEvents.length !== droppedCount) {
132
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["dropped_events"], message: "dropped event details must match dropped count" });
173
+ ctx.addIssue({
174
+ code: z.ZodIssueCode.custom,
175
+ path: ["dropped_events"],
176
+ message: "dropped event details must match dropped count",
177
+ });
133
178
  }
134
179
  const indexes = new Set();
135
180
  for (const [detailIndex, detail] of droppedEvents.entries()) {
136
- if (detail.event_index >= submittedCount || indexes.has(detail.event_index)) {
137
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["dropped_events", detailIndex, "event_index"], message: "dropped event indexes must be valid and unique" });
181
+ if (detail.event_index >= submittedCount ||
182
+ indexes.has(detail.event_index)) {
183
+ ctx.addIssue({
184
+ code: z.ZodIssueCode.custom,
185
+ path: ["dropped_events", detailIndex, "event_index"],
186
+ message: "dropped event indexes must be valid and unique",
187
+ });
138
188
  }
139
189
  indexes.add(detail.event_index);
140
190
  }
@@ -1,5 +1,6 @@
1
- import { type IngestRetryStrategy } from "./delivery-response-contract.js";
2
1
  import type { IngestUsageGovernorFeedback } from "../provider/types.js";
2
+ import { type IngestRetryStrategy } from "./delivery-response-contract.js";
3
+ import type { DiagnosticCode } from "./diagnostics.js";
3
4
  export declare const BROWSER_REJECTION_STRATEGIES: Readonly<Record<string, IngestRetryStrategy>>;
4
5
  export type { IngestRetryStrategy } from "./delivery-response-contract.js";
5
6
  export type BrowserDeliveryDecision = {
@@ -12,6 +13,7 @@ export type BrowserDeliveryDecision = {
12
13
  readonly acceptedEventCount: number;
13
14
  readonly acceptedPartial: boolean;
14
15
  readonly reason: string | null;
16
+ readonly diagnosticCodes: readonly DiagnosticCode[];
15
17
  };
16
18
  export declare const evaluateBrowserDeliveryResponse: (input: {
17
19
  readonly status: number;
@@ -1,5 +1,5 @@
1
- import { observationSourceEventBatchResponseSchema, } from "./delivery-response-contract.js";
2
1
  import { parseGovernorFeedbackEnvelope } from "../provider/usage-governor.js";
2
+ import { observationSourceEventBatchResponseSchema, } from "./delivery-response-contract.js";
3
3
  export const BROWSER_REJECTION_STRATEGIES = {
4
4
  invalid_request: "stop_and_report",
5
5
  unsupported_source_schema_version: "stop_and_report",
@@ -21,6 +21,7 @@ export const BROWSER_REJECTION_STRATEGIES = {
21
21
  raw_ingest_produce_failed: "retry_same_batch",
22
22
  raw_ingest_ack_unknown: "retry_same_batch",
23
23
  raw_ingest_receipt_metadata_failed: "retry_same_batch",
24
+ raw_ingest_receipt_unprocessable: "stop_and_report",
24
25
  ingest_config_changed_retryable: "retry_same_batch",
25
26
  usage_meter_unavailable: "retry_same_batch",
26
27
  };
@@ -36,6 +37,15 @@ export const evaluateBrowserDeliveryResponse = (input) => {
36
37
  return invalidResponse();
37
38
  }
38
39
  const partial = parsed.data.dropped_event_count > 0;
40
+ const diagnosticCodes = [];
41
+ if (partial) {
42
+ diagnosticCodes.push(parsed.data.dropped_events.length > 0
43
+ ? "intentional_drop"
44
+ : "duplicate_drop_details_unavailable");
45
+ }
46
+ if (input.status !== 202) {
47
+ diagnosticCodes.push("non_canonical_success_status");
48
+ }
39
49
  return {
40
50
  ok: true,
41
51
  retryStrategy: null,
@@ -46,6 +56,7 @@ export const evaluateBrowserDeliveryResponse = (input) => {
46
56
  acceptedEventCount: parsed.data.accepted_event_count,
47
57
  acceptedPartial: partial,
48
58
  reason: partial ? "accepted_partial" : null,
59
+ diagnosticCodes,
49
60
  };
50
61
  }
51
62
  if (BROWSER_REJECTION_STRATEGIES[parsed.data.reason] !==
@@ -60,6 +71,7 @@ export const evaluateBrowserDeliveryResponse = (input) => {
60
71
  acceptedEventCount: parsed.data.accepted_event_count ?? 0,
61
72
  acceptedPartial: false,
62
73
  reason: parsed.data.reason,
74
+ diagnosticCodes: [],
63
75
  };
64
76
  }
65
77
  return invalidResponse();
@@ -91,6 +103,7 @@ const invalidResponse = () => ({
91
103
  acceptedEventCount: 0,
92
104
  acceptedPartial: false,
93
105
  reason: "invalid_response",
106
+ diagnosticCodes: ["invalid_response"],
94
107
  });
95
108
  export const classifyIngestHttpStatus = (status) => {
96
109
  if (status >= 200 && status < 300)
@@ -104,29 +117,27 @@ export const classifyIngestHttpStatus = (status) => {
104
117
  }
105
118
  return "retry_same_batch";
106
119
  };
107
- const fallback = (status) => {
108
- const retryStrategy = classifyIngestHttpStatus(status);
109
- if (status >= 200 && status < 300)
110
- return invalidResponse();
111
- if (retryStrategy === "retry_same_batch")
112
- return retry(status);
113
- return terminal(retryStrategy, status);
114
- };
115
- const retry = (status) => ({
116
- ok: false,
117
- retryStrategy: "retry_same_batch",
118
- governor: null,
119
- droppedEventCount: 0,
120
- acceptedEventCount: 0,
121
- acceptedPartial: false,
122
- reason: `http_${status}`,
123
- });
124
- const terminal = (retryStrategy, status) => ({
120
+ const RETRYABLE_CLIENT_STATUSES = [408, 422, 425, 429];
121
+ const fallback = (status) => ({
125
122
  ok: false,
126
- retryStrategy,
123
+ retryStrategy: classifyIngestHttpStatus(status),
127
124
  governor: null,
128
125
  droppedEventCount: 0,
129
126
  acceptedEventCount: 0,
130
127
  acceptedPartial: false,
131
128
  reason: `http_${status}`,
129
+ diagnosticCodes: fallbackDiagnosticCodes(status),
132
130
  });
131
+ const fallbackDiagnosticCodes = (status) => {
132
+ if (classifyIngestHttpStatus(status) !== "retry_same_batch")
133
+ return [];
134
+ if (status >= 200 && status <= 299)
135
+ return ["invalid_response"];
136
+ if (status >= 300 && status <= 399)
137
+ return ["protocol_redirect"];
138
+ if (status >= 500 && status <= 599)
139
+ return [];
140
+ if (RETRYABLE_CLIENT_STATUSES.includes(status))
141
+ return ["invalid_response"];
142
+ return ["unknown_status"];
143
+ };
@@ -1,4 +1,4 @@
1
- import type { IngestRetryStrategy } from "./delivery-response.js";
1
+ import type { IngestRetryStrategy } from "./delivery-response-contract.js";
2
2
  export type DiagnosticCode = "batch_split" | "consent_persistence_failed" | "consent_purge" | "consent_purge_failed" | "consent_state_unavailable" | "durable_outbox_unavailable" | "duplicate_drop_details_unavailable" | "event_oversize" | "intentional_drop" | "invalid_response" | "legacy_pre_revocation_event_blocked" | "legacy_bridge_capacity_reached" | "legacy_writer_detected" | "non_canonical_success_status" | "outbox_key_collision" | "outbox_record_corrupt" | "protocol_redirect" | "queue_overflow" | "retry_exhausted" | "retry_scheduled" | "shutdown_failure" | "terminal_rejection" | "unknown_status";
3
3
  export type DiagnosticLifecycle = "open" | "closing" | "closing_failed" | "closed";
4
4
  export type DiagnosticFlushOutcome = "clean" | "failed" | null;
@@ -51,6 +51,7 @@ const SAFE_REASONS = new Set([
51
51
  "raw_ingest_ack_unknown",
52
52
  "raw_ingest_produce_failed",
53
53
  "raw_ingest_receipt_metadata_failed",
54
+ "raw_ingest_receipt_unprocessable",
54
55
  "route_throttled",
55
56
  "stale_browser_token",
56
57
  "unresolved_project_or_environment",
@@ -131,7 +132,9 @@ const normalize = (input) => {
131
132
  if (input.reason && SAFE_REASONS.has(input.reason))
132
133
  entry.reason = input.reason;
133
134
  const attempt = input.attempt;
134
- if (typeof attempt === "number" && Number.isSafeInteger(attempt) && attempt > 0) {
135
+ if (typeof attempt === "number" &&
136
+ Number.isSafeInteger(attempt) &&
137
+ attempt > 0) {
135
138
  entry.attempt = attempt;
136
139
  }
137
140
  const count = input.count;
@@ -20,9 +20,7 @@ const nextMonotonicParts = () => {
20
20
  typeof globalThis.performance.now === "function"
21
21
  ? Math.floor(globalThis.performance.now() * 1000)
22
22
  : Date.now();
23
- const monotonicTime = performanceNow > lastMonotonicTime
24
- ? performanceNow
25
- : lastMonotonicTime + 1;
23
+ const monotonicTime = performanceNow > lastMonotonicTime ? performanceNow : lastMonotonicTime + 1;
26
24
  lastMonotonicTime = monotonicTime;
27
25
  monotonicCounter = (monotonicCounter + 1) % Number.MAX_SAFE_INTEGER;
28
26
  return { monotonicTime, counter: monotonicCounter };
@@ -1,7 +1,7 @@
1
- import { isDeniedKey } from "./sanitize.js";
1
+ import { CLUEBASE_OTEL_ALLOWED_RESOURCE_KEYS, CLUEBASE_OTEL_SDK_SOURCE_REQUIRED_RESOURCE_KEYS, } from "../_generated/otel-resource-keys.js";
2
2
  import { SDK_VERSION } from "../provider/constants.js";
3
- import { CLUEBASE_OTEL_SDK_SOURCE_REQUIRED_RESOURCE_KEYS, CLUEBASE_OTEL_SDK_SOURCE_RESOURCE_KEYS, } from "../_generated/otel-resource-keys.js";
4
- const FRONTEND_RESOURCE_KEYS = new Set(CLUEBASE_OTEL_SDK_SOURCE_RESOURCE_KEYS);
3
+ import { isDeniedKey } from "./sanitize.js";
4
+ const FRONTEND_RESOURCE_KEYS = new Set(CLUEBASE_OTEL_ALLOWED_RESOURCE_KEYS);
5
5
  const FRONTEND_REQUIRED_RESOURCE_KEYS = new Set(CLUEBASE_OTEL_SDK_SOURCE_REQUIRED_RESOURCE_KEYS);
6
6
  const MAX_RESOURCE_VALUE_BYTES = 512;
7
7
  export const createFrontendResourceAttributes = (resolved) => ({
@@ -27,7 +27,8 @@ export const toFrontendSourceResource = (resource, deniedKeys = []) => {
27
27
  }
28
28
  for (const [key, value] of entries) {
29
29
  if (!FRONTEND_RESOURCE_KEYS.has(key) ||
30
- (isDeniedKey(key, deniedKeys) && !FRONTEND_REQUIRED_RESOURCE_KEYS.has(key))) {
30
+ (isDeniedKey(key, deniedKeys) &&
31
+ !FRONTEND_REQUIRED_RESOURCE_KEYS.has(key))) {
31
32
  continue;
32
33
  }
33
34
  if (key === "cluebase.service_type" && value !== "frontend") {
@@ -30,6 +30,7 @@ export declare class ObservationSourceEventExporter implements SpanExporter {
30
30
  private ignoreSpanReason;
31
31
  enqueue(event: BrowserSourceSignalEvent): void;
32
32
  export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
33
+ private exportAsync;
33
34
  forceFlush(): Promise<void>;
34
35
  shutdown(): Promise<void>;
35
36
  internalMetrics(): {
@@ -55,6 +55,9 @@ export class ObservationSourceEventExporter {
55
55
  });
56
56
  return;
57
57
  }
58
+ void this.exportAsync(spans, resultCallback);
59
+ }
60
+ async exportAsync(spans, resultCallback) {
58
61
  try {
59
62
  const context = this.getContext();
60
63
  for (const span of spans) {
@@ -71,7 +74,7 @@ export class ObservationSourceEventExporter {
71
74
  this.droppedSpanCount += 1;
72
75
  continue;
73
76
  }
74
- const sourceSignal = spanToSourceSignalEvent(span, {
77
+ const sourceSignal = await spanToSourceSignalEvent(span, {
75
78
  context,
76
79
  deniedKeys: this.deniedKeys,
77
80
  });
@@ -85,7 +88,7 @@ export class ObservationSourceEventExporter {
85
88
  });
86
89
  }
87
90
  }
88
- void this.flushImpl()
91
+ await this.flushImpl()
89
92
  .then(() => resultCallback({ code: ExportResultCode.SUCCESS }))
90
93
  .catch((cause) => resultCallback({
91
94
  code: ExportResultCode.FAILED,