diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index 28dda43427..ed1ab5eb50 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -54,7 +54,8 @@ import { isCustomEnrichmentEnabled, } from './default-tracking'; import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper'; -import { Context } from './plugins/context'; +import { getRuntimeEnvironment } from './utils/environment'; +import { BROWSER_PLATFORM, Context } from './plugins/context'; import { useBrowserConfig, createTransport, shouldFetchRemoteConfig } from './config'; import { pageViewTrackingPlugin } from '@amplitude/plugin-page-view-tracking-browser'; import { formInteractionTracking } from './plugins/form-interaction-tracking'; @@ -190,6 +191,8 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An sampleRate: diagnosticsSampleRate, }); diagnosticsClient.setTag('library', `${LIBPREFIX}/${VERSION}`); + diagnosticsClient.setTag('platform', BROWSER_PLATFORM); + diagnosticsClient.setTag('web_environment', getRuntimeEnvironment()); if (typeof navigator !== 'undefined') { diagnosticsClient.setTag('user_agent', navigator.userAgent); } diff --git a/packages/analytics-browser/src/plugins/context.ts b/packages/analytics-browser/src/plugins/context.ts index a5d4174a7f..df65d9444b 100644 --- a/packages/analytics-browser/src/plugins/context.ts +++ b/packages/analytics-browser/src/plugins/context.ts @@ -2,7 +2,7 @@ import { UUID, getLanguage, Event, BeforePlugin, BrowserConfig } from '@amplitud import { VERSION } from '../version'; import { LIBPREFIX } from '../lib-prefix'; -const BROWSER_PLATFORM = 'Web'; +export const BROWSER_PLATFORM = 'Web'; const IP_ADDRESS = '$remote'; export class Context implements BeforePlugin { name = '@amplitude/plugin-context-browser'; diff --git a/packages/analytics-browser/src/utils/environment.ts b/packages/analytics-browser/src/utils/environment.ts new file mode 100644 index 0000000000..a53af96152 --- /dev/null +++ b/packages/analytics-browser/src/utils/environment.ts @@ -0,0 +1,75 @@ +import { getGlobalScope } from '@amplitude/analytics-core'; + +export type RuntimeEnvironment = + | 'browser' + | 'web_worker' + | 'service_worker' + | 'chrome_extension' + | 'chrome_extension_service_worker' + | 'node' + | 'unknown'; + +type Scope = NonNullable>; + +// All predicates take the already-resolved global scope so the whole classification +// reads one consistent object. + +// Same check as core's isChromeExtension, on the resolved scope: extension pages, +// content scripts, and MV3 background workers all expose chrome.runtime.id. +const isChromeExtension = (scope: Scope): boolean => { + return typeof (scope as { chrome?: { runtime?: { id?: unknown } } }).chrome?.runtime?.id === 'string'; +}; + +// A service worker global, including an MV3 extension background worker. +// ServiceWorkerGlobalScope inherits from WorkerGlobalScope, so isWebWorker also matches +// here — getRuntimeEnvironment checks this one first. +const isServiceWorker = (scope: Scope): boolean => { + const ctor = (scope as { ServiceWorkerGlobalScope?: new () => unknown }).ServiceWorkerGlobalScope; + return typeof ctor === 'function' && scope instanceof ctor; +}; + +// Dedicated / shared / service worker. `WorkerGlobalScope` is only exposed on worker +// globals, so its presence is already worker-specific; the instanceof check additionally +// rules out non-browser runtimes that expose web constructors on a server global. +const isWebWorker = (scope: Scope): boolean => { + const ctor = (scope as { WorkerGlobalScope?: new () => unknown }).WorkerGlobalScope; + return typeof ctor === 'function' && scope instanceof ctor; +}; + +// Main-thread only: workers have no document. +const isBrowser = (scope: Scope): boolean => { + return typeof (scope as { document?: unknown }).document !== 'undefined'; +}; + +// Checks process.versions.node rather than bare process: bundlers commonly inject a +// process.env shim into browser builds, but not versions.node. +const isNode = (scope: Scope): boolean => { + return typeof (scope as { process?: { versions?: { node?: unknown } } }).process?.versions?.node === 'string'; +}; + +/** + * Classifies the JS runtime for diagnostics. The check order is load-bearing: + * - service worker before web worker: ServiceWorkerGlobalScope inherits from WorkerGlobalScope + * - browser after the extension split: extension pages and content scripts have a `document` + * - browser before node: jsdom exposes both `document` and `process.versions.node` + */ +export function getRuntimeEnvironment(): RuntimeEnvironment { + const scope = getGlobalScope(); + if (!scope) { + return 'unknown'; + } + const isExtension = isChromeExtension(scope); + if (isServiceWorker(scope)) { + return isExtension ? 'chrome_extension_service_worker' : 'service_worker'; + } + if (isWebWorker(scope)) { + return 'web_worker'; + } + if (isBrowser(scope)) { + return isExtension ? 'chrome_extension' : 'browser'; + } + if (isNode(scope)) { + return 'node'; + } + return 'unknown'; +} diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index c29b1e1c47..5aca152ef0 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -427,6 +427,10 @@ describe('browser-client', () => { expect(client.config.enableDiagnostics).toBe(expectedEnabled); expect(client.config.diagnosticsSampleRate).toBe(expectedSampleRate); + expect(mockDiagnosticsClient.setTag).toHaveBeenCalledWith('platform', 'Web'); + // jsdom: document present, no chrome.runtime + expect(mockDiagnosticsClient.setTag).toHaveBeenCalledWith('web_environment', 'browser'); + // Clean up diagnosticsClientSpy.mockRestore(); }, diff --git a/packages/analytics-browser/test/utils/environment.test.ts b/packages/analytics-browser/test/utils/environment.test.ts new file mode 100644 index 0000000000..d39dc5f180 --- /dev/null +++ b/packages/analytics-browser/test/utils/environment.test.ts @@ -0,0 +1,94 @@ +import * as core from '@amplitude/analytics-core'; +import { getRuntimeEnvironment } from '../../src/utils/environment'; + +// Stands in for a worker global: an instance of the constructor it also exposes. +class FakeWorkerGlobalScope { + WorkerGlobalScope = FakeWorkerGlobalScope; +} + +// Mirrors the real inheritance: ServiceWorkerGlobalScope extends WorkerGlobalScope. +class FakeServiceWorkerGlobalScope extends FakeWorkerGlobalScope { + ServiceWorkerGlobalScope = FakeServiceWorkerGlobalScope; +} + +const asScope = (scope: unknown) => scope as typeof globalThis; +const workerGlobalScope = () => asScope(new FakeWorkerGlobalScope()); +const serviceWorkerGlobalScope = () => asScope(new FakeServiceWorkerGlobalScope()); + +describe('getRuntimeEnvironment', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(core, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns unknown when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(getRuntimeEnvironment()).toBe('unknown'); + }); + + test('returns service_worker in a service worker scope, not web_worker', () => { + // The fake inherits from the worker scope like the real one, so this also + // covers the service-worker-before-web-worker precedence. + getGlobalScopeSpy.mockReturnValue(serviceWorkerGlobalScope()); + expect(getRuntimeEnvironment()).toBe('service_worker'); + }); + + test('returns chrome_extension_service_worker in an MV3 extension background', () => { + const scope = Object.assign(serviceWorkerGlobalScope(), { chrome: { runtime: { id: 'ext-abc' } } }); + getGlobalScopeSpy.mockReturnValue(scope); + expect(getRuntimeEnvironment()).toBe('chrome_extension_service_worker'); + }); + + test('returns web_worker in a dedicated or shared worker scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(getRuntimeEnvironment()).toBe('web_worker'); + }); + + test('returns browser when a document is present', () => { + getGlobalScopeSpy.mockReturnValue(asScope({ document: {} })); + expect(getRuntimeEnvironment()).toBe('browser'); + }); + + test('returns chrome_extension for extension pages and content scripts', () => { + getGlobalScopeSpy.mockReturnValue(asScope({ document: {}, chrome: { runtime: { id: 'ext-abc' } } })); + expect(getRuntimeEnvironment()).toBe('chrome_extension'); + }); + + test('returns browser when chrome.runtime.id is not a string', () => { + getGlobalScopeSpy.mockReturnValue(asScope({ document: {}, chrome: { runtime: { id: 1 } } })); + expect(getRuntimeEnvironment()).toBe('browser'); + }); + + test('ignores worker constructors the scope is not an instance of', () => { + // e.g. a server runtime that exposes web constructors on its global. + getGlobalScopeSpy.mockReturnValue( + asScope({ + ServiceWorkerGlobalScope: FakeServiceWorkerGlobalScope, + WorkerGlobalScope: FakeWorkerGlobalScope, + document: {}, + }), + ); + expect(getRuntimeEnvironment()).toBe('browser'); + }); + + test('returns node when process.versions.node is present without a document', () => { + getGlobalScopeSpy.mockReturnValue(asScope({ process: { versions: { node: '20.0.0' } } })); + expect(getRuntimeEnvironment()).toBe('node'); + }); + + test('returns unknown when nothing matches', () => { + getGlobalScopeSpy.mockReturnValue(asScope({})); + expect(getRuntimeEnvironment()).toBe('unknown'); + }); + + test('returns unknown for shim-only chrome and process objects', () => { + // chrome without runtime; a bundler-injected process.env shim without versions.node. + getGlobalScopeSpy.mockReturnValue(asScope({ chrome: {}, process: { env: {} } })); + expect(getRuntimeEnvironment()).toBe('unknown'); + }); +});