@formo/analytics 1.32.0 → 1.33.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.
@@ -66,6 +66,7 @@ import { detectInjectedProviderInfo, isValidProvider } from "./provider";
66
66
  import { FormoAnalyticsSession, SESSION_WALLET_DETECTED_KEY, SESSION_WALLET_IDENTIFIED_KEY, } from "./session";
67
67
  import { SignatureStatus, TransactionStatus, WRAPPED_REQUEST_SYMBOL, WRAPPED_REQUEST_REF_SYMBOL, } from "./types";
68
68
  import { validateAddress, validateAndChecksumAddress } from "./utils/address";
69
+ import { getTimezone } from "./utils/timezone";
69
70
  import { isLocalhost } from "./validators";
70
71
  import { parseChainId } from "./utils/chain";
71
72
  import { WagmiEventHandler } from "./wagmi";
@@ -391,6 +392,14 @@ var FormoAnalytics = /** @class */ (function () {
391
392
  logger.warn("Connect: Invalid address provided (\"".concat(address, "\"). Please provide a valid EVM or Solana address."));
392
393
  return [2 /*return*/];
393
394
  }
395
+ // connect() persists wallet/chain state (active-wallet cookie,
396
+ // currentAddress/currentChainId) before trackEvent's consent check —
397
+ // gate the whole method so a suppressed visitor or excluded environment
398
+ // (opt-out / timezone / host / path) leaves no session state.
399
+ if (this.isTrackingSuppressed()) {
400
+ logger.info("connect() skipped: tracking is suppressed for this visitor or environment");
401
+ return [2 /*return*/];
402
+ }
394
403
  this.setChainState(chainId, { address: validAddress });
395
404
  return [4 /*yield*/, this.trackEvent(EventType.CONNECT, {
396
405
  chainId: chainId,
@@ -578,10 +587,11 @@ var FormoAnalytics = /** @class */ (function () {
578
587
  case 0:
579
588
  _d.trys.push([0, 12, , 13]);
580
589
  // identify() writes the user-id cookie and marks wallet
581
- // identification before trackEvent's consent check — gate the
582
- // whole method so an opted-out user gets no identity persistence.
583
- if (this.hasOptedOutTracking()) {
584
- logger.info("identify() skipped: user has opted out of tracking");
590
+ // identification before trackEvent's consent check — gate the whole
591
+ // method so a suppressed visitor or excluded environment (opt-out /
592
+ // timezone / host / path) gets no identity persistence.
593
+ if (this.isTrackingSuppressed()) {
594
+ logger.info("identify() skipped: tracking is suppressed for this visitor or environment");
585
595
  return [2 /*return*/];
586
596
  }
587
597
  if (!!params) return [3 /*break*/, 10];
@@ -709,9 +719,10 @@ var FormoAnalytics = /** @class */ (function () {
709
719
  switch (_c.label) {
710
720
  case 0:
711
721
  // detect() marks wallet detection (a cookie write) before
712
- // trackEvent's consent check — gate it on opt-out.
713
- if (this.hasOptedOutTracking()) {
714
- logger.info("detect() skipped: user has opted out of tracking");
722
+ // trackEvent's consent check — gate it for a suppressed visitor or
723
+ // excluded environment (opt-out / timezone / host / path).
724
+ if (this.isTrackingSuppressed()) {
725
+ logger.info("detect() skipped: tracking is suppressed for this visitor or environment");
715
726
  return [2 /*return*/];
716
727
  }
717
728
  if (this.session.isWalletDetected(rdns))
@@ -1075,9 +1086,16 @@ var FormoAnalytics = /** @class */ (function () {
1075
1086
  case 26:
1076
1087
  nextChainId = _a.sent();
1077
1088
  wasDisconnected = !this._evmAddress;
1078
- // CRITICAL: Always update state regardless of whether connect tracking is enabled
1079
- // This ensures disconnect events will have valid address/chainId values
1080
- this.setChainState('evm', { address: address, chainId: nextChainId });
1089
+ // Update state regardless of whether connect *event* tracking is enabled,
1090
+ // so disconnect events keep valid address/chainId values. (excludeChains is
1091
+ // NOT suppression it still updates state so currentChainId can gate
1092
+ // events.)
1093
+ if (this.isTrackingSuppressed()) {
1094
+ this.clearStaleEvmWalletOnSwitchWhileSuppressed(address);
1095
+ }
1096
+ else {
1097
+ this.setChainState('evm', { address: address, chainId: nextChainId });
1098
+ }
1081
1099
  providerInfo = this.getProviderInfo(provider);
1082
1100
  effectiveChainId = nextChainId || 0;
1083
1101
  if (this.isAutocaptureEnabled("connect")) {
@@ -1250,13 +1268,19 @@ var FormoAnalytics = /** @class */ (function () {
1250
1268
  this._provider = provider;
1251
1269
  }
1252
1270
  isActiveProvider = this._provider === provider;
1253
- // CRITICAL: Always update state from active provider regardless of tracking config
1254
- // This ensures disconnect events will have valid address/chainId values
1271
+ // Update state from active provider so disconnect events keep valid
1272
+ // address/chainId values except while suppressed, where we must not
1273
+ // LEARN identity (only drop a stale EVM wallet on a switch).
1255
1274
  if (isActiveProvider) {
1256
- this.setChainState('evm', {
1257
- chainId: chainId,
1258
- address: validateAndChecksumAddress(address) || undefined,
1259
- });
1275
+ if (this.isTrackingSuppressed()) {
1276
+ this.clearStaleEvmWalletOnSwitchWhileSuppressed(address);
1277
+ }
1278
+ else {
1279
+ this.setChainState('evm', {
1280
+ chainId: chainId,
1281
+ address: validateAndChecksumAddress(address) || undefined,
1282
+ });
1283
+ }
1260
1284
  }
1261
1285
  // Conditionally emit connect event based on tracking configuration
1262
1286
  if (isActiveProvider && this._evmAddress) {
@@ -1625,6 +1649,108 @@ var FormoAnalytics = /** @class */ (function () {
1625
1649
  });
1626
1650
  });
1627
1651
  };
1652
+ /**
1653
+ * Visitor-level tracking suppression.
1654
+ *
1655
+ * Returns true when the SDK must not persist any identity/session/chain
1656
+ * state or send any events for this visitor — i.e. an explicit opt-out or a
1657
+ * jurisdiction/timezone exclusion. Public entry points that write state
1658
+ * before reaching the `shouldTrack()` event gate (identify/connect/detect)
1659
+ * check this first so suppressed visitors leave no cookies or session state.
1660
+ * @returns {boolean} True if all tracking and persistence must be suppressed
1661
+ */
1662
+ FormoAnalytics.prototype.isTrackingSuppressed = function () {
1663
+ return this.hasOptedOutTracking() || this.isCurrentEnvironmentExcluded();
1664
+ };
1665
+ /**
1666
+ * Whether the current environment is excluded from tracking — the visitor's
1667
+ * timezone, the current hostname, or the current pathname matches a
1668
+ * configured exclusion.
1669
+ *
1670
+ * Timezone is visitor/session-level (stable for the session); host/path are
1671
+ * current-page-level and transient — if a SPA navigates to an allowed path,
1672
+ * tracking resumes for future actions. Used as the "do not write identity or
1673
+ * send events" gate at every entry point that would persist state before the
1674
+ * `shouldTrack()` event gate.
1675
+ * @returns {boolean} True if the current environment is excluded
1676
+ */
1677
+ FormoAnalytics.prototype.isCurrentEnvironmentExcluded = function () {
1678
+ return (this.isTimezoneExcluded() ||
1679
+ this.isHostExcluded() ||
1680
+ this.isPathExcluded());
1681
+ };
1682
+ /**
1683
+ * Whether the current hostname matches a configured `tracking.excludeHosts`
1684
+ * entry (exact match). Current-page-level — see isCurrentEnvironmentExcluded.
1685
+ * @returns {boolean} True if the current hostname is excluded
1686
+ */
1687
+ FormoAnalytics.prototype.isHostExcluded = function () {
1688
+ var tracking = this.options.tracking;
1689
+ if (tracking === null ||
1690
+ typeof tracking !== "object" ||
1691
+ Array.isArray(tracking)) {
1692
+ return false;
1693
+ }
1694
+ if (typeof window === "undefined") {
1695
+ return false;
1696
+ }
1697
+ var _a = tracking.excludeHosts, excludeHosts = _a === void 0 ? [] : _a;
1698
+ return excludeHosts.includes(window.location.hostname);
1699
+ };
1700
+ /**
1701
+ * Whether the current pathname matches a configured `tracking.excludePaths`
1702
+ * entry (exact match). Current-page-level — see isCurrentEnvironmentExcluded.
1703
+ * @returns {boolean} True if the current pathname is excluded
1704
+ */
1705
+ FormoAnalytics.prototype.isPathExcluded = function () {
1706
+ var tracking = this.options.tracking;
1707
+ if (tracking === null ||
1708
+ typeof tracking !== "object" ||
1709
+ Array.isArray(tracking)) {
1710
+ return false;
1711
+ }
1712
+ if (typeof window === "undefined") {
1713
+ return false;
1714
+ }
1715
+ var _a = tracking.excludePaths, excludePaths = _a === void 0 ? [] : _a;
1716
+ return excludePaths.includes(window.location.pathname);
1717
+ };
1718
+ /**
1719
+ * Whether the current call is in a visitor-level suppression state — opt-out
1720
+ * or excluded timezone — for which any persisted identity cookie should be
1721
+ * actively purged (not merely skipped). Host/path exclusions are
1722
+ * deliberately excluded here: they are transient current-page states, so a
1723
+ * cookie legitimately written on an allowed page must survive a visit to an
1724
+ * excluded route.
1725
+ * @returns {boolean} True if persisted identity must be purged
1726
+ */
1727
+ FormoAnalytics.prototype.isPersistedIdentityPurgeRequired = function () {
1728
+ return this.hasOptedOutTracking() || this.isTimezoneExcluded();
1729
+ };
1730
+ /**
1731
+ * Whether the visitor's browser-resolved timezone matches a configured
1732
+ * `tracking.excludeTimezones` entry (case-insensitive). Client-side and
1733
+ * best-effort — see TrackingOptions.excludeTimezones.
1734
+ * @returns {boolean} True if the current timezone is excluded
1735
+ */
1736
+ FormoAnalytics.prototype.isTimezoneExcluded = function () {
1737
+ var tracking = this.options.tracking;
1738
+ if (tracking === null ||
1739
+ typeof tracking !== "object" ||
1740
+ Array.isArray(tracking)) {
1741
+ return false;
1742
+ }
1743
+ var _a = tracking.excludeTimezones, excludeTimezones = _a === void 0 ? [] : _a;
1744
+ if (excludeTimezones.length === 0) {
1745
+ return false;
1746
+ }
1747
+ var timezone = getTimezone();
1748
+ if (!timezone) {
1749
+ return false;
1750
+ }
1751
+ var lowerTimezone = timezone.toLowerCase();
1752
+ return excludeTimezones.some(function (tz) { return typeof tz === "string" && tz.toLowerCase() === lowerTimezone; });
1753
+ };
1628
1754
  /**
1629
1755
  * Determines if tracking should be enabled based on configuration and consent
1630
1756
  * @returns {boolean} True if tracking should be enabled
@@ -1642,20 +1768,11 @@ var FormoAnalytics = /** @class */ (function () {
1642
1768
  if (this.options.tracking !== null &&
1643
1769
  typeof this.options.tracking === "object" &&
1644
1770
  !Array.isArray(this.options.tracking)) {
1645
- var _a = this.options.tracking, _b = _a.excludeHosts, excludeHosts = _b === void 0 ? [] : _b, _c = _a.excludePaths, excludePaths = _c === void 0 ? [] : _c, _d = _a.excludeChains, excludeChains = _d === void 0 ? [] : _d;
1646
- // Check hostname exclusions - use exact matching
1647
- if (excludeHosts.length > 0 && typeof window !== "undefined") {
1648
- var hostname = window.location.hostname;
1649
- if (excludeHosts.includes(hostname)) {
1650
- return false;
1651
- }
1652
- }
1653
- // Check path exclusions - use exact matching
1654
- if (excludePaths.length > 0 && typeof window !== "undefined") {
1655
- var pathname = window.location.pathname;
1656
- if (excludePaths.includes(pathname)) {
1657
- return false;
1658
- }
1771
+ var _a = this.options.tracking.excludeChains, excludeChains = _a === void 0 ? [] : _a;
1772
+ // Environment exclusions (timezone / host / path) — no identify / connect
1773
+ // / track events while excluded. Host/path are exact-match.
1774
+ if (this.isCurrentEnvironmentExcluded()) {
1775
+ return false;
1659
1776
  }
1660
1777
  // Check chainId exclusions
1661
1778
  if (excludeChains.length > 0 &&
@@ -2046,10 +2163,31 @@ var FormoAnalytics = /** @class */ (function () {
2046
2163
  * value in the normal way; existing connections are never clobbered.
2047
2164
  */
2048
2165
  FormoAnalytics.prototype.backfillActiveWallet = function (address, chainId) {
2166
+ // Never learn identity while suppressed (opt-out / timezone / excluded host
2167
+ // or path). A signature/transaction observed on an excluded route must not
2168
+ // populate currentAddress for later allowed-page events. backfill only ever
2169
+ // *adds* an address (it no-ops when one is already known), so there is no
2170
+ // stale state to clear here.
2171
+ if (this.isTrackingSuppressed())
2172
+ return;
2049
2173
  if (this._evmAddress)
2050
2174
  return;
2051
2175
  this.setChainState('evm', { address: address, chainId: chainId });
2052
2176
  };
2177
+ /**
2178
+ * Apply an EVM autocapture connect/switch while tracking is suppressed
2179
+ * (opt-out / timezone / excluded host or path): never LEARN the wallet, but
2180
+ * if it is a switch away from an already-learned EVM wallet, drop the stale
2181
+ * one (which also clears the active-wallet cookie) so it can't attach to a
2182
+ * later allowed-page event.
2183
+ */
2184
+ FormoAnalytics.prototype.clearStaleEvmWalletOnSwitchWhileSuppressed = function (address) {
2185
+ var evmAddress = this._chainState.evm.address;
2186
+ var incoming = validateAndChecksumAddress(address);
2187
+ if (evmAddress && incoming && incoming !== evmAddress) {
2188
+ this.clearChainState('evm');
2189
+ }
2190
+ };
2053
2191
  /**
2054
2192
  * Polls for transaction receipt and emits tx.status = CONFIRMED or REVERTED.
2055
2193
  */
@@ -2256,6 +2394,39 @@ var FormoAnalytics = /** @class */ (function () {
2256
2394
  */
2257
2395
  FormoAnalytics.prototype.syncWalletState = function (params) {
2258
2396
  var chainId = params.chainId, address = params.address;
2397
+ if (this.isTrackingSuppressed()) {
2398
+ // While suppressed (opt-out / timezone / excluded host or path) we must
2399
+ // never LEARN a new wallet — but we must still CLEAR stale identity.
2400
+ // Otherwise a disconnect or wallet switch observed on a suppressed route
2401
+ // would leave the previously-learned address in memory and in the
2402
+ // active-wallet cookie, attaching it to later allowed-page events.
2403
+ if (!address) {
2404
+ // Disconnect: drop the affected namespace(s).
2405
+ if (chainId !== undefined && chainId !== null) {
2406
+ this.clearChainState(chainId);
2407
+ }
2408
+ else {
2409
+ this.clearChainState("evm");
2410
+ this.clearChainState("solana");
2411
+ }
2412
+ return;
2413
+ }
2414
+ // Address present: a switch away from the wallet already learned in this
2415
+ // namespace invalidates it. Drop the stale one without learning the new
2416
+ // address; a fresh connect (nothing learned yet) or a re-confirmation of
2417
+ // the same address is a no-op.
2418
+ if (chainId === null || chainId === undefined)
2419
+ return;
2420
+ var namespace = this.getNamespace(chainId);
2421
+ var namespaceAddress = this._chainState[namespace].address;
2422
+ var validIncoming = validateAddress(address, chainId);
2423
+ if (namespaceAddress &&
2424
+ validIncoming &&
2425
+ validIncoming !== namespaceAddress) {
2426
+ this.clearChainState(chainId);
2427
+ }
2428
+ return;
2429
+ }
2259
2430
  if (!address) {
2260
2431
  if (chainId !== undefined && chainId !== null) {
2261
2432
  this.clearChainState(chainId);
@@ -2313,18 +2484,28 @@ var FormoAnalytics = /** @class */ (function () {
2313
2484
  */
2314
2485
  FormoAnalytics.prototype.persistActiveWallet = function () {
2315
2486
  try {
2316
- // Never write an identity cookie for an opted-out user; ensure any
2317
- // prior snapshot is removed instead.
2318
- if (this.hasOptedOutTracking()) {
2487
+ // Visitor-level suppression (opt-out or excluded timezone): purge any
2488
+ // prior snapshot — these are stable for the session, so deletion is safe.
2489
+ if (this.isPersistedIdentityPurgeRequired()) {
2319
2490
  cookie().remove(ACTIVE_WALLET_KEY);
2320
2491
  return;
2321
2492
  }
2322
2493
  if (this.currentAddress) {
2494
+ // Current-page exclusion (host/path): do not write a new snapshot while
2495
+ // on an excluded route, but leave any existing cookie intact. A cookie
2496
+ // written on an allowed page must survive a transient visit to an
2497
+ // excluded one (passive navigation does not call this method).
2498
+ if (this.isHostExcluded() || this.isPathExcluded()) {
2499
+ return;
2500
+ }
2323
2501
  var value = JSON.stringify(__assign({ address: this.currentAddress }, (this.currentChainId !== undefined && { chainId: this.currentChainId })));
2324
2502
  var domain = getIdentityCookieDomain(this.crossSubdomainCookies);
2325
2503
  cookie().set(ACTIVE_WALLET_KEY, value, __assign(__assign({ path: "/", expires: new Date(Date.now() + ACTIVE_WALLET_TTL_MS).toUTCString() }, getIdentityCookieSecurity()), (domain ? { domain: domain } : {})));
2326
2504
  }
2327
2505
  else {
2506
+ // No active wallet → clear the snapshot. This runs even on an excluded
2507
+ // route, so a disconnect/switch observed while suppressed actively
2508
+ // removes stale identity instead of leaving it for later allowed events.
2328
2509
  cookie().remove(ACTIVE_WALLET_KEY);
2329
2510
  }
2330
2511
  }
@@ -2338,12 +2519,18 @@ var FormoAnalytics = /** @class */ (function () {
2338
2519
  */
2339
2520
  FormoAnalytics.prototype.loadActiveWallet = function () {
2340
2521
  try {
2341
- // Never restore wallet identity into memory for an opted-out user
2342
- // (mirrors persistActiveWallet's guard); drop any stale snapshot.
2343
- if (this.hasOptedOutTracking()) {
2522
+ // Visitor-level suppression (opt-out or excluded timezone): never restore
2523
+ // identity into memory; drop the stale snapshot.
2524
+ if (this.isPersistedIdentityPurgeRequired()) {
2344
2525
  cookie().remove(ACTIVE_WALLET_KEY);
2345
2526
  return;
2346
2527
  }
2528
+ // Current-page exclusion (host/path): don't restore into memory while on
2529
+ // an excluded route, but keep the cookie so a later allowed-page load can
2530
+ // restore it.
2531
+ if (this.isHostExcluded() || this.isPathExcluded()) {
2532
+ return;
2533
+ }
2347
2534
  var raw = cookie().get(ACTIVE_WALLET_KEY);
2348
2535
  if (!raw)
2349
2536
  return;
@@ -55,7 +55,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
55
55
  return to.concat(ar || Array.prototype.slice.call(from));
56
56
  };
57
57
  import { COUNTRY_LIST, LOCAL_ANONYMOUS_ID_KEY, SESSION_TRAFFIC_SOURCE_KEY, } from "../constants";
58
- import { toSnakeCase } from "../utils";
58
+ import { toSnakeCase, getTimezone } from "../utils";
59
59
  import { validateAddress } from "../utils/address";
60
60
  import { getCurrentTimeFormatted } from "../utils/timestamp";
61
61
  import { isUndefined } from "../validators";
@@ -270,13 +270,7 @@ var EventFactory = /** @class */ (function () {
270
270
  return validateAddress(address, chainId) || null;
271
271
  };
272
272
  EventFactory.prototype.getTimezone = function () {
273
- try {
274
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
275
- }
276
- catch (error) {
277
- logger.error("Error resolving timezone:", error);
278
- return "";
279
- }
273
+ return getTimezone();
280
274
  };
281
275
  EventFactory.prototype.getLocation = function () {
282
276
  try {
@@ -72,6 +72,20 @@ export interface TrackingOptions {
72
72
  excludeHosts?: string[];
73
73
  excludePaths?: string[];
74
74
  excludeChains?: ChainID[];
75
+ /**
76
+ * IANA timezone names to opt out of tracking entirely. When the visitor's
77
+ * resolved timezone (via `Intl.DateTimeFormat().resolvedOptions().timeZone`)
78
+ * matches one of these, no events are enqueued or sent — including `identify`
79
+ * and `connect`. Matched case-insensitively against the full timezone string.
80
+ *
81
+ * Note: this is client-side, timezone-derived geolocation. It is best-effort
82
+ * and can be bypassed (a VPN does not change the browser timezone, and users
83
+ * can change their OS timezone). For authoritative jurisdiction blocking, use
84
+ * server-side IP geolocation at your ingest endpoint instead.
85
+ *
86
+ * @example ["Europe/London", "America/New_York"]
87
+ */
88
+ excludeTimezones?: string[];
75
89
  /**
76
90
  * Additional query parameter names to strip from forwarded and stored URLs,
77
91
  * on top of a built-in always-on denylist (currently `privy_oauth_code`,
@@ -3,4 +3,5 @@ export * from "./base";
3
3
  export * from "./converter";
4
4
  export * from "./generate";
5
5
  export * from "./hash";
6
+ export * from "./timezone";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,4 +3,5 @@ export * from "./base";
3
3
  export * from "./converter";
4
4
  export * from "./generate";
5
5
  export * from "./hash";
6
+ export * from "./timezone";
6
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolve the current IANA timezone (e.g. "Europe/London") via the Intl API.
3
+ * Returns "" when the timezone cannot be resolved (e.g. Intl unavailable).
4
+ */
5
+ declare const getTimezone: () => string;
6
+ export { getTimezone };
7
+ //# sourceMappingURL=timezone.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { logger } from "../logger";
2
+ /**
3
+ * Resolve the current IANA timezone (e.g. "Europe/London") via the Intl API.
4
+ * Returns "" when the timezone cannot be resolved (e.g. Intl unavailable).
5
+ */
6
+ var getTimezone = function () {
7
+ try {
8
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
9
+ }
10
+ catch (error) {
11
+ logger.error("Error resolving timezone:", error);
12
+ return "";
13
+ }
14
+ };
15
+ export { getTimezone };
16
+ //# sourceMappingURL=timezone.js.map