Skip to content

Add timezone-based tracking opt-out via excludeTimezones#283

Merged
yosriady merged 8 commits into
mainfrom
claude/country-timezone-tracking-optout-79i8s2
Jun 9, 2026
Merged

Add timezone-based tracking opt-out via excludeTimezones#283
yosriady merged 8 commits into
mainfrom
claude/country-timezone-tracking-optout-79i8s2

Conversation

@yosriady

@yosriady yosriady commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for excluding visitors from tracking based on their IANA timezone. When a visitor's resolved timezone matches one of the configured excludeTimezones, no events (identify, connect, track, etc.) are enqueued or sent.

Key Changes

  • New utility function getTimezone() in src/utils/timezone.ts that resolves the current IANA timezone via the Intl.DateTimeFormat API with error handling
  • Updated TrackingOptions interface to include optional excludeTimezones: string[] field with comprehensive documentation noting this is client-side, best-effort geolocation
  • Added timezone check in FormoAnalytics.shouldTrack() that gates all event tracking at the single choke point, ensuring excluded timezones produce no events at all
  • Refactored EventFactory to use the new shared getTimezone() utility instead of duplicating the logic
  • Comprehensive test suite (test/trackingTimezoneExclusion.spec.ts) covering:
    • Blocking tracking for excluded timezones
    • Allowing tracking for non-excluded timezones
    • Case-insensitive timezone matching
    • Behavior when no exclusions are configured
    • Graceful handling when timezone cannot be resolved
    • Verification that identify/connect events are prevented from being enqueued

Implementation Details

  • Timezone matching is case-insensitive to handle variations in how timezones are specified
  • Returns empty string when timezone resolution fails, which does not trigger exclusion (fail-open approach)
  • Documentation explicitly notes this is client-side geolocation that can be bypassed (VPN, OS timezone changes) and recommends server-side IP geolocation for authoritative jurisdiction blocking

https://claude.ai/code/session_01QoaE5LN9ksZ2eeaxZr1MZc


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Add `tracking.excludeTimezones` (IANA timezone names) so visitors in
matching timezones produce no events at all — including identify and
connect. Enforced in the single shouldTrack() gate that every event
flows through, mirroring the existing excludeHosts/excludePaths/
excludeChains exclusions. Matching is case-insensitive.

Extract the Intl-based timezone resolution into a shared
utils/timezone.getTimezone() helper, reused by EventFactory and the
new gate.

Note: this is client-side, timezone-derived geolocation and is
best-effort (a VPN does not change browser timezone, and users can
change their OS timezone). For authoritative jurisdiction blocking,
use server-side IP geolocation at the ingest endpoint.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces timezone-based tracking exclusion to allow opting out visitors in specific timezones entirely. Feedback focuses on optimizing the timezone matching loop by lowercasing the visitor's timezone once, adding a fallback to an empty string in the timezone utility for older environments, and restoring the original global.Intl object in tests to prevent global state pollution.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/FormoAnalytics.ts Outdated
Comment thread src/utils/timezone.ts
Comment thread test/trackingTimezoneExclusion.spec.ts
Comment thread test/trackingTimezoneExclusion.spec.ts Outdated
claude added 7 commits June 9, 2026 02:43
Previously excludeTimezones was only enforced in shouldTrack(), which
runs inside trackEvent() — after identify()/connect()/detect() had
already persisted identity/session/chain state (user-id, active-wallet,
wallet-identification/detection cookies, currentAddress/currentChainId).
A timezone-excluded visitor therefore left cookies and state behind even
though no events were sent.

Introduce isTrackingSuppressed() (opt-out OR excluded timezone) and gate
the three public entry points on it before any state writes, mirroring
the existing opt-out early-exit pattern. The autocapture provider-event
path intentionally keeps persisting state so disconnect payloads stay
valid. Extract the timezone match into isTimezoneExcluded(), reused by
shouldTrack().

Add a test asserting no wallet/identity/session state is persisted for an
excluded visitor.
- isTimezoneExcluded(): hoist timezone.toLowerCase() out of the some()
  loop and guard each entry with typeof tz === "string"
- getTimezone(): fall back to "" when resolvedOptions().timeZone is
  undefined, matching the declared string return type
- spec: restore the original global.Intl in afterEach instead of
  deleting it, to avoid polluting other suites
…sitors

The public identify/connect/detect path was already gated, but the
EIP-1193 / Wagmi autocapture and syncWalletState paths reach
persistActiveWallet()/loadActiveWallet() directly, which only checked
hasOptedOutTracking(). An excluded-timezone visitor therefore still got
an active-wallet cookie (and a restored snapshot at init).

Broaden both persistence guards to isTrackingSuppressed() (opt-out OR
excluded timezone). This is the single cookie chokepoint, so it closes
the leak across all paths at once. In-memory currentAddress/currentChainId
are intentionally left intact for disconnect correctness; only the
persisted cookie is suppressed.

Add tests covering the syncWalletState (autocapture) path plus a
non-excluded control.
Extend the persistence-suppression model to the current-page environment
excludes. excludeHosts/excludePaths are now treated as current-page
suppression (not permanent opt-out): while on an excluded host or path no
events are sent and no wallet/session cookies are written, and tracking
resumes once the SPA navigates to an allowed route.

- Add isHostExcluded()/isPathExcluded()/isCurrentEnvironmentExcluded()
  helpers; isTrackingSuppressed() = opt-out || current-environment-excluded.
- Refactor shouldTrack() to reuse the helpers (single source of truth for
  host/path matching).
- Gate the identity-write entry points (identify/connect/detect/
  syncWalletState and the EIP-1193 autocapture state sync) on
  isTrackingSuppressed(), so excluded-page activity is never learned and
  can't be backfilled into a later allowed-page event.
- Two-tier cookie handling: opt-out/timezone are visitor-level and purge
  the active-wallet cookie; host/path are transient and only skip new
  writes — a cookie written on an allowed page survives a visit to an
  excluded route (isPersistedIdentityPurgeRequired()).

excludeChains is intentionally left out of suppression: it must keep
updating in-memory chain state so currentChainId can gate events and keep
disconnect payloads valid. excludeQueryParams stays as event-construction
redaction. Both are unchanged.

Add tests for excluded host/path (no track, no cookie), the no-delete
contract on navigation, and tracking resumption on an allowed path.
…nect/switch

syncWalletState() previously returned immediately whenever tracking was
suppressed, which skipped disconnects and wallet switches. A wallet learned
on an allowed page therefore survived a disconnect/switch observed on an
excluded route and could attach to later allowed-page events.

Split the suppressed-route behaviour into "never learn new identity" vs
"always clear stale identity":
- syncWalletState(): on a disconnect (no address) clear the namespace(s);
  on a switch (different address in the namespace) drop the stale wallet;
  a fresh connect or same-address re-confirmation stays a no-op.
- persistActiveWallet(): only the *write* path is skipped on an excluded
  host/path; when state has been cleared (no active wallet) the cookie is
  removed even on an excluded route, so stale identity is purged. Passive
  navigation onto an excluded route never calls this method, so a valid
  cookie still survives.
- EIP-1193 autocapture switch: clear the stale EVM wallet instead of merely
  skipping the learn.

Covers the Wagmi disconnect/switch path (WagmiEventHandler routes through
syncWalletState). Add tests for disconnect and switch observed on an
excluded path.
…y gating

Add tests for gaps in the suppression coverage:
- identify()/detect() write no session markers while on an excluded path
- loadActiveWallet() does not restore a snapshot at init on an excluded
  path but preserves the cookie
- loadActiveWallet() purges the snapshot at init for an excluded-timezone
  visitor
…while suppressed

backfillActiveWallet() (used by the autocapture signature/transaction
payload builders) and the EIP-1193 connect listener (onConnected) wrote
EVM chain state directly, bypassing the suppression-aware path. A
signature/transaction or connect observed on an excluded host/path/timezone
could therefore populate currentAddress in memory and leak into later
allowed-route events, even though the event and cookie write were skipped.

- backfillActiveWallet(): no-op while suppressed (it only ever adds an
  address, so there is no stale state to clear).
- onConnected() and onAccountsChanged(): route the suppressed branch through
  a shared clearStaleEvmWalletOnSwitchWhileSuppressed() helper — never learn,
  but drop a stale EVM wallet on a switch.

onChainChanged is unaffected: it updates only chainId, not the address.

Add tests for the backfill path (suppressed no-op + allowed control) and the
shared switch-clear helper.
@yosriady yosriady merged commit b1b86c8 into main Jun 9, 2026
11 checks passed
@yosriady yosriady deleted the claude/country-timezone-tracking-optout-79i8s2 branch June 9, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants