Skip to content
5 changes: 5 additions & 0 deletions app/scripts/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import getObjStructure from './lib/getObjStructure';
import setupEnsIpfsResolver from './lib/ens-ipfs/setup';
import {
getPlatform,
initInstallType,
isWebOrigin,
shouldEmitDappViewedEvent,
} from './lib/util';
Expand Down Expand Up @@ -709,6 +710,10 @@ function saveTimestamp() {
* @returns {Promise} Setup complete.
*/
async function initialize(backup) {
// Initialize install type early so it's cached for MetaMetrics user traits
// This is fire-and-forget - we don't await it to avoid blocking initialization
initInstallType();

const offscreenPromise = isManifestV3 ? createOffscreen() : null;

// Set up connectivity listener IMMEDIATELY for MV3 (before any awaits)
Expand Down
2 changes: 2 additions & 0 deletions app/scripts/controllers/metametrics-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,8 @@ describe('MetaMetricsController', function () {
[MetaMetricsUserTrait.TokenSortPreference]: 'token-sort-key',
[MetaMetricsUserTrait.PrivacyModeEnabled]: true,
[MetaMetricsUserTrait.NetworkFilterPreference]: [],
[MetaMetricsUserTrait.Platform]: 'Chrome',
[MetaMetricsUserTrait.InstallType]: 'unknown',
});
});
});
Expand Down
10 changes: 9 additions & 1 deletion app/scripts/controllers/metametrics-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ import type {
import { SECOND } from '../../../shared/constants/time';
import { isManifestV3 } from '../../../shared/modules/mv3.utils';
import { METAMETRICS_FINALIZE_EVENT_FRAGMENT_ALARM } from '../../../shared/constants/alarms';
import { checkAlarmExists, generateRandomId, isValidDate } from '../lib/util';
import {
checkAlarmExists,
generateRandomId,
getInstallType,
getPlatform,
isValidDate,
} from '../lib/util';
import {
AnonymousTransactionMetaMetricsEvent,
TransactionMetaMetricsEvent,
Expand Down Expand Up @@ -1435,6 +1441,8 @@ export default class MetaMetricsController extends BaseController<
[MetaMetricsUserTrait.ProfileId]: Object.entries(
metamaskState.srpSessionData || {},
)?.[0]?.[1]?.profile?.profileId,
[MetaMetricsUserTrait.Platform]: getPlatform(),
[MetaMetricsUserTrait.InstallType]: getInstallType(),
};

if (!this.previousUserTraits && metamaskState.participateInMetaMetrics) {
Expand Down
44 changes: 44 additions & 0 deletions app/scripts/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import urlLib from 'url';
import { AccessList } from '@ethereumjs/tx';
import BN from 'bn.js';
import { memoize } from 'lodash';
import browser from 'webextension-polyfill';
import {
TransactionEnvelopeType,
TransactionMeta,
Expand Down Expand Up @@ -81,6 +82,47 @@ const getPlatform = () => {
return PLATFORM_CHROME;
};

/**
* Cached install type value.
* Possible values: 'admin', 'development', 'normal', 'sideload', 'other', 'unknown'
* - 'normal' means installed from official store (Chrome Web Store, Firefox Add-ons, etc.)
* - 'development' means loaded unpacked in developer mode
* - 'sideload' means installed by other software
* - 'admin' means installed by admin policy (enterprise)
* - 'unknown' means the value hasn't been fetched yet or fetch failed
*/
let cachedInstallType = 'unknown';

/**
* Initializes the install type by fetching it from the browser API.
* This should be called early in the extension lifecycle.
* The result is cached and can be retrieved synchronously via getInstallType().
*
* @returns A promise that resolves to the install type string
*/
const initInstallType = async (): Promise<string> => {
try {
const extensionInfo = await browser.management.getSelf();
if (extensionInfo.installType) {
cachedInstallType = extensionInfo.installType;
}
} catch (error) {
// Silently fail - install type will remain 'unknown'
console.error('Error getting extension installType', error);
}
return cachedInstallType;
};

/**
* Returns the cached install type.
* Call initInstallType() first to populate the cache.
*
* @returns The install type string ('normal', 'development', 'sideload', 'admin', 'other', or 'unknown')
*/
const getInstallType = (): string => {
return cachedInstallType;
};

/**
* Converts a hex string to a BN object
*
Expand Down Expand Up @@ -159,8 +201,10 @@ export {
checkAlarmExists,
getChainType,
getEnvironmentType,
getInstallType,
getPlatform,
hexToBn,
initInstallType,
};

// Taken from https://stackoverflow.com/a/1349426/3696652
Expand Down
20 changes: 20 additions & 0 deletions shared/constants/metametrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,18 @@ export type MetaMetricsUserTraits = {
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
rewards_referral_code_used?: string;
/**
* The platform (browser) where the extension is running.
* Can be 'Chrome', 'Firefox', 'Brave', 'Edge', or 'Opera'.
*/
platform?: string;
/**
* The installation type of the extension.
* Can be 'normal' (official store), 'development', 'sideload', 'admin', 'other', or 'unknown'.
*/
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
install_type?: string;
};

export enum MetaMetricsUserTrait {
Expand Down Expand Up @@ -681,6 +693,14 @@ export enum MetaMetricsUserTrait {
HasRewardsOptedIn = 'has_rewards_opted_in',
RewardsReferred = 'rewards_referred',
RewardsReferralCodeUsed = 'rewards_referral_code_used',
/**
* The platform (browser) where the extension is running.
*/
Platform = 'platform',
/**
* The installation type of the extension.
*/
InstallType = 'install_type',
}

/**
Expand Down
Loading