Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 { 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';
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', getRuntimeEnvironment());
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 RuntimeEnvironment =
| '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 — 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';
}
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 { 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');
});
});
Loading