Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ import {
isCustomEnrichmentEnabled,
} from './default-tracking';
import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper';
import { Context } from './plugins/context';
import { getWebEnvironment } 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';
Expand Down Expand Up @@ -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', getWebEnvironment());
if (typeof navigator !== 'undefined') {
diagnosticsClient.setTag('user_agent', navigator.userAgent);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/analytics-browser/src/plugins/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
75 changes: 75 additions & 0 deletions packages/analytics-browser/src/utils/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { getGlobalScope } from '@amplitude/analytics-core';

export type WebEnvironment =
| 'browser'
| 'web_worker'
| 'service_worker'
| 'chrome_extension'
| 'chrome_extension_service_worker'
| 'node'
| 'unknown';

type Scope = NonNullable<ReturnType<typeof getGlobalScope>>;

// 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 — getWebEnvironment 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 getWebEnvironment(): WebEnvironment {
Comment thread
Mercy811 marked this conversation as resolved.
Outdated
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';
}
4 changes: 4 additions & 0 deletions packages/analytics-browser/test/browser-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
94 changes: 94 additions & 0 deletions packages/analytics-browser/test/utils/environment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as core from '@amplitude/analytics-core';
import { getWebEnvironment } 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('getWebEnvironment', () => {
let getGlobalScopeSpy: jest.SpyInstance;

beforeEach(() => {
getGlobalScopeSpy = jest.spyOn(core, 'getGlobalScope');
});

afterEach(() => {
getGlobalScopeSpy.mockRestore();
});

test('returns unknown when globalScope is undefined', () => {
getGlobalScopeSpy.mockReturnValue(undefined);
expect(getWebEnvironment()).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(getWebEnvironment()).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(getWebEnvironment()).toBe('chrome_extension_service_worker');
});

test('returns web_worker in a dedicated or shared worker scope', () => {
getGlobalScopeSpy.mockReturnValue(workerGlobalScope());
expect(getWebEnvironment()).toBe('web_worker');
});

test('returns browser when a document is present', () => {
getGlobalScopeSpy.mockReturnValue(asScope({ document: {} }));
expect(getWebEnvironment()).toBe('browser');
});

test('returns chrome_extension for extension pages and content scripts', () => {
getGlobalScopeSpy.mockReturnValue(asScope({ document: {}, chrome: { runtime: { id: 'ext-abc' } } }));
expect(getWebEnvironment()).toBe('chrome_extension');
});

test('returns browser when chrome.runtime.id is not a string', () => {
getGlobalScopeSpy.mockReturnValue(asScope({ document: {}, chrome: { runtime: { id: 1 } } }));
expect(getWebEnvironment()).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(getWebEnvironment()).toBe('browser');
});

test('returns node when process.versions.node is present without a document', () => {
getGlobalScopeSpy.mockReturnValue(asScope({ process: { versions: { node: '20.0.0' } } }));
expect(getWebEnvironment()).toBe('node');
});

test('returns unknown when nothing matches', () => {
getGlobalScopeSpy.mockReturnValue(asScope({}));
expect(getWebEnvironment()).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(getWebEnvironment()).toBe('unknown');
});
});
Loading