@corbado/observe 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/analysis/client.mjs +82 -56
  2. package/dist/analysis/config-manager.mjs +30 -5
  3. package/dist/analysis/device-info-manager.mjs +16 -1
  4. package/dist/analysis/device.mjs +88 -10
  5. package/dist/analysis/handoff.mjs +7 -0
  6. package/dist/analysis/low-events/grouped-input-tracker.mjs +148 -33
  7. package/dist/analysis/low-events/input-tracker.mjs +138 -59
  8. package/dist/analysis/low-events/shared-window-observer.mjs +148 -77
  9. package/dist/analysis/low-events/viewport-batcher.mjs +3 -3
  10. package/dist/analysis/queue.mjs +75 -8
  11. package/dist/analysis/{low-events/input-paste-observer.mjs → readers/input-paste.mjs} +2 -2
  12. package/dist/analysis/readers/input.mjs +61 -0
  13. package/dist/analysis/readers/loader-global.mjs +15 -0
  14. package/dist/analysis/readers/network.mjs +14 -0
  15. package/dist/analysis/readers/page.mjs +30 -0
  16. package/dist/analysis/readers/storage.mjs +28 -0
  17. package/dist/analysis/{utils → readers}/web-lock.mjs +6 -2
  18. package/dist/analysis/readers/window-events.mjs +48 -0
  19. package/dist/analysis/storage/index.mjs +61 -9
  20. package/dist/analysis/store/event-store.mjs +34 -16
  21. package/dist/analysis/transports/fetch.mjs +45 -3
  22. package/dist/analysis/utils/email-link-operation-full.mjs +22 -2
  23. package/dist/analysis/utils/email-otp-operation-full.mjs +0 -1
  24. package/dist/analysis/utils/form-operation-full.mjs +0 -1
  25. package/dist/analysis/utils/operation-full-provide-identifier-with-cui.mjs +6 -6
  26. package/dist/analysis/utils/passkey-enrollment-operation-full.mjs +9 -6
  27. package/dist/analysis/utils/passkey-login-operation-full.mjs +10 -5
  28. package/dist/analysis/utils/password-login-operation-full.mjs +8 -4
  29. package/dist/analysis/utils/password-manager-sampler.mjs +44 -4
  30. package/dist/analysis/utils/sms-otp-operation-full.mjs +0 -1
  31. package/dist/analysis/utils/totp-operation-full.mjs +0 -1
  32. package/dist/analysis/version.mjs +1 -1
  33. package/dist/cdn/script.global.js +4 -4
  34. package/dist/index.d.mts +131 -19
  35. package/dist/index.d.ts +131 -19
  36. package/dist/index.js +2 -2
  37. package/dist/index.mjs +2 -2
  38. package/package.json +5 -3
  39. package/tools/reader-meta-text.d.mts +2 -0
  40. package/tools/reader-meta-text.mjs +26 -0
  41. package/tools/strip-reader-meta.d.mts +33 -0
  42. package/tools/strip-reader-meta.mjs +131 -0
@@ -4,7 +4,8 @@ import {
4
4
  KeyRegistrationOperationFull
5
5
  } from "./utils/key-operation-full.mjs";
6
6
  import { reserveCaptureSeq } from "./low-events/capture-sequence.mjs";
7
- import { applySourcePathQuery } from "./utils/source-path.mjs";
7
+ import { readSourcePath } from "./readers/page.mjs";
8
+ import { readUserAgent } from "./readers/device.mjs";
8
9
  import { SharedWindowObserver, getCapturePageId } from "./low-events/shared-window-observer.mjs";
9
10
  import {
10
11
  AuthEventName
@@ -33,7 +34,7 @@ import { PasskeyLoginOperationFull } from "./utils/passkey-login-operation-full.
33
34
  import { EmailOtpOperationFull } from "./utils/email-otp-operation-full.mjs";
34
35
  import { SmsOtpOperationFull } from "./utils/sms-otp-operation-full.mjs";
35
36
  import { isBlockedUserAgent } from "./utils/blocked-user-agents.mjs";
36
- import { withWebLock } from "./utils/web-lock.mjs";
37
+ import { hasWebLocks, withWebLock } from "./readers/web-lock.mjs";
37
38
  import { SDK_NAME, SDK_VERSION } from "./version.mjs";
38
39
  import {
39
40
  EmailLinkContinuationOperation,
@@ -507,7 +508,23 @@ var CorbadoTracker = class _CorbadoTracker {
507
508
  if (typeof navigator === "undefined") {
508
509
  return false;
509
510
  }
510
- return isBlockedUserAgent(navigator.userAgent);
511
+ return isBlockedUserAgent(
512
+ readUserAgent({
513
+ key: "browser-api:sdk.blocked-user-agent",
514
+ title: "User agent check for crawlers and bots",
515
+ accessNote: "navigator.userAgent, matched against a list of crawler and bot patterns",
516
+ group: "Observe SDK state",
517
+ origin: "observe-external",
518
+ fields: [
519
+ {
520
+ path: "result",
521
+ label: "Whether the browser looks like a bot",
522
+ exposure: "computed",
523
+ transform: { note: "matched against the blocked user-agent list; only the boolean is used (tracking off)" }
524
+ }
525
+ ]
526
+ })
527
+ );
511
528
  }
512
529
  resolveSessionId() {
513
530
  try {
@@ -678,27 +695,74 @@ var CorbadoTracker = class _CorbadoTracker {
678
695
  return current;
679
696
  };
680
697
  const timeoutMs = this.config.seqLockTimeoutMs;
681
- if (timeoutMs === 0 || typeof navigator === "undefined" || !navigator.locks?.request) {
698
+ if (timeoutMs === 0 || !hasWebLocks({
699
+ key: "browser-api:sdk.web-locks",
700
+ title: "Coordination of Observe data across open tabs",
701
+ accessNote: "Web Locks API (navigator.locks) on Observe's own lock names",
702
+ group: "Observe SDK state",
703
+ origin: "observe-internal",
704
+ fields: [
705
+ {
706
+ path: "available",
707
+ label: "Whether the browser supports tab coordination",
708
+ exposure: "enum",
709
+ transform: { values: [true, false] }
710
+ }
711
+ ]
712
+ })) {
682
713
  return allocate();
683
714
  }
684
- return withWebLock(this.seqLockName, allocate, {
685
- timeoutMs,
686
- onTimeout: () => {
687
- this.logger.warn(`Sequence Web Lock acquisition timed out after ${timeoutMs}ms; using fallback`);
688
- this.reportTelemetry("info", `seq web lock timeout; using fallback, ${timeoutMs}ms`);
715
+ return withWebLock(
716
+ {
717
+ key: "browser-api:sdk.web-locks",
718
+ title: "Coordination of Observe data across open tabs",
719
+ accessNote: "Web Locks API (navigator.locks) on Observe's own lock names",
720
+ group: "Observe SDK state",
721
+ origin: "observe-internal",
722
+ fields: [
723
+ {
724
+ path: "result",
725
+ label: "Coordination between open tabs",
726
+ exposure: "none",
727
+ transform: { note: "own cbo_* lock names; serializes the SDK's storage updates across tabs" }
728
+ }
729
+ ]
730
+ },
731
+ this.seqLockName,
732
+ allocate,
733
+ {
734
+ timeoutMs,
735
+ onTimeout: () => {
736
+ this.logger.warn(`Sequence Web Lock acquisition timed out after ${timeoutMs}ms; using fallback`);
737
+ this.reportTelemetry("info", `seq web lock timeout; using fallback, ${timeoutMs}ms`);
738
+ }
689
739
  }
690
- });
740
+ );
691
741
  }
692
742
  resolveTrackingSourcePath() {
693
743
  if (typeof window === "undefined") {
694
744
  return void 0;
695
745
  }
696
- const search = applySourcePathQuery(
697
- window.location.search,
746
+ return readSourcePath(
747
+ {
748
+ key: "page:location.source-path",
749
+ title: "Page path and allowed query parameters",
750
+ accessNote: "location.pathname and location.search; the query is reduced by the sourcePathQuery policy inside the reader (trackingSourcePath)",
751
+ group: "Page",
752
+ origin: "observe-external",
753
+ fields: [
754
+ { path: "url.path", label: "Page path", exposure: "raw" },
755
+ {
756
+ path: "url.query",
757
+ label: "Page address parameters (allowed ones only)",
758
+ exposure: "redacted",
759
+ transform: { note: "sourcePathQuery 'allowlist': values of non-allowlisted keys become '*'; 'all': raw" }
760
+ }
761
+ ]
762
+ },
698
763
  this.config.sourcePathQuery,
699
764
  this.config.sourcePathQueryAllow
700
765
  );
701
- return `${window.location.pathname}${search}`;
702
766
  }
703
767
  /**
704
768
  * Per-tab id for event meta, cached after the first successful resolution: the id is
@@ -1535,56 +1599,18 @@ var CorbadoTracker = class _CorbadoTracker {
1535
1599
  smsOtpOperationFull(config) {
1536
1600
  return new SmsOtpOperationFull(this, config);
1537
1601
  }
1538
- /**
1539
- * Track a code from an authenticator app (TOTP) through its verification.
1540
- *
1541
- * @param config - Optional code input field(s) and spec type (`totp-login` or `totp-enrollment`).
1542
- * @remarks
1543
- * There is no send step: the code comes from the user's authenticator, not from the application.
1544
- * Report the verification request on postResponse. Bind the code input to capture typing and
1545
- * autofill; use setInputFields when the input is rendered after the operation starts.
1546
- * @example
1547
- * ```ts
1548
- * const totp = tracker.totpOperationFull({ explicitSpecType: "totp-login", inputHtmlField: codeInput });
1549
- * totp.postResponse.start({});
1550
- * totp.postResponse.finished({});
1551
- * ```
1552
- */
1553
1602
  totpOperationFull(config) {
1554
1603
  return new TotpOperationFull(this, config);
1555
1604
  }
1556
1605
  provideIdentifierOperationFull(config) {
1557
- return new OperationFullProvideIdentifierWithCUI(this, config);
1606
+ return new OperationFullProvideIdentifierWithCUI(
1607
+ this,
1608
+ config
1609
+ );
1558
1610
  }
1559
1611
  provideDataOperationFull(config) {
1560
1612
  return new ProvideDataOperationFull(this, config);
1561
1613
  }
1562
- /**
1563
- * Track one form: a page, step or modal where the user provides one coherent set of data and submits
1564
- * it, typically as one step of a custom process flow such as an onboarding.
1565
- *
1566
- * @param config - The form's stable `name` (required); optionally the names of its `fields`, the
1567
- * `inputHtmlFields` to collect interaction signals from, and `startedAt` when the form became
1568
- * visible earlier than this call.
1569
- * @remarks
1570
- * Create the operation when the form is shown; one operation per visit, so going back to a form
1571
- * creates a new one with the same name. Report `submit` when the application accepted the form, or
1572
- * `skip` when the user left it forward without submitting (e.g. "Do it later"). A form that ends with
1573
- * neither counts as abandoned; one whose last submit failed counts as failed. Without a name the form
1574
- * is not counted. Send field names only, never values. A form spread over several pages stays one
1575
- * operation: bind each page's inputs with `setInputFields`.
1576
- * @example
1577
- * ```ts
1578
- * const form = tracker.formOperationFull({ name: "personal-details", fields: ["firstName", "lastName"] });
1579
- * form.submit.start({});
1580
- * try {
1581
- * await savePersonalDetails(values);
1582
- * form.submit.finished({});
1583
- * } catch (error) {
1584
- * form.submit.error(error);
1585
- * }
1586
- * ```
1587
- */
1588
1614
  formOperationFull(config) {
1589
1615
  return new FormOperationFull(this, config);
1590
1616
  }
@@ -1829,7 +1855,7 @@ var PROBE_PAGE_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwx
1829
1855
  function randomProbePageId() {
1830
1856
  const bytes = new Uint8Array(16);
1831
1857
  try {
1832
- globalThis.crypto.getRandomValues(bytes);
1858
+ crypto.getRandomValues(bytes);
1833
1859
  } catch {
1834
1860
  for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
1835
1861
  }
@@ -1,5 +1,6 @@
1
1
  // src/config-manager.ts
2
2
  import { cacheConfig, loadCachedConfig, parseReliabilityConfig } from "./config.mjs";
3
+ import { sendFetch } from "./readers/network.mjs";
3
4
  var CONFIG_FETCH_TIMEOUT_MS = 10 * 1e3;
4
5
  var CONFIG_REFRESH_INTERVAL_MS = 10 * 60 * 1e3;
5
6
  var CONFIG_RETRY_DELAYS_MS = [1e3, 3e3];
@@ -36,11 +37,35 @@ var ConfigManager = class {
36
37
  this.requestTimer = controller ? setTimeout(() => controller.abort(), CONFIG_FETCH_TIMEOUT_MS) : void 0;
37
38
  let retryable = true;
38
39
  try {
39
- const response = await fetch(this.url, {
40
- method: "GET",
41
- cache: "no-store",
42
- ...controller ? { signal: controller.signal } : {}
43
- });
40
+ const response = await sendFetch(
41
+ {
42
+ key: "network:observe.sdk-config",
43
+ title: "Observe settings download",
44
+ accessNote: "fetch to the Observe API; the response is the SDK reliability config",
45
+ group: "Event delivery",
46
+ origin: "observe-internal",
47
+ fields: [
48
+ { path: "response.status", label: "Response code", exposure: "raw" },
49
+ {
50
+ path: "response.header.retry-after",
51
+ label: "Whether the server asked to retry later",
52
+ exposure: "presence"
53
+ },
54
+ {
55
+ path: "response.body",
56
+ label: "Observe settings",
57
+ exposure: "raw",
58
+ transform: { note: "SDK reliability config" }
59
+ }
60
+ ]
61
+ },
62
+ this.url,
63
+ {
64
+ method: "GET",
65
+ cache: "no-store",
66
+ ...controller ? { signal: controller.signal } : {}
67
+ }
68
+ );
44
69
  if (!response.ok) {
45
70
  retryable = (response.status === 408 || response.status === 429 || response.status >= 500) && !response.headers.has("Retry-After");
46
71
  throw new Error(`SDK config request failed (${response.status})`);
@@ -1,6 +1,21 @@
1
1
  // src/device-info-manager.ts
2
+ import { readDocumentHidden } from "./readers/page.mjs";
2
3
  function isDocumentHidden() {
3
- return typeof document !== "undefined" && document.visibilityState === "hidden";
4
+ return typeof document !== "undefined" && readDocumentHidden({
5
+ key: "page:lifecycle.visibility",
6
+ title: "Page visibility",
7
+ accessNote: "document.visibilityState",
8
+ group: "Page lifecycle",
9
+ origin: "observe-external",
10
+ fields: [
11
+ {
12
+ path: "visibilityState",
13
+ label: "Whether the page is hidden",
14
+ exposure: "enum",
15
+ transform: { values: ["hidden", "other"] }
16
+ }
17
+ ]
18
+ });
4
19
  }
5
20
  var DeviceInfoManager = class {
6
21
  constructor(logger, source, refreshInterval, attachInterval, diagnostics, onUndelivered) {
@@ -180,7 +180,18 @@ var DeviceCollector = class {
180
180
  "navigator.bluetooth.getAvailability",
181
181
  () => readBluetoothAvailability({
182
182
  key: "browser-api:device.bluetooth-availability",
183
- fields: [{ path: "result", exposure: "enum", transform: { values: [true, false] } }]
183
+ title: "Bluetooth availability",
184
+ accessNote: "navigator.bluetooth.getAvailability()",
185
+ group: "Device information",
186
+ origin: "observe-external",
187
+ fields: [
188
+ {
189
+ path: "result",
190
+ label: "Whether Bluetooth is available",
191
+ exposure: "enum",
192
+ transform: { values: [true, false] }
193
+ }
194
+ ]
184
195
  })
185
196
  ),
186
197
  this.startField(
@@ -188,9 +199,14 @@ var DeviceCollector = class {
188
199
  "PublicKeyCredential.getClientCapabilities",
189
200
  () => readClientCapabilities({
190
201
  key: "browser-api:device.client-capabilities",
202
+ title: "Passkey features supported by the browser",
203
+ accessNote: "PublicKeyCredential.getClientCapabilities(), every returned key",
204
+ group: "Device information",
205
+ origin: "observe-external",
191
206
  fields: [
192
207
  {
193
208
  path: "result.<capability>",
209
+ label: "Supported passkey features",
194
210
  exposure: "enum",
195
211
  transform: { values: ["true", "false"], note: "open set of capability names, kept as reported" }
196
212
  }
@@ -202,7 +218,18 @@ var DeviceCollector = class {
202
218
  "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable",
203
219
  () => readUserVerifyingPlatformAuthenticatorAvailable({
204
220
  key: "browser-api:device.uvpaa",
205
- fields: [{ path: "result", exposure: "enum", transform: { values: [true, false] } }]
221
+ title: "Built-in passkey support on the device",
222
+ accessNote: "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()",
223
+ group: "Device information",
224
+ origin: "observe-external",
225
+ fields: [
226
+ {
227
+ path: "result",
228
+ label: "Whether the device has a built-in passkey authenticator",
229
+ exposure: "enum",
230
+ transform: { values: [true, false] }
231
+ }
232
+ ]
206
233
  })
207
234
  ),
208
235
  this.startField(
@@ -210,7 +237,18 @@ var DeviceCollector = class {
210
237
  "PublicKeyCredential.isConditionalMediationAvailable",
211
238
  () => readConditionalMediationAvailable({
212
239
  key: "browser-api:device.conditional-mediation",
213
- fields: [{ path: "result", exposure: "enum", transform: { values: [true, false] } }]
240
+ title: "Passkey autofill availability",
241
+ accessNote: "PublicKeyCredential.isConditionalMediationAvailable()",
242
+ group: "Device information",
243
+ origin: "observe-external",
244
+ fields: [
245
+ {
246
+ path: "result",
247
+ label: "Whether passkey autofill is available",
248
+ exposure: "enum",
249
+ transform: { values: [true, false] }
250
+ }
251
+ ]
214
252
  })
215
253
  ),
216
254
  this.startField(
@@ -218,11 +256,20 @@ var DeviceCollector = class {
218
256
  "navigator.userAgentData.getHighEntropyValues",
219
257
  () => readJavaScriptHighEntropy({
220
258
  key: "browser-api:device.ua-high-entropy",
259
+ title: "Device platform, version and model reported by the browser",
260
+ accessNote: "User-Agent Client Hints: navigator.userAgentData.getHighEntropyValues()",
261
+ group: "Device information",
262
+ origin: "observe-external",
221
263
  fields: [
222
- { path: "result.platform", exposure: "raw" },
223
- { path: "result.mobile", exposure: "enum", transform: { values: [true, false] } },
224
- { path: "result.platformVersion", exposure: "raw" },
225
- { path: "result.model", exposure: "raw" }
264
+ { path: "result.platform", label: "Operating system", exposure: "raw" },
265
+ {
266
+ path: "result.mobile",
267
+ label: "Whether the device is a mobile device",
268
+ exposure: "enum",
269
+ transform: { values: [true, false] }
270
+ },
271
+ { path: "result.platformVersion", label: "Operating system version", exposure: "raw" },
272
+ { path: "result.model", label: "Device model", exposure: "raw" }
226
273
  ]
227
274
  })
228
275
  ),
@@ -231,7 +278,18 @@ var DeviceCollector = class {
231
278
  "navigator.webdriver",
232
279
  () => readWebdriver({
233
280
  key: "browser-api:device.webdriver",
234
- fields: [{ path: "result", exposure: "enum", transform: { values: [true, false] } }]
281
+ title: "Browser automation flag",
282
+ accessNote: "navigator.webdriver",
283
+ group: "Device information",
284
+ origin: "observe-external",
285
+ fields: [
286
+ {
287
+ path: "result",
288
+ label: "Whether the browser is controlled by automation",
289
+ exposure: "enum",
290
+ transform: { values: [true, false] }
291
+ }
292
+ ]
235
293
  })
236
294
  ),
237
295
  this.startField(
@@ -239,7 +297,11 @@ var DeviceCollector = class {
239
297
  "navigator.maxTouchPoints",
240
298
  () => readMaxTouchPoints({
241
299
  key: "browser-api:device.max-touch-points",
242
- fields: [{ path: "result", exposure: "raw" }]
300
+ title: "Touch screen support",
301
+ accessNote: "navigator.maxTouchPoints",
302
+ group: "Device information",
303
+ origin: "observe-external",
304
+ fields: [{ path: "result", label: "Number of touch points of the screen", exposure: "raw" }]
243
305
  })
244
306
  )
245
307
  ];
@@ -377,7 +439,18 @@ var DeviceCollector = class {
377
439
  source,
378
440
  () => typeof __CBO_DATA_PRIVATE_MODE__ === "boolean" && !__CBO_DATA_PRIVATE_MODE__ ? void 0 : readPrivateMode({
379
441
  key: "browser-api:device.private-mode",
380
- fields: [{ path: "result.isPrivate", exposure: "enum", transform: { values: [true, false] } }],
442
+ title: "Private browsing mode",
443
+ accessNote: "detectincognitojs library (IndexedDB, storage quota and browser-specific probes)",
444
+ group: "Device information",
445
+ origin: "observe-external",
446
+ fields: [
447
+ {
448
+ path: "result.isPrivate",
449
+ label: "Whether private browsing is on",
450
+ exposure: "enum",
451
+ transform: { values: [true, false] }
452
+ }
453
+ ],
381
454
  dataConfig: "privateMode"
382
455
  })
383
456
  );
@@ -456,9 +529,14 @@ var DeviceCollector = class {
456
529
  static getUserAgent() {
457
530
  return readUserAgent({
458
531
  key: "browser-api:device.user-agent",
532
+ title: "Browser and operating system",
533
+ accessNote: "navigator.userAgent",
534
+ group: "Device information",
535
+ origin: "observe-external",
459
536
  fields: [
460
537
  {
461
538
  path: "result",
539
+ label: "Browser and operating system",
462
540
  exposure: "class",
463
541
  transform: {
464
542
  classes: [
@@ -0,0 +1,7 @@
1
+ // src/handoff.ts
2
+ function acceptHandoffs(step) {
3
+ return step;
4
+ }
5
+ export {
6
+ acceptHandoffs
7
+ };
@@ -1,6 +1,8 @@
1
1
  // src/low-events/grouped-input-tracker.ts
2
2
  import { InputChangeRecorder } from "./input-change-recorder.mjs";
3
- import { InputPasteObserver } from "./input-paste-observer.mjs";
3
+ import { InputPasteObserver } from "../readers/input-paste.mjs";
4
+ import { observeInputField, readFocusedField, readInputGroupValueLength } from "../readers/input.mjs";
5
+ import { listenWindow } from "../readers/page.mjs";
4
6
  var LowEventGroupedInputTracker = class {
5
7
  constructor(logger, fields, sink) {
6
8
  this.logger = logger;
@@ -13,6 +15,27 @@ var LowEventGroupedInputTracker = class {
13
15
  sink.reserveCaptureSeq
14
16
  );
15
17
  this.paste = new InputPasteObserver(
18
+ {
19
+ key: "dom:input.paste",
20
+ title: "Paste into tracked input fields",
21
+ accessNote: "paste events on the input elements the integration passes to Observe; pasted values never leave the reader",
22
+ group: "Input interaction",
23
+ origin: "observe-external",
24
+ fields: [
25
+ {
26
+ path: "event.paste",
27
+ label: "Paste into the field",
28
+ exposure: "presence",
29
+ transform: { note: "trusted paste events only" }
30
+ },
31
+ {
32
+ path: "value",
33
+ label: "Length of the pasted text",
34
+ exposure: "size",
35
+ transform: { note: "inserted length only; values are compared in memory and discarded" }
36
+ }
37
+ ]
38
+ },
16
39
  this.inputs,
17
40
  logger,
18
41
  (ts, _inserted, captureSeq) => {
@@ -32,35 +55,115 @@ var LowEventGroupedInputTracker = class {
32
55
  );
33
56
  this.unregisterInputs = sink.registerInputs?.(this.inputs, () => this.flushPending());
34
57
  this.capturePrefill();
58
+ const guard = (_name, run) => this.safe(run);
35
59
  for (const input of this.inputs) {
36
- this.listen(input, "input", (event) => {
37
- if (this.paste.handlesInput(event)) return;
38
- this.composing ||= event.isComposing || event.inputType === "insertCompositionText";
39
- if (this.changeTimer !== null) return;
40
- this.changeTime = Date.now();
41
- this.changeSeq = sink.reserveCaptureSeq?.();
42
- this.changeTimer = setTimeout(() => this.safe(() => this.flushChanges()), 0);
43
- });
44
- this.listen(input, "focus", (event) => {
45
- if (this.contains(event.relatedTarget)) return;
46
- this.emit("focus");
47
- });
48
- this.listen(input, "blur", (event) => {
49
- if (this.contains(event.relatedTarget)) return;
50
- this.flushPending();
51
- this.emit("blur");
52
- });
53
- for (const type of ["click", "pointerdown", "pointerup"]) this.listen(input, type, () => this.emit(type));
54
- this.listen(input, "keyup", (event) => {
55
- if (event.key === "Escape") this.emit("keyup-escape");
56
- });
60
+ this.listeners.push(
61
+ observeInputField(
62
+ {
63
+ key: "dom:input.events",
64
+ title: "Interaction with tracked input fields",
65
+ accessNote: "focus, blur, pointerdown, pointerup, click, input and keyup events on the input elements the integration passes to Observe, reduced to booleans and identities",
66
+ group: "Input interaction",
67
+ origin: "observe-external",
68
+ fields: [
69
+ { path: "event.focus", label: "Field selected", exposure: "presence" },
70
+ {
71
+ path: "event.focus.relatedTarget",
72
+ label: "Whether focus moved between fields of the same group",
73
+ exposure: "computed",
74
+ transform: { note: "only whether focus moves within the group" }
75
+ },
76
+ { path: "event.blur", label: "Field left", exposure: "presence" },
77
+ { path: "event.pointerdown", label: "Pointer pressed on the field", exposure: "presence" },
78
+ { path: "event.pointerup", label: "Pointer released on the field", exposure: "presence" },
79
+ { path: "event.click", label: "Click on the field", exposure: "presence" },
80
+ { path: "event.input", label: "Typing in the field", exposure: "presence" },
81
+ {
82
+ path: "event.input.isComposing",
83
+ label: "Whether text is being composed with an input method",
84
+ exposure: "enum",
85
+ transform: { values: [true, false] }
86
+ },
87
+ {
88
+ path: "event.keyup.key",
89
+ label: "Escape key pressed",
90
+ exposure: "enum",
91
+ transform: { values: ["Escape", "other"] }
92
+ }
93
+ ]
94
+ },
95
+ input,
96
+ {
97
+ input: (composing, event) => {
98
+ if (this.paste.handlesInput(event)) return;
99
+ this.composing ||= composing;
100
+ if (this.changeTimer !== null) return;
101
+ this.changeTime = Date.now();
102
+ this.changeSeq = sink.reserveCaptureSeq?.();
103
+ this.changeTimer = setTimeout(() => this.safe(() => this.flushChanges()), 0);
104
+ },
105
+ focus: (relatedTarget) => {
106
+ if (this.contains(relatedTarget)) return;
107
+ this.emit("focus");
108
+ },
109
+ blur: (relatedTarget) => {
110
+ if (this.contains(relatedTarget)) return;
111
+ this.flushPending();
112
+ this.emit("blur");
113
+ },
114
+ click: () => this.emit("click"),
115
+ pointerdown: () => this.emit("pointerdown"),
116
+ pointerup: () => this.emit("pointerup"),
117
+ keyupEscape: () => this.emit("keyup-escape")
118
+ },
119
+ guard,
120
+ ["input", "focus", "blur", "click", "pointerdown", "pointerup", "keyup"]
121
+ )
122
+ );
57
123
  }
58
124
  if (typeof window !== "undefined")
59
- this.listen(window, "pagehide", () => {
60
- this.flushPending();
61
- sink.flushKeepalive();
62
- });
63
- if (typeof document !== "undefined" && this.contains(document.activeElement)) {
125
+ this.listeners.push(
126
+ listenWindow(
127
+ {
128
+ key: "page:lifecycle.pagehide",
129
+ title: "Leaving the page",
130
+ accessNote: "pagehide event on window",
131
+ group: "Page lifecycle",
132
+ origin: "observe-external",
133
+ fields: [
134
+ {
135
+ path: "event.pagehide",
136
+ label: "Leaving the page",
137
+ exposure: "presence",
138
+ transform: { note: "flushes pending input low events on unload" }
139
+ }
140
+ ]
141
+ },
142
+ "pagehide",
143
+ (_event) => this.safe(() => {
144
+ this.flushPending();
145
+ sink.flushKeepalive();
146
+ })
147
+ )
148
+ );
149
+ if (typeof document !== "undefined" && readFocusedField(
150
+ {
151
+ key: "dom:input.focused",
152
+ title: "Whether a tracked input field has focus",
153
+ accessNote: "document.activeElement compared with the passed input elements",
154
+ group: "Input interaction",
155
+ origin: "observe-external",
156
+ fields: [
157
+ {
158
+ path: "focused",
159
+ label: "Whether the field is selected",
160
+ exposure: "enum",
161
+ transform: { values: [true, false] }
162
+ }
163
+ ]
164
+ },
165
+ this.inputs
166
+ )) {
64
167
  this.emit("focus");
65
168
  }
66
169
  }
@@ -78,7 +181,24 @@ var LowEventGroupedInputTracker = class {
78
181
  return this.inputs.some((field) => field === target);
79
182
  }
80
183
  length() {
81
- return this.inputs.reduce((total, field) => total + field.value.length, 0);
184
+ return readInputGroupValueLength(
185
+ {
186
+ key: "dom:input.content",
187
+ title: "Length of the text in tracked input fields",
188
+ accessNote: "input value length only; the value itself does not leave the reader",
189
+ group: "Input interaction",
190
+ origin: "observe-external",
191
+ fields: [
192
+ {
193
+ path: "value.length",
194
+ label: "Length of the entered text",
195
+ exposure: "size",
196
+ transform: { note: "summed over the fields of a group" }
197
+ }
198
+ ]
199
+ },
200
+ this.inputs
201
+ );
82
202
  }
83
203
  safe(action) {
84
204
  try {
@@ -87,11 +207,6 @@ var LowEventGroupedInputTracker = class {
87
207
  this.logger.error("Failed to observe grouped input", error);
88
208
  }
89
209
  }
90
- listen(target, type, handler) {
91
- const wrapped = (event) => this.safe(() => handler(event));
92
- target.addEventListener(type, wrapped);
93
- this.listeners.push(() => target.removeEventListener(type, wrapped));
94
- }
95
210
  cancelChange() {
96
211
  if (this.changeTimer !== null) clearTimeout(this.changeTimer);
97
212
  this.changeTimer = null;