@maple-dev/browser 0.9.0 → 0.10.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.
@@ -82,6 +82,104 @@ function scrubUrl(url) {
82
82
  return out;
83
83
  }
84
84
  //#endregion
85
+ //#region ../browser-session/src/platform/cookie.ts
86
+ /** Scope cookies to the registered domain so subdomains share them. */
87
+ let crossSubdomainCookie = true;
88
+ /** Explicit `Domain=` override; `""` forces a host-only cookie. */
89
+ let cookieDomainOverride;
90
+ /** Memoized probe result. `undefined` = not resolved yet. */
91
+ let probedCookieDomain;
92
+ /**
93
+ * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
94
+ * both SDKs get it from the single call they already make.
95
+ *
96
+ * Like the consent gates, this only ever *tightens*: an app that initializes two
97
+ * SDKs, only one of which passes a `privacy` block, must not have the other's
98
+ * absent option widen the cookie back out to every subdomain.
99
+ *
100
+ * "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
101
+ * first-write-wins: `""` (host-only) is the tightest value there is, and a
102
+ * second SDK asking for it has to win over an earlier `"example.com"`. Between
103
+ * two non-empty domains the shorter one is the broader (`example.com` covers
104
+ * `app.example.com`, not the reverse), so the longer string wins.
105
+ */
106
+ function configureCookieScope(options) {
107
+ if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
108
+ if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
109
+ probedCookieDomain = void 0;
110
+ }
111
+ /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
112
+ function tighterCookieDomain(current, next) {
113
+ if (current === void 0) return next;
114
+ if (current === "" || next === "") return "";
115
+ return next.length > current.length ? next : current;
116
+ }
117
+ function readRawCookie(name) {
118
+ if (typeof document === "undefined") return void 0;
119
+ try {
120
+ for (const part of document.cookie.split(";")) {
121
+ const raw = part.trim();
122
+ if (!raw.startsWith(`${name}=`)) continue;
123
+ return decodeURIComponent(raw.slice(name.length + 1));
124
+ }
125
+ } catch {}
126
+ }
127
+ function setRawCookie(name, value, domain, maxAgeSeconds) {
128
+ if (typeof document === "undefined") return false;
129
+ const attributes = [
130
+ `${name}=${encodeURIComponent(value)}`,
131
+ "path=/",
132
+ `max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
133
+ "SameSite=Lax"
134
+ ];
135
+ if (domain) attributes.push(`domain=.${domain}`);
136
+ if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
137
+ try {
138
+ document.cookie = attributes.join("; ");
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ /** Expire `name` on the shared domain and host-only: it may predate a narrowed scope. */
145
+ function deleteRawCookie(name) {
146
+ const domain = cookieDomain();
147
+ setRawCookie(name, "", domain, 0);
148
+ if (domain) setRawCookie(name, "", "", 0);
149
+ }
150
+ /**
151
+ * The broadest domain this browser will actually accept a cookie for, found by
152
+ * probing rather than by carrying a public-suffix list, the same trick
153
+ * posthog-js uses. Candidates start at the broadest (the last two labels) and
154
+ * narrow a label at a time, with the first that sticks winning, so
155
+ * `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
156
+ *
157
+ * Returns `""` (host-only cookie) for single-label hosts like `localhost` and
158
+ * for bare IPs, neither of which can carry a `Domain=` attribute.
159
+ */
160
+ function probeCookieDomain() {
161
+ if (typeof document === "undefined" || typeof location === "undefined") return "";
162
+ const hostname = location.hostname;
163
+ if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
164
+ const parts = hostname.split(".");
165
+ if (parts.length < 2) return "";
166
+ for (let i = parts.length - 2; i >= 0; i--) {
167
+ const candidate = parts.slice(i).join(".");
168
+ const probe = "__maple_probe";
169
+ if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
170
+ setRawCookie(probe, "", candidate, 0);
171
+ return candidate;
172
+ }
173
+ }
174
+ return "";
175
+ }
176
+ function cookieDomain() {
177
+ if (cookieDomainOverride !== void 0) return cookieDomainOverride;
178
+ if (!crossSubdomainCookie) return "";
179
+ if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
180
+ return probedCookieDomain;
181
+ }
182
+ //#endregion
85
183
  //#region ../browser-session/src/platform/json.ts
86
184
  /**
87
185
  * Minimal structural guards for records read back out of browser storage.
@@ -126,6 +224,88 @@ function isStringRecord(value) {
126
224
  return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
127
225
  }
128
226
  //#endregion
227
+ //#region ../browser-session/src/session/idle.ts
228
+ /**
229
+ * Rotate the session after this much inactivity (PostHog's default). The visit
230
+ * claim uses the same window: longer and a rotated session goes unbilled,
231
+ * shorter and one continuous visit is billed twice.
232
+ */
233
+ const IDLE_TIMEOUT_MS = 18e5;
234
+ //#endregion
235
+ //#region ../browser-session/src/identity/visit.ts
236
+ /**
237
+ * The billable unit: one visit per visitor per idle window, across every tab and
238
+ * subdomain. `SessionId` cannot be that unit: sessionStorage scopes it to one
239
+ * tab and one origin, and it must stay that way because `session_replays`
240
+ * resolves each `(OrgId, SessionId)` by `argMax(field, Version)` over the whole
241
+ * row, so two tabs sharing an id would overwrite each other.
242
+ *
243
+ * The marker is the visit's latest activity, not the moment it was claimed:
244
+ * active tabs keep extending it, so a tab opened an hour into a busy visit is
245
+ * not a second charge, and 30 idle minutes (a session rotation) start a new one.
246
+ */
247
+ const STORAGE_KEY$2 = "maple.visit";
248
+ /** Cookie names cannot contain `.` per RFC 6265's token grammar. */
249
+ const COOKIE_NAME$1 = "maple_visit";
250
+ /** In-memory copy: all a page load has when persistence is off or blocked. */
251
+ let ephemeral$1;
252
+ function parseMarker(raw) {
253
+ if (!raw) return void 0;
254
+ const parsed = Number.parseInt(raw, 10);
255
+ return Number.isFinite(parsed) ? parsed : void 0;
256
+ }
257
+ function readMarker(persist) {
258
+ if (!persist) return ephemeral$1;
259
+ const fromCookie = parseMarker(readRawCookie(COOKIE_NAME$1));
260
+ if (fromCookie !== void 0) return fromCookie;
261
+ try {
262
+ const fromStorage = parseMarker(window.localStorage.getItem(STORAGE_KEY$2));
263
+ if (fromStorage !== void 0) return fromStorage;
264
+ } catch {}
265
+ return ephemeral$1;
266
+ }
267
+ function writeMarker(at, persist) {
268
+ ephemeral$1 = at;
269
+ if (!persist) return;
270
+ const value = String(at);
271
+ try {
272
+ window.localStorage.setItem(STORAGE_KEY$2, value);
273
+ } catch {}
274
+ setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - at)) / 1e3);
275
+ }
276
+ function withinVisit(marker, at) {
277
+ return marker !== void 0 && Math.abs(at - marker) < 18e5;
278
+ }
279
+ /**
280
+ * Claim the visit `at` belongs to, returning whether this caller is the one
281
+ * charged for it. `persist` is false when visitor tracking is off, which keeps
282
+ * the claim in memory: no identifier-adjacent cookie the host app opted out of.
283
+ */
284
+ function claimVisit(at, persist) {
285
+ if (typeof window === "undefined") return false;
286
+ const marker = readMarker(persist);
287
+ if (withinVisit(marker, at)) {
288
+ if (at > marker) writeMarker(at, persist);
289
+ return false;
290
+ }
291
+ writeMarker(at, persist);
292
+ return true;
293
+ }
294
+ /** Extend the current visit to `at`. Never opens one: only `claimVisit` does. */
295
+ function touchVisit(at, persist) {
296
+ if (typeof window === "undefined") return;
297
+ const marker = readMarker(persist);
298
+ if (withinVisit(marker, at) && at > marker) writeMarker(at, persist);
299
+ }
300
+ /** Drop the claim from every store, for a visitor-tracking opt-out. */
301
+ function clearVisitClaim() {
302
+ ephemeral$1 = void 0;
303
+ try {
304
+ window.localStorage.removeItem(STORAGE_KEY$2);
305
+ } catch {}
306
+ deleteRawCookie(COOKIE_NAME$1);
307
+ }
308
+ //#endregion
129
309
  //#region ../browser-session/src/identity/visitor.ts
130
310
  /**
131
311
  * A persistent per-browser visitor id.
@@ -183,96 +363,11 @@ let enabled = true;
183
363
  let cached;
184
364
  let persisted = false;
185
365
  let mintedThisLoad = false;
186
- /** Scope the cookie to the registered domain so subdomains share the id. */
187
- let crossSubdomainCookie = true;
188
- /** Explicit `Domain=` override; `""` forces a host-only cookie. */
189
- let cookieDomainOverride;
190
- /** Memoized probe result. `undefined` = not resolved yet. */
191
- let probedCookieDomain;
192
366
  /**
193
- * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
194
- * both SDKs get it from the single call they already make.
195
- *
196
- * Like the consent gates, this only ever *tightens*: an app that initializes two
197
- * SDKs, only one of which passes a `privacy` block, must not have the other's
198
- * absent option widen the cookie back out to every subdomain.
199
- *
200
- * "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
201
- * first-write-wins: `""` (host-only) is the tightest value there is, and a
202
- * second SDK asking for it has to win over an earlier `"example.com"`. Between
203
- * two non-empty domains the shorter one is the broader — `example.com` covers
204
- * `app.example.com` and not the reverse — so the longer string wins.
205
- */
206
- function configureVisitorCookie(options) {
207
- if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
208
- if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
209
- probedCookieDomain = void 0;
210
- }
211
- /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
212
- function tighterCookieDomain(current, next) {
213
- if (current === void 0) return next;
214
- if (current === "" || next === "") return "";
215
- return next.length > current.length ? next : current;
216
- }
217
- function readRawCookie(name) {
218
- if (typeof document === "undefined") return void 0;
219
- try {
220
- for (const part of document.cookie.split(";")) {
221
- const raw = part.trim();
222
- if (!raw.startsWith(`${name}=`)) continue;
223
- return decodeURIComponent(raw.slice(name.length + 1));
224
- }
225
- } catch {}
226
- }
227
- function setRawCookie(name, value, domain, maxAgeSeconds) {
228
- if (typeof document === "undefined") return false;
229
- const attributes = [
230
- `${name}=${encodeURIComponent(value)}`,
231
- "path=/",
232
- `max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
233
- "SameSite=Lax"
234
- ];
235
- if (domain) attributes.push(`domain=.${domain}`);
236
- if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
237
- try {
238
- document.cookie = attributes.join("; ");
239
- return true;
240
- } catch {
241
- return false;
242
- }
243
- }
244
- /**
245
- * The broadest domain this browser will actually accept a cookie for, found by
246
- * probing rather than by carrying a public-suffix list — the same trick
247
- * posthog-js uses. Candidates start at the broadest (the last two labels) and
248
- * narrow a label at a time, with the first that sticks winning — so
249
- * `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
250
- *
251
- * Returns `""` (host-only cookie) for single-label hosts like `localhost` and
252
- * for bare IPs, neither of which can carry a `Domain=` attribute.
367
+ * Kept under this name because `configurePrivacy` and the SDK tests call it;
368
+ * the domain decision lives in `platform/cookie.ts`, shared with the visit claim.
253
369
  */
254
- function probeCookieDomain() {
255
- if (typeof document === "undefined" || typeof location === "undefined") return "";
256
- const hostname = location.hostname;
257
- if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
258
- const parts = hostname.split(".");
259
- if (parts.length < 2) return "";
260
- for (let i = parts.length - 2; i >= 0; i--) {
261
- const candidate = parts.slice(i).join(".");
262
- const probe = "__maple_probe";
263
- if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
264
- setRawCookie(probe, "", candidate, 0);
265
- return candidate;
266
- }
267
- }
268
- return "";
269
- }
270
- function cookieDomain() {
271
- if (cookieDomainOverride !== void 0) return cookieDomainOverride;
272
- if (!crossSubdomainCookie) return "";
273
- if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
274
- return probedCookieDomain;
275
- }
370
+ const configureVisitorCookie = configureCookieScope;
276
371
  function parseRecord(raw) {
277
372
  if (!raw) return void 0;
278
373
  const value = parseJsonObject(raw);
@@ -383,9 +478,8 @@ function setVisitorTracking(nextEnabled) {
383
478
  try {
384
479
  window.localStorage.removeItem(STORAGE_KEY$1);
385
480
  } catch {}
386
- const domain = cookieDomain();
387
- setRawCookie(COOKIE_NAME, "", domain, 0);
388
- if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
481
+ deleteRawCookie(COOKIE_NAME);
482
+ clearVisitClaim();
389
483
  }
390
484
  //#endregion
391
485
  //#region ../browser-session/src/events/trace-id.ts
@@ -568,11 +662,13 @@ const browserDocument = () => globalThis.document;
568
662
  const SDK_HINT_HEADER = "x-maple-sdk";
569
663
  /** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
570
664
  const sdkHint = (name, version) => `${name}/${version}`;
571
- /** Auth + identity headers shared by every ingest request. */
665
+ /** Auth + identity headers shared by every ingest request. No key, no `Authorization`. */
572
666
  function ingestHeaders(config) {
667
+ const identity = { [SDK_HINT_HEADER]: config.sdk };
668
+ if (!config.ingestKey) return identity;
573
669
  return {
574
670
  Authorization: `Bearer ${config.ingestKey}`,
575
- [SDK_HINT_HEADER]: config.sdk
671
+ ...identity
576
672
  };
577
673
  }
578
674
  const lastWarnAt = /* @__PURE__ */ new Map();
@@ -817,6 +913,7 @@ function buildSessionMetaRow(input) {
817
913
  resource_attributes: {
818
914
  "maple.session.recorded": input.recorded ? "true" : "false",
819
915
  "maple.session.replay_format": "rrweb",
916
+ ...input.recorded && input.replayTrigger ? { "maple.session.replay_trigger": input.replayTrigger } : void 0,
820
917
  ...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
821
918
  ...input.environment ? {
822
919
  "deployment.environment": input.environment,
@@ -826,6 +923,7 @@ function buildSessionMetaRow(input) {
826
923
  },
827
924
  visitor_id: input.visitorId ?? "",
828
925
  visitor_is_new: input.visitorIsNew ? 1 : 0,
926
+ billable_start: input.billableStart ? 1 : 0,
829
927
  user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
830
928
  user_name: identity?.username ?? "",
831
929
  group_id: identity?.groupId ?? "",
@@ -890,8 +988,6 @@ function approximateSize(value) {
890
988
  //#endregion
891
989
  //#region ../browser-session/src/session/session.ts
892
990
  const STORAGE_KEY = "maple.session";
893
- /** Rotate the session after this much inactivity (PostHog's default). */
894
- const IDLE_TIMEOUT_MS = 18e5;
895
991
  /** Hard cap on a single session's lifetime regardless of activity. */
896
992
  const MAX_SESSION_MS = 864e5;
897
993
  /**
@@ -949,10 +1045,19 @@ function parseSessionRecord(raw) {
949
1045
  if (typeof value.visitorIsNew !== "boolean") return void 0;
950
1046
  record.visitorIsNew = value.visitorIsNew;
951
1047
  }
1048
+ if (value.billable !== void 0) {
1049
+ if (typeof value.billable !== "boolean") return void 0;
1050
+ record.billable = value.billable;
1051
+ }
952
1052
  if (value.replaySampled !== void 0) {
953
1053
  if (typeof value.replaySampled !== "boolean") return void 0;
954
1054
  record.replaySampled = value.replaySampled;
955
1055
  }
1056
+ if (value.replayBuffered !== void 0) {
1057
+ if (typeof value.replayBuffered !== "boolean") return void 0;
1058
+ record.replayBuffered = value.replayBuffered;
1059
+ }
1060
+ if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger;
956
1061
  if (value.utm !== void 0) {
957
1062
  if (!isStringRecord(value.utm)) return void 0;
958
1063
  record.utm = value.utm;
@@ -968,6 +1073,12 @@ const UTM_KEYS = [
968
1073
  ];
969
1074
  /** In-memory fallback when sessionStorage is unavailable (private mode). */
970
1075
  let ephemeral;
1076
+ /**
1077
+ * Set while sessionStorage writes throw. On quota exhaustion reads still work,
1078
+ * so the stored copy is a frozen snapshot: `metaVersion` pinned at 1 billed
1079
+ * every heartbeat as a new session. Keep this separate from the read try/catch.
1080
+ */
1081
+ let writesFailed = false;
971
1082
  const rotationListeners = /* @__PURE__ */ new Set();
972
1083
  /**
973
1084
  * Read the entry URL/referrer/UTM of the current page.
@@ -1009,6 +1120,7 @@ function freshRecord(now) {
1009
1120
  };
1010
1121
  }
1011
1122
  function readRecord() {
1123
+ if (writesFailed && ephemeral) return ephemeral;
1012
1124
  try {
1013
1125
  const raw = window.sessionStorage.getItem(STORAGE_KEY);
1014
1126
  if (!raw) return void 0;
@@ -1021,7 +1133,10 @@ function writeRecord(record) {
1021
1133
  ephemeral = record;
1022
1134
  try {
1023
1135
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
1024
- } catch {}
1136
+ writesFailed = false;
1137
+ } catch {
1138
+ writesFailed = true;
1139
+ }
1025
1140
  claimTab(record.id);
1026
1141
  }
1027
1142
  const TAB_LEASE_PREFIX = "maple.session.tab.";
@@ -1123,7 +1238,7 @@ function ownedByAnotherTab(record, now) {
1123
1238
  }
1124
1239
  }
1125
1240
  function isSessionExpired(record, now = Date.now()) {
1126
- return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
1241
+ return now - record.lastActivityAt > 18e5 || now - record.startedAt > MAX_SESSION_MS;
1127
1242
  }
1128
1243
  /** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
1129
1244
  function onSessionRotate(listener) {
@@ -1234,6 +1349,22 @@ function noteCounts(counts) {
1234
1349
  });
1235
1350
  }
1236
1351
  /**
1352
+ * Whether `sessionId` is the session charged for its visit. `claim` runs once,
1353
+ * on the first row, and the answer is persisted so heartbeats, reloads and the
1354
+ * `ended` row all agree with it.
1355
+ */
1356
+ function resolveBillable(sessionId, claim) {
1357
+ const record = readRecord();
1358
+ if (!record || record.id !== sessionId) return false;
1359
+ if (record.billable !== void 0) return record.billable;
1360
+ const billable = claim();
1361
+ writeRecord({
1362
+ ...record,
1363
+ billable
1364
+ });
1365
+ return billable;
1366
+ }
1367
+ /**
1237
1368
  * The persisted session record as-is — no activity touch, no rotation. Use
1238
1369
  * this to read counters when posting a metadata row; `getSession()` would
1239
1370
  * rotate an idle session out from under the row being written.
@@ -1306,20 +1437,65 @@ function claimReplaySample(sampleRate) {
1306
1437
  return sampled;
1307
1438
  }
1308
1439
  /**
1440
+ * This session's replay mode: recorded at `sampleRate`, otherwise buffered for
1441
+ * errors at `onErrorSampleRate`. Rolled once per session and persisted, like
1442
+ * `claimReplaySample`.
1443
+ */
1444
+ function claimReplayMode(sampleRate, onErrorSampleRate) {
1445
+ if (claimReplaySample(sampleRate)) return "record";
1446
+ const record = readRecord() ?? getSession();
1447
+ if (record.replayBuffered === void 0) {
1448
+ const buffered = onErrorSampleRate > 0 && uniformRandom() < onErrorSampleRate;
1449
+ writeRecord({
1450
+ ...record,
1451
+ replayBuffered: buffered
1452
+ });
1453
+ return buffered ? "buffer" : "off";
1454
+ }
1455
+ return record.replayBuffered ? "buffer" : "off";
1456
+ }
1457
+ /** An error made this buffered session a recorded one: later loads record it from the start. */
1458
+ function markReplayTriggered(sessionId) {
1459
+ const record = readRecord();
1460
+ if (!record || record.id !== sessionId) return;
1461
+ writeRecord({
1462
+ ...record,
1463
+ replaySampled: true,
1464
+ replayTrigger: "error"
1465
+ });
1466
+ }
1467
+ /**
1309
1468
  * Pin a session to the capture mode a running page is already in. A session
1310
1469
  * minted by idle rotation mid-page inherits that page's mode rather than
1311
1470
  * rolling again, and later loads of it honour the same answer.
1312
1471
  */
1313
- function adoptReplayDecision(sessionId, recorded) {
1472
+ function adoptReplayDecision(sessionId, recorded, buffered) {
1314
1473
  const record = readRecord();
1315
1474
  if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
1316
1475
  writeRecord({
1317
1476
  ...record,
1318
- replaySampled: recorded
1477
+ replaySampled: recorded,
1478
+ ...buffered !== void 0 && !recorded ? { replayBuffered: buffered } : void 0
1319
1479
  });
1320
1480
  }
1321
1481
  //#endregion
1322
1482
  //#region ../browser-session/src/events/events-sink.ts
1483
+ /** On `globalThis` like the sink: every bundled copy of this module must see the same listeners. */
1484
+ const LISTENERS_KEY = "__MAPLE_SESSION_EVENT_LISTENERS__";
1485
+ function listeners() {
1486
+ const global = globalThis;
1487
+ let set = global[LISTENERS_KEY];
1488
+ if (!set) {
1489
+ set = /* @__PURE__ */ new Set();
1490
+ global[LISTENERS_KEY] = set;
1491
+ }
1492
+ return set;
1493
+ }
1494
+ /** Observe every event the live sink records, e.g. for an error's breadcrumb trail. Returns an unsubscribe. */
1495
+ function onSessionEvent(listener) {
1496
+ listeners().add(listener);
1497
+ return () => listeners().delete(listener);
1498
+ }
1323
1499
  const FLUSH_INTERVAL_MS = 5e3;
1324
1500
  const FLUSH_BYTES = 65536;
1325
1501
  /**
@@ -1386,6 +1562,9 @@ function startEventSink(config, sessionId) {
1386
1562
  });
1387
1563
  bufferBytes += approximateSize(ev);
1388
1564
  if (bufferBytes >= FLUSH_BYTES) flush();
1565
+ for (const listener of listeners()) try {
1566
+ listener(ev);
1567
+ } catch {}
1389
1568
  };
1390
1569
  const stopNavigation = installNavigationObserver((url) => {
1391
1570
  emit({
@@ -1546,7 +1725,7 @@ function toRow(config, sessionId, ev, seq) {
1546
1725
  * posted at session start, whose counters are all zero and therefore read as a
1547
1726
  * bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
1548
1727
  * every heartbeat is an unmerged part until the next merge. Billing meters only
1549
- * `version == 1` rows, so heartbeats do not double-bill.
1728
+ * `billable_start == 1 && version == 1` rows, so heartbeats do not double-bill.
1550
1729
  */
1551
1730
  const HEARTBEAT_INTERVAL_MS = 6e4;
1552
1731
  /**
@@ -1564,6 +1743,7 @@ function startSessionLifecycle(options, hooks) {
1564
1743
  let errorCountBase = 0;
1565
1744
  let sinkClickCountAtStart = 0;
1566
1745
  let sinkErrorCountAtStart = 0;
1746
+ const isRecorded = () => typeof hooks.recorded === "function" ? hooks.recorded() : hooks.recorded;
1567
1747
  /**
1568
1748
  * The persisted record of the session this lifecycle owns.
1569
1749
  *
@@ -1612,6 +1792,10 @@ function startSessionLifecycle(options, hooks) {
1612
1792
  const post = (status, keepalive) => {
1613
1793
  const record = liveRecord();
1614
1794
  const counts = countsFor(record);
1795
+ const visitorId = getVisitorId();
1796
+ const persistVisit = visitorId !== void 0;
1797
+ const billableStart = resolveBillable(record.id, () => claimVisit(Date.now(), persistVisit));
1798
+ touchVisit(record.lastActivityAt, persistVisit);
1615
1799
  hooks.post(buildSessionMetaRow({
1616
1800
  sessionId: record.id,
1617
1801
  startedAt: new Date(record.startedAt),
@@ -1622,7 +1806,7 @@ function startSessionLifecycle(options, hooks) {
1622
1806
  captureUserEmail: options.captureUserEmail,
1623
1807
  environment: options.environment,
1624
1808
  serviceVersion: options.serviceVersion,
1625
- visitorId: getVisitorId(),
1809
+ visitorId,
1626
1810
  visitorIsNew: record.visitorIsNew === true,
1627
1811
  visitorIdPersisted: isVisitorIdPersisted(),
1628
1812
  entry: entryContextOf(record),
@@ -1631,7 +1815,9 @@ function startSessionLifecycle(options, hooks) {
1631
1815
  pageViews: counts.pageViews,
1632
1816
  errorCount: counts.errorCount,
1633
1817
  traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
1634
- recorded: hooks.recorded
1818
+ recorded: isRecorded(),
1819
+ replayTrigger: record.replayTrigger,
1820
+ billableStart
1635
1821
  }), keepalive);
1636
1822
  };
1637
1823
  const stopHeartbeat = () => {
@@ -1643,7 +1829,7 @@ function startSessionLifecycle(options, hooks) {
1643
1829
  if (stopped || running) return;
1644
1830
  running = true;
1645
1831
  const record = liveRecord();
1646
- adoptReplayDecision(record.id, hooks.recorded);
1832
+ adoptReplayDecision(record.id, isRecorded(), hooks.buffered?.());
1647
1833
  rebaseCounts(record);
1648
1834
  hooks.onStart?.(record);
1649
1835
  post("active", false);
@@ -1705,6 +1891,9 @@ function startSessionLifecycle(options, hooks) {
1705
1891
  get sessionId() {
1706
1892
  return current.id;
1707
1893
  },
1894
+ announce: () => {
1895
+ if (running) post("active", false);
1896
+ },
1708
1897
  shutdown: async (shutdownOptions) => {
1709
1898
  if (stopped) return;
1710
1899
  stopped = true;
@@ -1788,4 +1977,4 @@ function readSessionSink() {
1788
1977
  return globalThis[SESSION_SINK_KEY];
1789
1978
  }
1790
1979
  //#endregion
1791
- export { setVisitorTracking as A, warnDropped as C, setActiveTraceIdProvider as D, activeTraceId as E, scrubUrl as M, withStartedTraceId as O, sdkHint as S, safeEmit as T, isLikelyBot as _, recordTraceId as a, postSessionBlob as b, getActiveSink as c, claimReplaySample as d, getSession as f, postSessionMetaRow as g, rotateSession as h, readSessionSink as i, addUrlSanitizer as j, configureVisitorCookie as k, queuePending as l, nextChunkSeq as m, getObservedTraceIds as n, startSessionLifecycle as o, markActivity as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, startEventSink as u, SDK_HINT_HEADER as v, BLOCK_SELECTOR as w, postSessionMeta as x, gzip as y };
1980
+ export { setActiveTraceIdProvider as A, postSessionMeta as C, BLOCK_SELECTOR as D, selectorOf as E, scrubUrl as F, configureVisitorCookie as M, setVisitorTracking as N, safeEmit as O, addUrlSanitizer as P, postSessionBlob as S, warnDropped as T, rotateSession as _, recordTraceId as a, gzip as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, withStartedTraceId as j, activeTraceId as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, sdkHint as w, ingestHeaders as x, isLikelyBot as y };