@genn-inc/cluebase-frontend-sdk 0.0.1

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 (140) hide show
  1. package/README.md +222 -0
  2. package/dist/_generated/correlation-attribute-keys.d.ts +2 -0
  3. package/dist/_generated/correlation-attribute-keys.js +33 -0
  4. package/dist/_generated/identity-pii-policy.d.ts +38 -0
  5. package/dist/_generated/identity-pii-policy.js +103 -0
  6. package/dist/_generated/ingest-privacy-tables.d.ts +120 -0
  7. package/dist/_generated/ingest-privacy-tables.js +890 -0
  8. package/dist/_generated/otel-resource-keys.d.ts +21 -0
  9. package/dist/_generated/otel-resource-keys.js +72 -0
  10. package/dist/_generated/otel-span-conversion.d.ts +48 -0
  11. package/dist/_generated/otel-span-conversion.js +69 -0
  12. package/dist/_generated/parameter-snapshot-leaf.d.ts +11 -0
  13. package/dist/_generated/parameter-snapshot-leaf.js +37 -0
  14. package/dist/_generated/sdk-runtime-policy.d.ts +7 -0
  15. package/dist/_generated/sdk-runtime-policy.js +14 -0
  16. package/dist/_generated/usage-governor-tables.d.ts +15 -0
  17. package/dist/_generated/usage-governor-tables.js +44 -0
  18. package/dist/context/browser-storage.d.ts +15 -0
  19. package/dist/context/browser-storage.js +38 -0
  20. package/dist/context/environment.d.ts +37 -0
  21. package/dist/context/environment.js +229 -0
  22. package/dist/context/identity.d.ts +16 -0
  23. package/dist/context/identity.js +139 -0
  24. package/dist/context/organization.d.ts +16 -0
  25. package/dist/context/organization.js +60 -0
  26. package/dist/context/session.d.ts +28 -0
  27. package/dist/context/session.js +130 -0
  28. package/dist/context/tab.d.ts +22 -0
  29. package/dist/context/tab.js +117 -0
  30. package/dist/context/trace.d.ts +76 -0
  31. package/dist/context/trace.js +163 -0
  32. package/dist/exporter/batch.d.ts +94 -0
  33. package/dist/exporter/batch.js +485 -0
  34. package/dist/exporter/browser-outbox-records.d.ts +159 -0
  35. package/dist/exporter/browser-outbox-records.js +341 -0
  36. package/dist/exporter/browser-outbox.d.ts +112 -0
  37. package/dist/exporter/browser-outbox.js +876 -0
  38. package/dist/exporter/client.d.ts +92 -0
  39. package/dist/exporter/client.js +541 -0
  40. package/dist/exporter/delivery-response-contract.d.ts +103 -0
  41. package/dist/exporter/delivery-response-contract.js +147 -0
  42. package/dist/exporter/delivery-response.d.ts +37 -0
  43. package/dist/exporter/delivery-response.js +151 -0
  44. package/dist/exporter/diagnostics.d.ts +42 -0
  45. package/dist/exporter/diagnostics.js +148 -0
  46. package/dist/exporter/event-id.d.ts +10 -0
  47. package/dist/exporter/event-id.js +75 -0
  48. package/dist/exporter/frontend-resource.d.ts +20 -0
  49. package/dist/exporter/frontend-resource.js +52 -0
  50. package/dist/exporter/local-minimization.d.ts +29 -0
  51. package/dist/exporter/local-minimization.js +87 -0
  52. package/dist/exporter/observation-source-event-exporter.d.ts +69 -0
  53. package/dist/exporter/observation-source-event-exporter.js +154 -0
  54. package/dist/exporter/parameter-snapshot.d.ts +14 -0
  55. package/dist/exporter/parameter-snapshot.js +215 -0
  56. package/dist/exporter/path-template.d.ts +1 -0
  57. package/dist/exporter/path-template.js +67 -0
  58. package/dist/exporter/raw-event-to-source-signal.d.ts +12 -0
  59. package/dist/exporter/raw-event-to-source-signal.js +297 -0
  60. package/dist/exporter/retry.d.ts +8 -0
  61. package/dist/exporter/retry.js +8 -0
  62. package/dist/exporter/sanitize.d.ts +30 -0
  63. package/dist/exporter/sanitize.js +177 -0
  64. package/dist/exporter/schema-evidence.d.ts +20 -0
  65. package/dist/exporter/schema-evidence.js +232 -0
  66. package/dist/exporter/source-event.d.ts +99 -0
  67. package/dist/exporter/source-event.js +13 -0
  68. package/dist/exporter/span-to-source-signal.d.ts +8 -0
  69. package/dist/exporter/span-to-source-signal.js +174 -0
  70. package/dist/exporter/text-fingerprint.d.ts +9 -0
  71. package/dist/exporter/text-fingerprint.js +16 -0
  72. package/dist/index.d.ts +25 -0
  73. package/dist/index.js +158 -0
  74. package/dist/instrumentation/action.d.ts +2 -0
  75. package/dist/instrumentation/action.js +171 -0
  76. package/dist/instrumentation/cluebase-authored-attributes.d.ts +4 -0
  77. package/dist/instrumentation/cluebase-authored-attributes.js +32 -0
  78. package/dist/instrumentation/common.d.ts +73 -0
  79. package/dist/instrumentation/common.js +90 -0
  80. package/dist/instrumentation/dom.d.ts +63 -0
  81. package/dist/instrumentation/dom.js +640 -0
  82. package/dist/instrumentation/drag.d.ts +2 -0
  83. package/dist/instrumentation/drag.js +82 -0
  84. package/dist/instrumentation/error.d.ts +2 -0
  85. package/dist/instrumentation/error.js +318 -0
  86. package/dist/instrumentation/form.d.ts +2 -0
  87. package/dist/instrumentation/form.js +132 -0
  88. package/dist/instrumentation/input.d.ts +2 -0
  89. package/dist/instrumentation/input.js +180 -0
  90. package/dist/instrumentation/location.d.ts +11 -0
  91. package/dist/instrumentation/location.js +69 -0
  92. package/dist/instrumentation/naming-evidence.d.ts +20 -0
  93. package/dist/instrumentation/naming-evidence.js +11 -0
  94. package/dist/instrumentation/navigation.d.ts +2 -0
  95. package/dist/instrumentation/navigation.js +144 -0
  96. package/dist/instrumentation/network-request.d.ts +103 -0
  97. package/dist/instrumentation/network-request.js +285 -0
  98. package/dist/instrumentation/network-span-attributes.d.ts +25 -0
  99. package/dist/instrumentation/network-span-attributes.js +153 -0
  100. package/dist/instrumentation/network.d.ts +16 -0
  101. package/dist/instrumentation/network.js +502 -0
  102. package/dist/instrumentation/page.d.ts +2 -0
  103. package/dist/instrumentation/page.js +56 -0
  104. package/dist/instrumentation/performance.d.ts +2 -0
  105. package/dist/instrumentation/performance.js +365 -0
  106. package/dist/integrations/next-router.d.ts +16 -0
  107. package/dist/integrations/next-router.js +18 -0
  108. package/dist/integrations/react-router.d.ts +7 -0
  109. package/dist/integrations/react-router.js +61 -0
  110. package/dist/internal/internal-log.d.ts +23 -0
  111. package/dist/internal/internal-log.js +47 -0
  112. package/dist/internal/metrics.d.ts +10 -0
  113. package/dist/internal/metrics.js +50 -0
  114. package/dist/provider/_version.d.ts +1 -0
  115. package/dist/provider/_version.js +3 -0
  116. package/dist/provider/config.d.ts +12 -0
  117. package/dist/provider/config.js +177 -0
  118. package/dist/provider/constants.d.ts +62 -0
  119. package/dist/provider/constants.js +118 -0
  120. package/dist/provider/contracts.d.ts +51 -0
  121. package/dist/provider/contracts.js +46 -0
  122. package/dist/provider/privacy-policy-store.d.ts +20 -0
  123. package/dist/provider/privacy-policy-store.js +56 -0
  124. package/dist/provider/sampling.d.ts +2 -0
  125. package/dist/provider/sampling.js +17 -0
  126. package/dist/provider/sdk-event-pipeline.d.ts +60 -0
  127. package/dist/provider/sdk-event-pipeline.js +478 -0
  128. package/dist/provider/sdk-input-normalization.d.ts +7 -0
  129. package/dist/provider/sdk-input-normalization.js +113 -0
  130. package/dist/provider/sdk-otel-pipeline.d.ts +14 -0
  131. package/dist/provider/sdk-otel-pipeline.js +148 -0
  132. package/dist/provider/sdk-runtime-surfaces.d.ts +49 -0
  133. package/dist/provider/sdk-runtime-surfaces.js +216 -0
  134. package/dist/provider/sdk.d.ts +2 -0
  135. package/dist/provider/sdk.js +614 -0
  136. package/dist/provider/types.d.ts +323 -0
  137. package/dist/provider/types.js +1 -0
  138. package/dist/provider/usage-governor.d.ts +7 -0
  139. package/dist/provider/usage-governor.js +116 -0
  140. package/package.json +59 -0
@@ -0,0 +1,92 @@
1
+ import type { InternalMetricsStore } from "../internal/metrics.js";
2
+ import { type BrowserSourceEvent } from "./source-event.js";
3
+ import { type IngestRetryStrategy } from "./delivery-response.js";
4
+ import type { DiagnosticStore } from "./diagnostics.js";
5
+ import type { IngestUsageGovernorFeedback } from "../provider/types.js";
6
+ export declare const CLUEBASE_SDK_REQUEST_HEADER = "x-cluebase-sdk-request";
7
+ export declare const CLUEBASE_SDK_VERSION_HEADER = "x-cluebase-sdk-version";
8
+ export declare const CLUEBASE_SOURCE_SCHEMA_VERSION_HEADER = "x-cluebase-source-schema-version";
9
+ export declare const CLUEBASE_BROWSER_TOKEN_HEADER = "x-cluebase-browser-token";
10
+ export declare const CANONICAL_BROWSER_INGEST_PATH = "/api/v1/ingest/browser";
11
+ export declare const CANONICAL_BROWSER_TOKEN_PATH = "/api/v1/ingest/browser-tokens";
12
+ export declare const resolveCanonicalIngestEndpoint: (endpoint: string) => string;
13
+ export type IngestClientOptions = {
14
+ endpoint: string;
15
+ projectKey: string;
16
+ sdkType: "browser";
17
+ sdkVersion: string;
18
+ schemaVersion: number;
19
+ metrics?: InternalMetricsStore;
20
+ diagnostics?: DiagnosticStore;
21
+ };
22
+ export declare const resolveBrowserTokenEndpoint: (endpoint: string) => string;
23
+ export type SendEventsOptions = {
24
+ keepalive?: boolean;
25
+ useBeacon?: boolean;
26
+ signal?: AbortSignal;
27
+ requireCachedBrowserToken?: boolean;
28
+ maxPayloadBytes?: number;
29
+ metadata?: BatchEnvelopeMetadata;
30
+ /** Absolute monotonic deadline shared by one SDK flush or close operation. */
31
+ deadlineAt?: number;
32
+ };
33
+ export type SendEventsResult = {
34
+ ok: boolean;
35
+ status: number | null;
36
+ attempts: number;
37
+ governor: IngestUsageGovernorFeedback | null;
38
+ retryStrategy: IngestRetryStrategy | null;
39
+ observationIngestBatchId?: string;
40
+ partial?: boolean;
41
+ droppedEventCount?: number;
42
+ reason?: string | null;
43
+ };
44
+ export type BatchEnvelopeMetadata = {
45
+ batch_id: string;
46
+ idempotency_key: string;
47
+ sent_at: string;
48
+ };
49
+ export type { IngestRetryStrategy } from "./delivery-response.js";
50
+ export declare class IngestClient {
51
+ private readonly endpoint;
52
+ private readonly browserTokenEndpoint;
53
+ private readonly projectKey;
54
+ private readonly sourceIdentifier;
55
+ private readonly producerId;
56
+ private readonly sdkType;
57
+ private readonly sdkVersion;
58
+ private readonly schemaVersion;
59
+ private readonly metrics;
60
+ private readonly diagnostics;
61
+ private cachedToken;
62
+ private pendingTokenRequest;
63
+ constructor(options: IngestClientOptions);
64
+ send(events: BrowserSourceEvent[], options?: SendEventsOptions): Promise<SendEventsResult>;
65
+ measurePayloadBytes(events: BrowserSourceEvent[], metadata?: BatchEnvelopeMetadata): number;
66
+ private stringifyPayload;
67
+ private withSourceEvidence;
68
+ createBatchEnvelopeMetadata(): BatchEnvelopeMetadata;
69
+ hasUsableBrowserToken(nowMs?: number): boolean;
70
+ warmupBrowserToken(signal?: AbortSignal): Promise<boolean>;
71
+ private failedSend;
72
+ private recordDecision;
73
+ private postBatch;
74
+ private createId;
75
+ /**
76
+ * Obtain a browser ingest token directly from the Cluebase backend.
77
+ *
78
+ * The SDK POSTs `{ projectKey }` to
79
+ * `<endpoint>/api/v1/ingest/browser-tokens`; the browser stamps the `Origin`
80
+ * header automatically and the Cluebase backend validates project key + origin
81
+ * before minting a short-lived token.
82
+ *
83
+ * Tokens are cached in-memory until they are within
84
+ * `BROWSER_TOKEN_REFRESH_LEEWAY_MS` of expiry, at which point the next
85
+ * `ensureBrowserToken()` call transparently refreshes them. The function
86
+ * throws on transport / HTTP errors so the caller (`send`) can mark the
87
+ * batch as failed without bypassing the credentialled boundary.
88
+ */
89
+ private getUsableCachedBrowserToken;
90
+ private ensureBrowserToken;
91
+ private safeEnsureBrowserToken;
92
+ }
@@ -0,0 +1,541 @@
1
+ import { canonicalize as canonicalJsonStringify } from "json-canonicalize";
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";
5
+ import { classifyIngestHttpStatus, evaluateBrowserDeliveryResponse, } from "./delivery-response.js";
6
+ import { computeRetryDelay, RETRY_CONFIG } from "./retry.js";
7
+ import { appliedPrivacyPolicyToken, applyDeliveredPrivacyPolicy, } from "../provider/privacy-policy-store.js";
8
+ export const CLUEBASE_SDK_REQUEST_HEADER = "x-cluebase-sdk-request";
9
+ export const CLUEBASE_SDK_VERSION_HEADER = "x-cluebase-sdk-version";
10
+ export const CLUEBASE_SOURCE_SCHEMA_VERSION_HEADER = "x-cluebase-source-schema-version";
11
+ export const CLUEBASE_BROWSER_TOKEN_HEADER = "x-cluebase-browser-token";
12
+ export const CANONICAL_BROWSER_INGEST_PATH = "/api/v1/ingest/browser";
13
+ export const CANONICAL_BROWSER_TOKEN_PATH = "/api/v1/ingest/browser-tokens";
14
+ const CANONICAL_INGEST_PATH_SUFFIX = CANONICAL_BROWSER_INGEST_PATH.replace(/^\/api\/v1/, "");
15
+ export const resolveCanonicalIngestEndpoint = (endpoint) => {
16
+ if (!endpoint) {
17
+ return endpoint;
18
+ }
19
+ if (endpoint.endsWith(CANONICAL_INGEST_PATH_SUFFIX)) {
20
+ return endpoint;
21
+ }
22
+ // Strip a trailing slash if the host app provided only the base URL.
23
+ const trimmed = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
24
+ if (trimmed.endsWith("/api/v1")) {
25
+ return `${trimmed}${CANONICAL_INGEST_PATH_SUFFIX}`;
26
+ }
27
+ // Host apps typically pass the bare API origin (e.g. `http://api.example.com`
28
+ // or `http://127.0.0.1:4000`). Append the full canonical ingest path so the
29
+ // SDK can POST to a real Cluebase endpoint instead of the origin root. This
30
+ // mirrors `resolveBrowserTokenEndpoint`, which already rewrites the pathname
31
+ // unconditionally — keeping the two resolvers in sync prevents the SDK from
32
+ // silently posting events to `/` while still successfully fetching tokens.
33
+ try {
34
+ const parsed = new URL(trimmed);
35
+ if (parsed.pathname === "" || parsed.pathname === "/") {
36
+ parsed.pathname = CANONICAL_BROWSER_INGEST_PATH;
37
+ parsed.search = "";
38
+ parsed.hash = "";
39
+ return parsed.toString();
40
+ }
41
+ }
42
+ catch {
43
+ // Relative / malformed endpoints fall through unchanged.
44
+ }
45
+ return endpoint;
46
+ };
47
+ // Refresh the cached token this many milliseconds before its real expiry so
48
+ // transports do not race a token that expires mid-flight.
49
+ const BROWSER_TOKEN_REFRESH_LEEWAY_MS = 60000;
50
+ class BrowserTokenRequestError extends Error {
51
+ constructor(status) {
52
+ super("browser_token_request_failed");
53
+ this.status = status;
54
+ this.name = "BrowserTokenRequestError";
55
+ }
56
+ }
57
+ export const resolveBrowserTokenEndpoint = (endpoint) => {
58
+ if (!endpoint) {
59
+ return CANONICAL_BROWSER_TOKEN_PATH;
60
+ }
61
+ // `endpoint` may already point at an ingest path. Derive the API root, then
62
+ // append the canonical browser-tokens path.
63
+ try {
64
+ const parsed = new URL(endpoint);
65
+ parsed.pathname = CANONICAL_BROWSER_TOKEN_PATH;
66
+ parsed.search = "";
67
+ parsed.hash = "";
68
+ return parsed.toString();
69
+ }
70
+ catch {
71
+ // Fall back to a string-based rewrite for unusual / relative endpoints.
72
+ const trimmed = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
73
+ const apiV1Idx = trimmed.indexOf("/api/v1");
74
+ if (apiV1Idx >= 0) {
75
+ return `${trimmed.slice(0, apiV1Idx)}${CANONICAL_BROWSER_TOKEN_PATH}`;
76
+ }
77
+ return `${trimmed}${CANONICAL_BROWSER_TOKEN_PATH}`;
78
+ }
79
+ };
80
+ const NORMAL_ATTEMPT_TIMEOUT_MS = 15000;
81
+ export class IngestClient {
82
+ constructor(options) {
83
+ this.sourceIdentifier = DEFAULT_FRONTEND_SOURCE_IDENTIFIER;
84
+ this.producerId = DEFAULT_FRONTEND_SOURCE_IDENTIFIER;
85
+ // Memory-only token cache. Refreshed before expiry so the next ingest POST
86
+ // always has a live token.
87
+ this.cachedToken = null;
88
+ this.pendingTokenRequest = null;
89
+ this.endpoint = resolveCanonicalIngestEndpoint(options.endpoint);
90
+ this.browserTokenEndpoint = resolveBrowserTokenEndpoint(options.endpoint);
91
+ this.projectKey = options.projectKey;
92
+ this.sdkType = options.sdkType;
93
+ this.sdkVersion = options.sdkVersion;
94
+ this.schemaVersion = options.schemaVersion;
95
+ this.metrics = options.metrics ?? null;
96
+ this.diagnostics = options.diagnostics ?? null;
97
+ }
98
+ async send(events, options = {}) {
99
+ if (!hasDeadlineRemaining(options.deadlineAt)) {
100
+ return this.failedSend(null, 0, "retry_same_batch");
101
+ }
102
+ const sourceSignalEvents = events.filter(isBrowserSourceSignalEvent);
103
+ const droppedEventCount = events.length - sourceSignalEvents.length;
104
+ if (droppedEventCount > 0) {
105
+ this.metrics?.increment("dropped_count", droppedEventCount);
106
+ }
107
+ if (sourceSignalEvents.length === 0) {
108
+ return {
109
+ ok: true,
110
+ status: null,
111
+ attempts: 0,
112
+ governor: null,
113
+ retryStrategy: null,
114
+ };
115
+ }
116
+ const maxPayloadBytes = Math.max(1024, options.maxPayloadBytes ?? Number.MAX_SAFE_INTEGER);
117
+ const metadata = options.metadata ?? this.createBatchEnvelopeMetadata();
118
+ const payload = this.stringifyPayload(sourceSignalEvents, metadata);
119
+ const keepalive = options.keepalive ?? options.useBeacon ?? false;
120
+ const payloadSize = new TextEncoder().encode(payload).byteLength;
121
+ this.metrics?.increment("payload_size_bytes", payloadSize);
122
+ if (payloadSize > maxPayloadBytes) {
123
+ this.metrics?.increment("dropped_count", events.length);
124
+ this.metrics?.increment("send_failure_count");
125
+ this.metrics?.increment("transport_failed_count");
126
+ return {
127
+ ok: false,
128
+ status: null,
129
+ attempts: 0,
130
+ governor: null,
131
+ retryStrategy: null,
132
+ };
133
+ }
134
+ const browserTokenResult = await this.safeEnsureBrowserToken({
135
+ signal: options.signal,
136
+ requireCachedToken: options.requireCachedBrowserToken,
137
+ deadlineAt: options.deadlineAt,
138
+ });
139
+ if (!browserTokenResult.ok) {
140
+ this.metrics?.increment("send_failure_count");
141
+ this.metrics?.increment("transport_failed_count");
142
+ return {
143
+ ok: false,
144
+ status: null,
145
+ attempts: 0,
146
+ governor: null,
147
+ retryStrategy: browserTokenResult.retryStrategy ??
148
+ (options.requireCachedBrowserToken ? "drop_event" : null),
149
+ };
150
+ }
151
+ let browserToken = browserTokenResult.token;
152
+ let refreshedToken = false;
153
+ let lastStatus = null;
154
+ for (let attempts = 1; attempts <= RETRY_CONFIG.maxAttempts; attempts += 1) {
155
+ if (options.signal?.aborted ||
156
+ !hasDeadlineRemaining(options.deadlineAt)) {
157
+ return this.failedSend(lastStatus, attempts - 1, isConsentAbort(options.signal) ? "drop_event" : "retry_same_batch");
158
+ }
159
+ const attempt = await this.postBatch({
160
+ payload,
161
+ browserToken,
162
+ keepalive,
163
+ signal: options.signal,
164
+ deadlineAt: options.deadlineAt,
165
+ localSubmittedCount: sourceSignalEvents.length,
166
+ });
167
+ lastStatus = attempt.status;
168
+ const decision = attempt.decision;
169
+ this.recordDecision(decision, lastStatus, attempts, payload);
170
+ if (decision.ok) {
171
+ if (decision.droppedEventCount > 0) {
172
+ this.metrics?.increment("ingest_dropped_event_count", decision.droppedEventCount);
173
+ }
174
+ this.metrics?.increment("sent_count", decision.acceptedEventCount);
175
+ return {
176
+ ok: true,
177
+ status: lastStatus,
178
+ attempts,
179
+ governor: decision.governor,
180
+ retryStrategy: null,
181
+ observationIngestBatchId: decision.observationIngestBatchId,
182
+ partial: decision.acceptedPartial,
183
+ droppedEventCount: decision.droppedEventCount,
184
+ reason: decision.reason,
185
+ };
186
+ }
187
+ if (decision.droppedEventCount > 0) {
188
+ this.metrics?.increment("ingest_dropped_event_count", decision.droppedEventCount);
189
+ }
190
+ if (!options.requireCachedBrowserToken &&
191
+ !refreshedToken &&
192
+ (decision.reason === "expired_browser_token" ||
193
+ decision.reason === "stale_browser_token")) {
194
+ this.cachedToken = null;
195
+ const refreshed = await this.safeEnsureBrowserToken({
196
+ signal: options.signal,
197
+ deadlineAt: options.deadlineAt,
198
+ });
199
+ if (!refreshed.ok) {
200
+ return this.failedSend(lastStatus, attempts, refreshed.retryStrategy ?? "retry_same_batch");
201
+ }
202
+ browserToken = refreshed.token;
203
+ refreshedToken = true;
204
+ continue;
205
+ }
206
+ if (decision.retryStrategy !== "retry_same_batch" ||
207
+ attempts === RETRY_CONFIG.maxAttempts) {
208
+ return this.failedSend(lastStatus, attempts, decision.retryStrategy, decision.governor);
209
+ }
210
+ this.metrics?.increment("retry_count");
211
+ this.metrics?.increment("transport_retry_count");
212
+ this.diagnostics?.record({
213
+ code: "retry_scheduled",
214
+ strategy: "retry_same_batch",
215
+ http_status: lastStatus ?? undefined,
216
+ attempt: attempts,
217
+ batch_fingerprint: this.diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
218
+ });
219
+ if (!(await sleep(computeRetryDelay(attempts, RETRY_CONFIG), options.signal, options.deadlineAt))) {
220
+ return this.failedSend(lastStatus, attempts, isConsentAbort(options.signal) ? "drop_event" : "retry_same_batch");
221
+ }
222
+ }
223
+ return this.failedSend(lastStatus, RETRY_CONFIG.maxAttempts, "retry_same_batch");
224
+ }
225
+ measurePayloadBytes(events, metadata = {
226
+ batch_id: "batch_00000000-0000-4000-8000-000000000000",
227
+ idempotency_key: "idem_00000000-0000-4000-8000-000000000000",
228
+ sent_at: "2026-01-01T00:00:00.000Z",
229
+ }) {
230
+ return new TextEncoder().encode(this.stringifyPayload(events, metadata))
231
+ .byteLength;
232
+ }
233
+ stringifyPayload(events, metadata) {
234
+ // Emit the canonical batch envelope against `/ingest/browser`. Field names
235
+ // follow the camelCase `observationSourceEventBatchRequestSchema`.
236
+ return JSON.stringify({
237
+ sourceType: "frontend_sdk",
238
+ sourceSchemaVersion: String(this.schemaVersion),
239
+ batchId: metadata.batch_id,
240
+ idempotencyKey: metadata.idempotency_key,
241
+ sentAt: metadata.sent_at,
242
+ // いま適用している方針の合印。受付側は違う時だけ本体を返す。持っていなければ
243
+ // 省く(= まだ全部伏せている)ので、受付側は必ず本体を返してくれる。
244
+ privacyPolicyToken: appliedPrivacyPolicyToken(),
245
+ producerMetadata: {
246
+ producer_id: this.producerId,
247
+ sdk_type: this.sdkType,
248
+ sdk_version: this.sdkVersion,
249
+ },
250
+ events: events.map((event) => this.withSourceEvidence(event)),
251
+ });
252
+ }
253
+ withSourceEvidence(event) {
254
+ // Privacy metadata is explicit at the transport boundary. Re-apply the
255
+ // strictest fallback so malformed upstream events never ship without
256
+ // privacy declaration.
257
+ return {
258
+ ...event,
259
+ source_event_id: event.source_event_id ?? event.event_id,
260
+ source_schema_version: String(this.schemaVersion),
261
+ source_event_type: SDK_SOURCE_SIGNAL_EVENT_TYPE,
262
+ source_event_kind: SDK_SOURCE_SIGNAL_EVENT_TYPE,
263
+ event_name: SDK_SOURCE_SIGNAL_EVENT_NAME,
264
+ event_category: "context",
265
+ producer_id: this.producerId,
266
+ service_name: event.service_name ?? this.sourceIdentifier,
267
+ privacy_state: event.privacy_state ?? "masked",
268
+ privacy_level: event.privacy_level ?? "default_masked",
269
+ masking_state: event.masking_state ?? "masked",
270
+ allowed_value_keys: event.allowed_value_keys ?? [],
271
+ };
272
+ }
273
+ createBatchEnvelopeMetadata() {
274
+ const id = this.createId();
275
+ return {
276
+ batch_id: `batch_${id}`,
277
+ idempotency_key: `idem_${id}`,
278
+ sent_at: new Date().toISOString(),
279
+ };
280
+ }
281
+ hasUsableBrowserToken(nowMs = Date.now()) {
282
+ return this.getUsableCachedBrowserToken(nowMs) !== null;
283
+ }
284
+ async warmupBrowserToken(signal) {
285
+ const result = await this.safeEnsureBrowserToken({ signal });
286
+ return result.ok;
287
+ }
288
+ failedSend(status, attempts, retryStrategy, governor = null) {
289
+ this.metrics?.increment("send_failure_count");
290
+ this.metrics?.increment("transport_failed_count");
291
+ return { ok: false, status, attempts, governor, retryStrategy };
292
+ }
293
+ recordDecision(decision, status, attempt, payload) {
294
+ if (!this.diagnostics)
295
+ return;
296
+ if (decision.reason === "invalid_response") {
297
+ this.diagnostics.record({
298
+ code: "invalid_response",
299
+ http_status: status ?? undefined,
300
+ attempt,
301
+ batch_fingerprint: this.diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
302
+ });
303
+ return;
304
+ }
305
+ if (status !== null && status >= 300 && status < 400) {
306
+ this.diagnostics.record({
307
+ code: "protocol_redirect",
308
+ http_status: status,
309
+ attempt,
310
+ batch_fingerprint: this.diagnostics.batchFingerprint(diagnosticCanonicalPayload(payload)),
311
+ });
312
+ }
313
+ }
314
+ async postBatch(input) {
315
+ try {
316
+ const response = await fetchWithDeadline(this.endpoint, {
317
+ method: "POST",
318
+ headers: {
319
+ "content-type": "application/json",
320
+ "x-cluebase-project-key": this.projectKey,
321
+ [CLUEBASE_BROWSER_TOKEN_HEADER]: input.browserToken,
322
+ [CLUEBASE_SDK_REQUEST_HEADER]: this.sdkType,
323
+ [CLUEBASE_SDK_VERSION_HEADER]: this.sdkVersion,
324
+ [CLUEBASE_SOURCE_SCHEMA_VERSION_HEADER]: String(this.schemaVersion),
325
+ },
326
+ body: input.payload,
327
+ keepalive: input.keepalive,
328
+ credentials: "omit",
329
+ redirect: "error",
330
+ }, input.signal, input.deadlineAt);
331
+ const decision = evaluateBrowserDeliveryResponse({
332
+ status: response.status,
333
+ bodyText: await response.text(),
334
+ localSubmittedCount: input.localSubmittedCount,
335
+ });
336
+ // 受付側が方針を返した時だけ適用する。読めない形なら全部伏せた側へ戻る。
337
+ if (decision.privacyPolicy !== undefined) {
338
+ applyDeliveredPrivacyPolicy(decision.privacyPolicy);
339
+ }
340
+ return {
341
+ status: response.status,
342
+ decision,
343
+ };
344
+ }
345
+ catch {
346
+ return {
347
+ status: null,
348
+ decision: {
349
+ ok: false,
350
+ retryStrategy: isConsentAbort(input.signal)
351
+ ? "drop_event"
352
+ : "retry_same_batch",
353
+ governor: null,
354
+ droppedEventCount: 0,
355
+ acceptedEventCount: 0,
356
+ acceptedPartial: false,
357
+ reason: isConsentAbort(input.signal)
358
+ ? "consent_denied"
359
+ : "transport_timeout",
360
+ },
361
+ };
362
+ }
363
+ }
364
+ createId() {
365
+ return generateEventId();
366
+ }
367
+ /**
368
+ * Obtain a browser ingest token directly from the Cluebase backend.
369
+ *
370
+ * The SDK POSTs `{ projectKey }` to
371
+ * `<endpoint>/api/v1/ingest/browser-tokens`; the browser stamps the `Origin`
372
+ * header automatically and the Cluebase backend validates project key + origin
373
+ * before minting a short-lived token.
374
+ *
375
+ * Tokens are cached in-memory until they are within
376
+ * `BROWSER_TOKEN_REFRESH_LEEWAY_MS` of expiry, at which point the next
377
+ * `ensureBrowserToken()` call transparently refreshes them. The function
378
+ * throws on transport / HTTP errors so the caller (`send`) can mark the
379
+ * batch as failed without bypassing the credentialled boundary.
380
+ */
381
+ getUsableCachedBrowserToken(nowMs = Date.now()) {
382
+ if (this.cachedToken &&
383
+ this.cachedToken.expiresAtMs > nowMs + BROWSER_TOKEN_REFRESH_LEEWAY_MS) {
384
+ return this.cachedToken.token;
385
+ }
386
+ return null;
387
+ }
388
+ async ensureBrowserToken(signal, deadlineAt) {
389
+ const nowMs = Date.now();
390
+ const cachedToken = this.getUsableCachedBrowserToken(nowMs);
391
+ if (cachedToken) {
392
+ return cachedToken;
393
+ }
394
+ if (this.pendingTokenRequest) {
395
+ return await awaitWithAbort(this.pendingTokenRequest, signal);
396
+ }
397
+ // `x-cluebase-project-key` lets the Cluebase API resolve project-scoped CORS and
398
+ // token issuance consistently with the JSON body.
399
+ const request = (async () => {
400
+ const response = await fetchWithDeadline(this.browserTokenEndpoint, {
401
+ method: "POST",
402
+ headers: {
403
+ "content-type": "application/json",
404
+ "x-cluebase-project-key": this.projectKey,
405
+ },
406
+ body: JSON.stringify({
407
+ projectKey: this.projectKey,
408
+ }),
409
+ credentials: "omit",
410
+ redirect: "error",
411
+ }, signal, deadlineAt);
412
+ if (!response.ok) {
413
+ throw new BrowserTokenRequestError(response.status);
414
+ }
415
+ const tokenBody = asObject(await response.json());
416
+ const token = typeof tokenBody?.token === "string" ? tokenBody.token.trim() : "";
417
+ const expiresAt = typeof tokenBody?.expiresAt === "string" ? tokenBody.expiresAt : "";
418
+ const expiresAtMs = Date.parse(expiresAt);
419
+ if (token.length === 0 || Number.isNaN(expiresAtMs)) {
420
+ throw new BrowserTokenRequestError(response.status);
421
+ }
422
+ this.cachedToken = {
423
+ token,
424
+ expiresAtMs,
425
+ };
426
+ return token;
427
+ })();
428
+ this.pendingTokenRequest = request;
429
+ try {
430
+ return await request;
431
+ }
432
+ finally {
433
+ if (this.pendingTokenRequest === request) {
434
+ this.pendingTokenRequest = null;
435
+ }
436
+ }
437
+ }
438
+ async safeEnsureBrowserToken(options = {}) {
439
+ if (options.requireCachedToken) {
440
+ const cachedToken = this.getUsableCachedBrowserToken();
441
+ if (cachedToken) {
442
+ return { ok: true, token: cachedToken };
443
+ }
444
+ this.metrics?.increment("browser_token_missing_cached_count");
445
+ return { ok: false, retryStrategy: "drop_event" };
446
+ }
447
+ try {
448
+ return {
449
+ ok: true,
450
+ token: await this.ensureBrowserToken(options.signal, options.deadlineAt),
451
+ };
452
+ }
453
+ catch (error) {
454
+ this.metrics?.increment("browser_token_failure_count");
455
+ if (isConsentAbort(options.signal)) {
456
+ return { ok: false, retryStrategy: "drop_event" };
457
+ }
458
+ // A transport failure carries no status and may succeed later. Anything
459
+ // that did reach Cluebase is classified by the same owner batch delivery
460
+ // uses, so a refusal the token endpoint will repeat forever — a malformed
461
+ // body, a bad key, a wrong path — stops instead of being replayed.
462
+ if (!(error instanceof BrowserTokenRequestError) || error.status === null) {
463
+ return { ok: false, retryStrategy: "retry_same_batch" };
464
+ }
465
+ return {
466
+ ok: false,
467
+ retryStrategy: classifyIngestHttpStatus(error.status),
468
+ };
469
+ }
470
+ }
471
+ }
472
+ const asObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
473
+ ? value
474
+ : null;
475
+ const diagnosticCanonicalPayload = (payload) => {
476
+ try {
477
+ return canonicalJsonStringify(JSON.parse(payload));
478
+ }
479
+ catch {
480
+ return "{}";
481
+ }
482
+ };
483
+ const isConsentAbort = (signal) => signal?.aborted === true && signal.reason === CONSENT_DENIED_ABORT_REASON;
484
+ const sleep = (milliseconds, signal, deadlineAt) => new Promise((resolve) => {
485
+ const remaining = remainingDeadlineMs(deadlineAt);
486
+ if (signal?.aborted || (remaining !== null && remaining <= 0)) {
487
+ resolve(false);
488
+ return;
489
+ }
490
+ const abort = () => {
491
+ clearTimeout(timeout);
492
+ resolve(false);
493
+ };
494
+ const timeoutMs = Math.min(milliseconds, remaining ?? milliseconds);
495
+ const timeout = setTimeout(() => {
496
+ signal?.removeEventListener("abort", abort);
497
+ resolve(timeoutMs === milliseconds);
498
+ }, timeoutMs);
499
+ signal?.addEventListener("abort", abort, { once: true });
500
+ });
501
+ const awaitWithAbort = async (promise, signal) => {
502
+ if (!signal)
503
+ return await promise;
504
+ if (signal.aborted)
505
+ throw new DOMException("aborted", "AbortError");
506
+ return await new Promise((resolve, reject) => {
507
+ const abort = () => reject(new DOMException("aborted", "AbortError"));
508
+ signal.addEventListener("abort", abort, { once: true });
509
+ void promise.then((value) => {
510
+ signal.removeEventListener("abort", abort);
511
+ resolve(value);
512
+ }, (error) => {
513
+ signal.removeEventListener("abort", abort);
514
+ reject(error);
515
+ });
516
+ });
517
+ };
518
+ const fetchWithDeadline = async (input, init, externalSignal, deadlineAt) => {
519
+ const controller = new AbortController();
520
+ const abort = () => controller.abort();
521
+ if (externalSignal?.aborted)
522
+ abort();
523
+ externalSignal?.addEventListener("abort", abort, { once: true });
524
+ const remaining = remainingDeadlineMs(deadlineAt);
525
+ if (remaining !== null && remaining <= 0)
526
+ abort();
527
+ const timeout = setTimeout(abort, Math.min(NORMAL_ATTEMPT_TIMEOUT_MS, remaining ?? NORMAL_ATTEMPT_TIMEOUT_MS));
528
+ try {
529
+ return await fetch(input, { ...init, signal: controller.signal });
530
+ }
531
+ finally {
532
+ clearTimeout(timeout);
533
+ externalSignal?.removeEventListener("abort", abort);
534
+ }
535
+ };
536
+ const monotonicNow = () => typeof performance !== "undefined" ? performance.now() : Date.now();
537
+ const remainingDeadlineMs = (deadlineAt) => deadlineAt === undefined ? null : Math.max(0, deadlineAt - monotonicNow());
538
+ const hasDeadlineRemaining = (deadlineAt) => {
539
+ const remaining = remainingDeadlineMs(deadlineAt);
540
+ return remaining === null || remaining > 0;
541
+ };