Skip to content

feat(analytics-controller): resolve geolocation only after opt-in - #9728

Merged
gauthierpetetin merged 4 commits into
mainfrom
feat/analytics-geolocation-opt-in
Jul 31, 2026
Merged

feat(analytics-controller): resolve geolocation only after opt-in#9728
gauthierpetetin merged 4 commits into
mainfrom
feat/analytics-geolocation-opt-in

Conversation

@gauthierpetetin

@gauthierpetetin gauthierpetetin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Explanation

Follow-up to #9691, which added geo-enrichment of analytics events. As originally merged, the AnalyticsController resolved the user's geolocation during init regardless of consent state — so a user's location could be requested before they had opted in to analytics (for example, during onboarding).

This PR gates geolocation resolution behind user consent:

  • Resolution now runs only once the user is opted in: during init for an already-opted-in user, otherwise deferred to optIn. A user's location is never requested before they consent to analytics.
  • AnalyticsController.optIn becomes async (returns Promise<void>). It resolves and awaits geolocation before replaying queued pre-consent events, so those events are enriched with the resolved location as they replay. Anonymous payloads are excluded, per ADR-0008.
  • Resolution is started at most once per session (#maybeResolveLocation is idempotent).

init was already async (from #9691); optIn becoming async is the one new breaking change for consumers, who must now await it.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Breaking API change (optIn async) plus consent-sensitive geolocation and pre-consent replay timing; behavior is well-tested but integrators must await optIn and register GeolocationController before consent.

Overview
Follow-up to geo-enrichment in AnalyticsController: geolocation is no longer fetched during init for undecided or opted-out users. With isGeolocationEnabled, resolution runs only after the user has opted in—on init if already opted in, otherwise on optIn—via a one-shot #maybeResolveLocation path.

optIn is now async (Promise<void>): it awaits geolocation, then calls #reconcilePreConsentEvents instead of replaying the pre-consent queue immediately, so events are not sent if consent was reset while location was resolving. Queued pre-consent events get context.location on replay through #enrichPreConsentEvent; anonymous track payloads stay without location.

The changelog documents the breaking await optIn() requirement for consumers.

Reviewed by Cursor Bugbot for commit a8c6462. Bugbot is set up for automated code reviews on this repo. Configure here.

gauthierpetetin and others added 2 commits July 31, 2026 06:06
Gate geolocation resolution behind user consent so a user's location is
never requested before they opt in to analytics (for example, during
onboarding).

- `optIn` is now async: it resolves and awaits geolocation before
  replaying queued pre-consent events, so those events are enriched with
  the resolved location on replay (anonymous payloads excluded).
- Resolution runs at most once per session: during `init` for an already
  opted-in user, otherwise on `optIn`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR updates @metamask/analytics-controller to ensure geolocation is only resolved after a user has opted in to analytics, preventing any location request prior to consent while still enriching queued pre-consent events upon replay.

Changes:

  • Gate geolocation resolution behind opt-in via an idempotent per-session resolver (#maybeResolveLocation) and defer resolution until optIn() when the user was previously undecided/opted out.
  • Make AnalyticsController.optIn asynchronous and await geolocation resolution before replaying queued pre-consent events (excluding anonymous payloads).
  • Update tests and changelog to reflect the new consent-gated behavior and the async optIn() breaking change.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
packages/analytics-controller/src/AnalyticsController.ts Defers geolocation resolution until the user is opted in; makes optIn() async and enriches replayed pre-consent events after resolution.
packages/analytics-controller/src/AnalyticsController.test.ts Updates/extends tests to validate opt-in-gated geolocation resolution and replay enrichment, and awaits optIn().
packages/analytics-controller/src/AnalyticsController-method-action-types.ts Updates action documentation to reflect async opt-in and geolocation gating behavior.
packages/analytics-controller/CHANGELOG.md Documents the breaking async optIn() change and revised geolocation resolution timing/behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/analytics-controller/src/AnalyticsController.ts
Match the conditional-spread pattern used elsewhere so a replayed
pre-consent event does not gain an explicit `context: undefined` key when
geolocation is unresolved and the event carried no context, keeping
queued/persisted payloads JSON-shaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gauthierpetetin
gauthierpetetin marked this pull request as ready for review July 31, 2026 04:17
@gauthierpetetin
gauthierpetetin requested review from a team as code owners July 31, 2026 04:17

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 95d85f1. Configure here.

Comment thread packages/analytics-controller/src/AnalyticsController.ts
optIn now awaits geolocation before draining the pre-consent queue, which
opens a window where the consent decision can change (e.g.
resetConsentDecision) while resolution is in flight. Replaying blindly
after the await could deliver preserved pre-consent events once the user
is undecided again.

Reconcile against the current state after the await (as #performInit
already does) instead of calling #replayPreConsentEvents directly, so
preserved events are only delivered while the user is still opted in.
#replayPreConsentEvents now receives the validated queue from its sole
caller, dropping its redundant guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
async #resolveLocationContext(): Promise<void> {
if (!this.#isGeolocationEnabled) {
return;
#maybeResolveLocation(): Promise<void> | undefined {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (non-blocking): Could we explicitly document in JSDoc that a failed geolocation resolution is cached and will not be retried for this controller session?

The implementation is intentional, but the shared settled promise makes that behavior easy to overlook.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, good call, comment added in this PR: #9730

@gauthierpetetin
gauthierpetetin added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit d5d1c63 Jul 31, 2026
208 checks passed
@gauthierpetetin
gauthierpetetin deleted the feat/analytics-geolocation-opt-in branch July 31, 2026 07:03
pull Bot pushed a commit to Reality2byte/core that referenced this pull request Jul 31, 2026
…r session (MetaMask#9730)

## Explanation

Follow-up to MetaMask#9728. A reviewer
[noted](MetaMask#9728 (comment))
that the at-most-once resolution behavior of `#maybeResolveLocation` is
easy to overlook, since `#resolveLocationContext` swallows its own
errors (the shared promise resolves rather than rejects, and the
`#locationResolvePromise !== undefined` guard then blocks any retry).

This adds a JSDoc paragraph to `#maybeResolveLocation` making the
behavior explicit: a failed resolution is not retried, and events are
delivered without location for the rest of the session.

## References

- Follow-up to MetaMask#9728
- Addresses [review
comment](MetaMask#9728 (comment))
by @NicolasMassart

## Checklist

- [ ] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [ ] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> JSDoc-only change with no behavioral impact.
> 
> **Overview**
> Adds a JSDoc paragraph on **`#maybeResolveLocation`** so reviewers and
maintainers can see behavior that was easy to miss: geolocation
resolution runs **at most once per controller session**, the settled
promise is kept (including after **`#resolveLocationContext`** swallows
errors), failures are **not retried**, and analytics events go out
**without location** for the rest of the session.
> 
> No runtime or test changes—documentation only, following review on
MetaMask#9728.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
46c2c80. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
BenraouaneSoufiane pushed a commit to BenraouaneSoufiane/metamask-extension that referenced this pull request Jul 31, 2026
## **Description**

Enables geolocation enrichment of analytics events in the extension,
wiring it up to the core changes in
[MetaMask/core#9691](MetaMask/core#9691) and
[MetaMask/core#9728](MetaMask/core#9728)
(implements [ADR-0008](MetaMask/decisions#217)).

**Why:** Extension metric events currently reach Segment without
geolocation, unlike Mobile. This attaches the user's country, region,
and timezone so downstream destinations (Segment, BigQuery, Mixpanel)
receive the same fields on both clients.

**What changed:**
- Enable `isGeolocationEnabled` on `AnalyticsController`, so it resolves
the user's location and attaches `country_code` / `region` / `timezone`
to `context.location` on non-anonymous track, identify, and view
payloads.
- Delegate `GeolocationController:getGeolocationData` to the analytics
controller messenger.
- Delegate `GeolocationApiService:fetchGeolocationData` (the v2 action,
renamed from `fetchGeolocation`) on the geolocation controller
messenger.
- Initialize `GeolocationApiService` + `GeolocationController`
**before** `AnalyticsController` in the controller init list, since
`AnalyticsController.init()` resolves geolocation during its own init
via the messenger.
- Update the `GeolocationController` init test fixture for the new
`country` / `region` / `timezone` state fields.

**Dependency / status:** This is tested against **core preview builds**
(`previewBuilds` manifest in `package.json`), since the core PR is not
yet released. Keep as **draft** until
[MetaMask/core#9691](MetaMask/core#9691) merges
and publishes, then swap the preview versions for the released package
versions.

## **Changelog**

CHANGELOG entry: null

<!-- Analytics enrichment plumbing; no user-facing UI change. Recommend
the `no-changelog` label. -->

## **Related issues**

Related to:
[MetaMask/core#9691](MetaMask/core#9691),
[ADR-0008](MetaMask/decisions#217)

Fixes: MetaMask/MetaMask-planning#7001

## **Manual testing steps**

1. Build and load the extension with analytics enabled (opted in).
2. Trigger some events (e.g. open the wallet, view a screen).
3. Inspect the outgoing Segment payloads and confirm non-anonymous
events carry `context.location` with `country_code`, `region`, and
`timezone`; anonymous payloads carry no location.

## **Screenshots/Recordings**

N/A — no UI changes.

### **Before**

### **After**

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes analytics payload shape and startup ordering for opted-in
users, and adds network calls to geolocation hosts; behavior is gated on
analytics consent in core but touches metrics and privacy-sensitive
location fields.
> 
> **Overview**
> Turns on geolocation enrichment for extension analytics by upgrading
`@metamask/analytics-controller` to v2 and
`@metamask/geolocation-controller` to v1, then wiring them together at
init time.
> 
> `AnalyticsController` is constructed with **`isGeolocationEnabled:
true`**, and its messenger is allowed to call
**`GeolocationController:getGeolocationData`**. The geolocation stack
delegates **`GeolocationApiService:fetchGeolocationData`** (v2 API
action rename). **`GeolocationApiService` and `GeolocationController`
now initialize immediately before `AnalyticsController`** so location
can be resolved during analytics `init()`.
> 
> E2E and privacy fixtures add mocks/allowlist entries for
`geolocation.api.cx.metamask.io` (and dev host), plus state snapshots
for `country` / `region` / `timezone`. Lavamoat policies drop a nested
geolocation `controller-utils` entry in favor of the shared package.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
071aa2b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
Bigshmow pushed a commit to MetaMask/metamask-extension that referenced this pull request Aug 5, 2026
## **Description**

Enables geolocation enrichment of analytics events in the extension,
wiring it up to the core changes in
[MetaMask/core#9691](MetaMask/core#9691) and
[MetaMask/core#9728](MetaMask/core#9728)
(implements [ADR-0008](MetaMask/decisions#217)).

**Why:** Extension metric events currently reach Segment without
geolocation, unlike Mobile. This attaches the user's country, region,
and timezone so downstream destinations (Segment, BigQuery, Mixpanel)
receive the same fields on both clients.

**What changed:**
- Enable `isGeolocationEnabled` on `AnalyticsController`, so it resolves
the user's location and attaches `country_code` / `region` / `timezone`
to `context.location` on non-anonymous track, identify, and view
payloads.
- Delegate `GeolocationController:getGeolocationData` to the analytics
controller messenger.
- Delegate `GeolocationApiService:fetchGeolocationData` (the v2 action,
renamed from `fetchGeolocation`) on the geolocation controller
messenger.
- Initialize `GeolocationApiService` + `GeolocationController`
**before** `AnalyticsController` in the controller init list, since
`AnalyticsController.init()` resolves geolocation during its own init
via the messenger.
- Update the `GeolocationController` init test fixture for the new
`country` / `region` / `timezone` state fields.

**Dependency / status:** This is tested against **core preview builds**
(`previewBuilds` manifest in `package.json`), since the core PR is not
yet released. Keep as **draft** until
[MetaMask/core#9691](MetaMask/core#9691) merges
and publishes, then swap the preview versions for the released package
versions.

## **Changelog**

CHANGELOG entry: null

<!-- Analytics enrichment plumbing; no user-facing UI change. Recommend
the `no-changelog` label. -->

## **Related issues**

Related to:
[MetaMask/core#9691](MetaMask/core#9691),
[ADR-0008](MetaMask/decisions#217)

Fixes: MetaMask/MetaMask-planning#7001

## **Manual testing steps**

1. Build and load the extension with analytics enabled (opted in).
2. Trigger some events (e.g. open the wallet, view a screen).
3. Inspect the outgoing Segment payloads and confirm non-anonymous
events carry `context.location` with `country_code`, `region`, and
`timezone`; anonymous payloads carry no location.

## **Screenshots/Recordings**

N/A — no UI changes.

### **Before**

### **After**

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes analytics payload shape and startup ordering for opted-in
users, and adds network calls to geolocation hosts; behavior is gated on
analytics consent in core but touches metrics and privacy-sensitive
location fields.
> 
> **Overview**
> Turns on geolocation enrichment for extension analytics by upgrading
`@metamask/analytics-controller` to v2 and
`@metamask/geolocation-controller` to v1, then wiring them together at
init time.
> 
> `AnalyticsController` is constructed with **`isGeolocationEnabled:
true`**, and its messenger is allowed to call
**`GeolocationController:getGeolocationData`**. The geolocation stack
delegates **`GeolocationApiService:fetchGeolocationData`** (v2 API
action rename). **`GeolocationApiService` and `GeolocationController`
now initialize immediately before `AnalyticsController`** so location
can be resolved during analytics `init()`.
> 
> E2E and privacy fixtures add mocks/allowlist entries for
`geolocation.api.cx.metamask.io` (and dev host), plus state snapshots
for `country` / `region` / `timezone`. Lavamoat policies drop a nested
geolocation `controller-utils` entry in favor of the shared package.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
071aa2b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
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.

3 participants