From 6b7a8a43ad7fa544871aaff3c1ba3e80e5fe2ca5 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Wed, 29 Jul 2026 12:31:46 -0700 Subject: [PATCH 1/5] feat(analytics-browser): add platform tag to diagnostics Report `platform: "Web"` as a diagnostics tag, alongside the existing `library` and `user_agent` tags. Reuse the `BROWSER_PLATFORM` constant already used for the analytics event `platform` field so the two stay in sync. Mirrors the mobile SDKs, which set the same `platform` tag key with "iOS"/"macOS"/"Android". Co-Authored-By: Claude Opus 5 (1M context) --- packages/analytics-browser/src/browser-client.ts | 3 ++- packages/analytics-browser/src/plugins/context.ts | 2 +- packages/analytics-browser/test/browser-client.test.ts | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index 28dda43427..dc2037a5c6 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -54,7 +54,7 @@ import { isCustomEnrichmentEnabled, } from './default-tracking'; import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper'; -import { Context } from './plugins/context'; +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 +190,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An sampleRate: diagnosticsSampleRate, }); diagnosticsClient.setTag('library', `${LIBPREFIX}/${VERSION}`); + diagnosticsClient.setTag('platform', BROWSER_PLATFORM); 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/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index c29b1e1c47..f0ebd9d03d 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -427,6 +427,8 @@ describe('browser-client', () => { expect(client.config.enableDiagnostics).toBe(expectedEnabled); expect(client.config.diagnosticsSampleRate).toBe(expectedSampleRate); + expect(mockDiagnosticsClient.setTag).toHaveBeenCalledWith('platform', 'Web'); + // Clean up diagnosticsClientSpy.mockRestore(); }, From 5659eb69deeb6394392d4514d7acdeb439233ecf Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 30 Jul 2026 09:54:52 -0700 Subject: [PATCH 2/5] feat(analytics-core): add web environment detection helpers Add isBrowser/isWebWorker (copied from the paused #1908 so the two branches converge), plus isServiceWorker, isNode, and a getWebEnvironment() resolver returning one of: browser, web_worker, service_worker, chrome_extension, chrome_extension_service_worker, node, unknown. Check order is load-bearing: ServiceWorkerGlobalScope inherits from WorkerGlobalScope; extension pages have a document; jsdom has both document and process.versions.node. Co-Authored-By: Claude Fable 5 --- packages/analytics-core/src/index.ts | 2 +- .../analytics-core/src/utils/environment.ts | 72 ++++++ .../test/utils/environment.test.ts | 212 ++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index 32a71dad2d..232e27a095 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -182,7 +182,7 @@ export { ExcludeInternalReferrersOptions, EXCLUDE_INTERNAL_REFERRERS_CONDITIONS export { VideoObserver, State as VideoState, type VideoObserverParams } from './observers/video'; export { EmbeddedVideoPlayer, type Vendor as VideoVendor } from './video-analytics/types'; -export { isChromeExtension, isReactNative } from './utils/environment'; +export { isChromeExtension, isReactNative, getWebEnvironment, WebEnvironment } from './utils/environment'; export { translateRemoteConfigToLocal, mergeUrls, diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index d7284fb869..de7b36a653 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -9,3 +9,75 @@ export function isReactNative(): boolean { const globalScope = getGlobalScope() as { navigator?: { product?: string } }; return globalScope?.navigator?.product === 'ReactNative'; } + +// Main-thread browser only. Workers have no `document` — use isWebWorker() for those. +export function isBrowser(): boolean { + const globalScope = getGlobalScope() as { document?: unknown } | undefined; + return typeof globalScope?.document !== 'undefined'; +} + +// True inside a 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. +export function isWebWorker(): boolean { + const globalScope = getGlobalScope() as { WorkerGlobalScope?: new () => unknown } | undefined; + if (!globalScope) { + return false; + } + const workerGlobalScope = globalScope.WorkerGlobalScope; + return typeof workerGlobalScope === 'function' && globalScope instanceof workerGlobalScope; +} + +// A service worker global, including an MV3 extension background worker. +// ServiceWorkerGlobalScope inherits from WorkerGlobalScope, so isWebWorker() also matches +// here — check this one first when distinguishing the two. +export function isServiceWorker(): boolean { + const globalScope = getGlobalScope() as { ServiceWorkerGlobalScope?: new () => unknown } | undefined; + if (!globalScope) { + return false; + } + const serviceWorkerGlobalScope = globalScope.ServiceWorkerGlobalScope; + return typeof serviceWorkerGlobalScope === 'function' && globalScope instanceof serviceWorkerGlobalScope; +} + +// Checks process.versions.node rather than bare `process`: bundlers commonly inject a +// process.env shim into browser builds, but not versions.node. +export function isNode(): boolean { + const globalScope = getGlobalScope() as { process?: { versions?: { node?: unknown } } } | undefined; + return typeof globalScope?.process?.versions?.node === 'string'; +} + +export type WebEnvironment = + | 'browser' + | 'web_worker' + | 'service_worker' + | 'chrome_extension' + | 'chrome_extension_service_worker' + | 'node' + | 'unknown'; + +/** + * 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 { + if (!getGlobalScope()) { + return 'unknown'; + } + const isExtension = isChromeExtension(); + if (isServiceWorker()) { + return isExtension ? 'chrome_extension_service_worker' : 'service_worker'; + } + if (isWebWorker()) { + return 'web_worker'; + } + if (isBrowser()) { + return isExtension ? 'chrome_extension' : 'browser'; + } + if (isNode()) { + return 'node'; + } + return 'unknown'; +} diff --git a/packages/analytics-core/test/utils/environment.test.ts b/packages/analytics-core/test/utils/environment.test.ts index a87371d9b1..0da2780a6d 100644 --- a/packages/analytics-core/test/utils/environment.test.ts +++ b/packages/analytics-core/test/utils/environment.test.ts @@ -1,8 +1,23 @@ import * as analyticsCoreModule from '../../src/index'; +// Not part of the public API — imported from the module under test directly. +import { isBrowser, isNode, isServiceWorker, isWebWorker } from '../../src/utils/environment'; import * as globalScopeModule from '../../src/global-scope'; type ChromeStub = { runtime?: { id?: string | number } }; +// 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 workerGlobalScope = () => new FakeWorkerGlobalScope() as unknown as typeof globalThis; +const serviceWorkerGlobalScope = () => new FakeServiceWorkerGlobalScope() as unknown as typeof globalThis; + describe('isChromeExtension', () => { const originalChrome = (globalThis as typeof globalThis & { chrome?: ChromeStub }).chrome; @@ -72,3 +87,200 @@ describe('isReactNative', () => { expect(analyticsCoreModule.isReactNative()).toBe(true); }); }); + +describe('isBrowser', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isBrowser()).toBe(false); + }); + + test('returns false when document is undefined', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isBrowser()).toBe(false); + }); + + test('returns true when document is defined', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(isBrowser()).toBe(true); + }); +}); + +describe('isWebWorker', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is not exposed', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is exposed but the scope is not a worker', () => { + // e.g. a server runtime that exposes web constructors on its global. + getGlobalScopeSpy.mockReturnValue({ + WorkerGlobalScope: FakeWorkerGlobalScope, + } as unknown as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns true inside a worker global scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isWebWorker()).toBe(true); + }); +}); + +describe('isServiceWorker', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isServiceWorker()).toBe(false); + }); + + test('returns false when ServiceWorkerGlobalScope is not exposed', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isServiceWorker()).toBe(false); + }); + + test('returns false when ServiceWorkerGlobalScope is exposed but the scope is not a service worker', () => { + getGlobalScopeSpy.mockReturnValue({ + ServiceWorkerGlobalScope: FakeServiceWorkerGlobalScope, + } as unknown as typeof globalThis); + expect(isServiceWorker()).toBe(false); + }); + + test('returns false inside a dedicated worker global scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isServiceWorker()).toBe(false); + }); + + test('returns true inside a service worker global scope', () => { + getGlobalScopeSpy.mockReturnValue(serviceWorkerGlobalScope()); + expect(isServiceWorker()).toBe(true); + }); +}); + +describe('isNode', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isNode()).toBe(false); + }); + + test('returns false when process is undefined', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isNode()).toBe(false); + }); + + test('returns false when process.versions is undefined', () => { + // e.g. a bundler-injected process.env shim in a browser build. + getGlobalScopeSpy.mockReturnValue({ process: { env: {} } } as unknown as typeof globalThis); + expect(isNode()).toBe(false); + }); + + test('returns false when process.versions.node is not a string', () => { + getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: 20 } } } as unknown as typeof globalThis); + expect(isNode()).toBe(false); + }); + + test('returns true when process.versions.node is a string', () => { + getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: '20.0.0' } } } as unknown as typeof globalThis); + expect(isNode()).toBe(true); + }); +}); + +describe('getWebEnvironment', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns unknown when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(analyticsCoreModule.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(analyticsCoreModule.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(analyticsCoreModule.getWebEnvironment()).toBe('chrome_extension_service_worker'); + }); + + test('returns web_worker in a dedicated or shared worker scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(analyticsCoreModule.getWebEnvironment()).toBe('web_worker'); + }); + + test('returns browser when a document is present', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(analyticsCoreModule.getWebEnvironment()).toBe('browser'); + }); + + test('returns chrome_extension for extension pages and content scripts', () => { + getGlobalScopeSpy.mockReturnValue({ + document: {}, + chrome: { runtime: { id: 'ext-abc' } }, + } as unknown as typeof globalThis); + expect(analyticsCoreModule.getWebEnvironment()).toBe('chrome_extension'); + }); + + test('returns node when process.versions.node is present without a document', () => { + getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: '20.0.0' } } } as unknown as typeof globalThis); + expect(analyticsCoreModule.getWebEnvironment()).toBe('node'); + }); + + test('returns unknown when nothing matches', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(analyticsCoreModule.getWebEnvironment()).toBe('unknown'); + }); +}); From 48500e71aaf27f03a764a8e2f153d33459f20628 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 30 Jul 2026 09:55:14 -0700 Subject: [PATCH 3/5] feat(analytics-browser): add web_environment tag to diagnostics Report the JS runtime environment alongside the platform tag, so diagnostics can distinguish page / worker / service worker / extension traffic. Named web_environment because Datadog already auto-tracks an environment tag. Co-Authored-By: Claude Fable 5 --- packages/analytics-browser/src/browser-client.ts | 2 ++ packages/analytics-browser/test/browser-client.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index dc2037a5c6..517d06049c 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -28,6 +28,7 @@ import { RemoteConfig, Source, DiagnosticsClient, + getWebEnvironment, createIdentifyEvent, Logger, safeJsonStringify, @@ -191,6 +192,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An }); 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); } diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index f0ebd9d03d..5aca152ef0 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -428,6 +428,8 @@ describe('browser-client', () => { 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(); From 806320be0adf2cce759f10da98e5402ffa5d3563 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 30 Jul 2026 10:06:43 -0700 Subject: [PATCH 4/5] refactor(analytics-browser): move web environment detection out of core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helpers are only consumed by the browser SDK, and core is shared by all platforms — keep it as light as possible. Reverts analytics-core to untouched (which also removes the merge conflict with #1908) and keeps getWebEnvironment() internal to analytics-browser, with predicates taking the resolved global scope so classification reads one consistent object. Co-Authored-By: Claude Fable 5 --- .../analytics-browser/src/browser-client.ts | 2 +- .../src/utils/environment.ts | 75 +++++++ .../test/utils/environment.test.ts | 94 ++++++++ packages/analytics-core/src/index.ts | 2 +- .../analytics-core/src/utils/environment.ts | 72 ------ .../test/utils/environment.test.ts | 212 ------------------ 6 files changed, 171 insertions(+), 286 deletions(-) create mode 100644 packages/analytics-browser/src/utils/environment.ts create mode 100644 packages/analytics-browser/test/utils/environment.test.ts diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index 517d06049c..986122de6e 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -28,7 +28,6 @@ import { RemoteConfig, Source, DiagnosticsClient, - getWebEnvironment, createIdentifyEvent, Logger, safeJsonStringify, @@ -55,6 +54,7 @@ import { isCustomEnrichmentEnabled, } from './default-tracking'; import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper'; +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'; diff --git a/packages/analytics-browser/src/utils/environment.ts b/packages/analytics-browser/src/utils/environment.ts new file mode 100644 index 0000000000..2a1ca3129d --- /dev/null +++ b/packages/analytics-browser/src/utils/environment.ts @@ -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>; + +// 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 { + 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/utils/environment.test.ts b/packages/analytics-browser/test/utils/environment.test.ts new file mode 100644 index 0000000000..411f75ff1e --- /dev/null +++ b/packages/analytics-browser/test/utils/environment.test.ts @@ -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'); + }); +}); diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index 232e27a095..32a71dad2d 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -182,7 +182,7 @@ export { ExcludeInternalReferrersOptions, EXCLUDE_INTERNAL_REFERRERS_CONDITIONS export { VideoObserver, State as VideoState, type VideoObserverParams } from './observers/video'; export { EmbeddedVideoPlayer, type Vendor as VideoVendor } from './video-analytics/types'; -export { isChromeExtension, isReactNative, getWebEnvironment, WebEnvironment } from './utils/environment'; +export { isChromeExtension, isReactNative } from './utils/environment'; export { translateRemoteConfigToLocal, mergeUrls, diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index de7b36a653..d7284fb869 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -9,75 +9,3 @@ export function isReactNative(): boolean { const globalScope = getGlobalScope() as { navigator?: { product?: string } }; return globalScope?.navigator?.product === 'ReactNative'; } - -// Main-thread browser only. Workers have no `document` — use isWebWorker() for those. -export function isBrowser(): boolean { - const globalScope = getGlobalScope() as { document?: unknown } | undefined; - return typeof globalScope?.document !== 'undefined'; -} - -// True inside a 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. -export function isWebWorker(): boolean { - const globalScope = getGlobalScope() as { WorkerGlobalScope?: new () => unknown } | undefined; - if (!globalScope) { - return false; - } - const workerGlobalScope = globalScope.WorkerGlobalScope; - return typeof workerGlobalScope === 'function' && globalScope instanceof workerGlobalScope; -} - -// A service worker global, including an MV3 extension background worker. -// ServiceWorkerGlobalScope inherits from WorkerGlobalScope, so isWebWorker() also matches -// here — check this one first when distinguishing the two. -export function isServiceWorker(): boolean { - const globalScope = getGlobalScope() as { ServiceWorkerGlobalScope?: new () => unknown } | undefined; - if (!globalScope) { - return false; - } - const serviceWorkerGlobalScope = globalScope.ServiceWorkerGlobalScope; - return typeof serviceWorkerGlobalScope === 'function' && globalScope instanceof serviceWorkerGlobalScope; -} - -// Checks process.versions.node rather than bare `process`: bundlers commonly inject a -// process.env shim into browser builds, but not versions.node. -export function isNode(): boolean { - const globalScope = getGlobalScope() as { process?: { versions?: { node?: unknown } } } | undefined; - return typeof globalScope?.process?.versions?.node === 'string'; -} - -export type WebEnvironment = - | 'browser' - | 'web_worker' - | 'service_worker' - | 'chrome_extension' - | 'chrome_extension_service_worker' - | 'node' - | 'unknown'; - -/** - * 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 { - if (!getGlobalScope()) { - return 'unknown'; - } - const isExtension = isChromeExtension(); - if (isServiceWorker()) { - return isExtension ? 'chrome_extension_service_worker' : 'service_worker'; - } - if (isWebWorker()) { - return 'web_worker'; - } - if (isBrowser()) { - return isExtension ? 'chrome_extension' : 'browser'; - } - if (isNode()) { - return 'node'; - } - return 'unknown'; -} diff --git a/packages/analytics-core/test/utils/environment.test.ts b/packages/analytics-core/test/utils/environment.test.ts index 0da2780a6d..a87371d9b1 100644 --- a/packages/analytics-core/test/utils/environment.test.ts +++ b/packages/analytics-core/test/utils/environment.test.ts @@ -1,23 +1,8 @@ import * as analyticsCoreModule from '../../src/index'; -// Not part of the public API — imported from the module under test directly. -import { isBrowser, isNode, isServiceWorker, isWebWorker } from '../../src/utils/environment'; import * as globalScopeModule from '../../src/global-scope'; type ChromeStub = { runtime?: { id?: string | number } }; -// 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 workerGlobalScope = () => new FakeWorkerGlobalScope() as unknown as typeof globalThis; -const serviceWorkerGlobalScope = () => new FakeServiceWorkerGlobalScope() as unknown as typeof globalThis; - describe('isChromeExtension', () => { const originalChrome = (globalThis as typeof globalThis & { chrome?: ChromeStub }).chrome; @@ -87,200 +72,3 @@ describe('isReactNative', () => { expect(analyticsCoreModule.isReactNative()).toBe(true); }); }); - -describe('isBrowser', () => { - let getGlobalScopeSpy: jest.SpyInstance; - - beforeEach(() => { - getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); - }); - - afterEach(() => { - getGlobalScopeSpy.mockRestore(); - }); - - test('returns false when globalScope is undefined', () => { - getGlobalScopeSpy.mockReturnValue(undefined); - expect(isBrowser()).toBe(false); - }); - - test('returns false when document is undefined', () => { - getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(isBrowser()).toBe(false); - }); - - test('returns true when document is defined', () => { - getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); - expect(isBrowser()).toBe(true); - }); -}); - -describe('isWebWorker', () => { - let getGlobalScopeSpy: jest.SpyInstance; - - beforeEach(() => { - getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); - }); - - afterEach(() => { - getGlobalScopeSpy.mockRestore(); - }); - - test('returns false when globalScope is undefined', () => { - getGlobalScopeSpy.mockReturnValue(undefined); - expect(isWebWorker()).toBe(false); - }); - - test('returns false when WorkerGlobalScope is not exposed', () => { - getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(isWebWorker()).toBe(false); - }); - - test('returns false when WorkerGlobalScope is exposed but the scope is not a worker', () => { - // e.g. a server runtime that exposes web constructors on its global. - getGlobalScopeSpy.mockReturnValue({ - WorkerGlobalScope: FakeWorkerGlobalScope, - } as unknown as typeof globalThis); - expect(isWebWorker()).toBe(false); - }); - - test('returns true inside a worker global scope', () => { - getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); - expect(isWebWorker()).toBe(true); - }); -}); - -describe('isServiceWorker', () => { - let getGlobalScopeSpy: jest.SpyInstance; - - beforeEach(() => { - getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); - }); - - afterEach(() => { - getGlobalScopeSpy.mockRestore(); - }); - - test('returns false when globalScope is undefined', () => { - getGlobalScopeSpy.mockReturnValue(undefined); - expect(isServiceWorker()).toBe(false); - }); - - test('returns false when ServiceWorkerGlobalScope is not exposed', () => { - getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(isServiceWorker()).toBe(false); - }); - - test('returns false when ServiceWorkerGlobalScope is exposed but the scope is not a service worker', () => { - getGlobalScopeSpy.mockReturnValue({ - ServiceWorkerGlobalScope: FakeServiceWorkerGlobalScope, - } as unknown as typeof globalThis); - expect(isServiceWorker()).toBe(false); - }); - - test('returns false inside a dedicated worker global scope', () => { - getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); - expect(isServiceWorker()).toBe(false); - }); - - test('returns true inside a service worker global scope', () => { - getGlobalScopeSpy.mockReturnValue(serviceWorkerGlobalScope()); - expect(isServiceWorker()).toBe(true); - }); -}); - -describe('isNode', () => { - let getGlobalScopeSpy: jest.SpyInstance; - - beforeEach(() => { - getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); - }); - - afterEach(() => { - getGlobalScopeSpy.mockRestore(); - }); - - test('returns false when globalScope is undefined', () => { - getGlobalScopeSpy.mockReturnValue(undefined); - expect(isNode()).toBe(false); - }); - - test('returns false when process is undefined', () => { - getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(isNode()).toBe(false); - }); - - test('returns false when process.versions is undefined', () => { - // e.g. a bundler-injected process.env shim in a browser build. - getGlobalScopeSpy.mockReturnValue({ process: { env: {} } } as unknown as typeof globalThis); - expect(isNode()).toBe(false); - }); - - test('returns false when process.versions.node is not a string', () => { - getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: 20 } } } as unknown as typeof globalThis); - expect(isNode()).toBe(false); - }); - - test('returns true when process.versions.node is a string', () => { - getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: '20.0.0' } } } as unknown as typeof globalThis); - expect(isNode()).toBe(true); - }); -}); - -describe('getWebEnvironment', () => { - let getGlobalScopeSpy: jest.SpyInstance; - - beforeEach(() => { - getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); - }); - - afterEach(() => { - getGlobalScopeSpy.mockRestore(); - }); - - test('returns unknown when globalScope is undefined', () => { - getGlobalScopeSpy.mockReturnValue(undefined); - expect(analyticsCoreModule.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(analyticsCoreModule.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(analyticsCoreModule.getWebEnvironment()).toBe('chrome_extension_service_worker'); - }); - - test('returns web_worker in a dedicated or shared worker scope', () => { - getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); - expect(analyticsCoreModule.getWebEnvironment()).toBe('web_worker'); - }); - - test('returns browser when a document is present', () => { - getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); - expect(analyticsCoreModule.getWebEnvironment()).toBe('browser'); - }); - - test('returns chrome_extension for extension pages and content scripts', () => { - getGlobalScopeSpy.mockReturnValue({ - document: {}, - chrome: { runtime: { id: 'ext-abc' } }, - } as unknown as typeof globalThis); - expect(analyticsCoreModule.getWebEnvironment()).toBe('chrome_extension'); - }); - - test('returns node when process.versions.node is present without a document', () => { - getGlobalScopeSpy.mockReturnValue({ process: { versions: { node: '20.0.0' } } } as unknown as typeof globalThis); - expect(analyticsCoreModule.getWebEnvironment()).toBe('node'); - }); - - test('returns unknown when nothing matches', () => { - getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(analyticsCoreModule.getWebEnvironment()).toBe('unknown'); - }); -}); From f2bd14ab97a232ae8432e5e918246164b066b1e8 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Fri, 31 Jul 2026 11:51:13 -0700 Subject: [PATCH 5/5] refactor(analytics-browser): rename getWebEnvironment to getRuntimeEnvironment Some classified environments aren't web (node), so WebEnvironment was a misnomer. The web_environment tag key is unchanged. Co-Authored-By: Claude Fable 5 --- .../analytics-browser/src/browser-client.ts | 4 +-- .../src/utils/environment.ts | 6 ++--- .../test/utils/environment.test.ts | 26 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index 986122de6e..ed1ab5eb50 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -54,7 +54,7 @@ import { isCustomEnrichmentEnabled, } from './default-tracking'; import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper'; -import { getWebEnvironment } from './utils/environment'; +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'; @@ -192,7 +192,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An }); diagnosticsClient.setTag('library', `${LIBPREFIX}/${VERSION}`); diagnosticsClient.setTag('platform', BROWSER_PLATFORM); - diagnosticsClient.setTag('web_environment', getWebEnvironment()); + diagnosticsClient.setTag('web_environment', getRuntimeEnvironment()); if (typeof navigator !== 'undefined') { diagnosticsClient.setTag('user_agent', navigator.userAgent); } diff --git a/packages/analytics-browser/src/utils/environment.ts b/packages/analytics-browser/src/utils/environment.ts index 2a1ca3129d..a53af96152 100644 --- a/packages/analytics-browser/src/utils/environment.ts +++ b/packages/analytics-browser/src/utils/environment.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/analytics-core'; -export type WebEnvironment = +export type RuntimeEnvironment = | 'browser' | 'web_worker' | 'service_worker' @@ -22,7 +22,7 @@ const isChromeExtension = (scope: Scope): boolean => { // A service worker global, including an MV3 extension background worker. // ServiceWorkerGlobalScope inherits from WorkerGlobalScope, so isWebWorker also matches -// here — getWebEnvironment checks this one first. +// 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; @@ -53,7 +53,7 @@ const isNode = (scope: Scope): boolean => { * - 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 { +export function getRuntimeEnvironment(): RuntimeEnvironment { const scope = getGlobalScope(); if (!scope) { return 'unknown'; diff --git a/packages/analytics-browser/test/utils/environment.test.ts b/packages/analytics-browser/test/utils/environment.test.ts index 411f75ff1e..d39dc5f180 100644 --- a/packages/analytics-browser/test/utils/environment.test.ts +++ b/packages/analytics-browser/test/utils/environment.test.ts @@ -1,5 +1,5 @@ import * as core from '@amplitude/analytics-core'; -import { getWebEnvironment } from '../../src/utils/environment'; +import { getRuntimeEnvironment } from '../../src/utils/environment'; // Stands in for a worker global: an instance of the constructor it also exposes. class FakeWorkerGlobalScope { @@ -15,7 +15,7 @@ const asScope = (scope: unknown) => scope as typeof globalThis; const workerGlobalScope = () => asScope(new FakeWorkerGlobalScope()); const serviceWorkerGlobalScope = () => asScope(new FakeServiceWorkerGlobalScope()); -describe('getWebEnvironment', () => { +describe('getRuntimeEnvironment', () => { let getGlobalScopeSpy: jest.SpyInstance; beforeEach(() => { @@ -28,40 +28,40 @@ describe('getWebEnvironment', () => { test('returns unknown when globalScope is undefined', () => { getGlobalScopeSpy.mockReturnValue(undefined); - expect(getWebEnvironment()).toBe('unknown'); + 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(getWebEnvironment()).toBe('service_worker'); + 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(getWebEnvironment()).toBe('chrome_extension_service_worker'); + expect(getRuntimeEnvironment()).toBe('chrome_extension_service_worker'); }); test('returns web_worker in a dedicated or shared worker scope', () => { getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); - expect(getWebEnvironment()).toBe('web_worker'); + expect(getRuntimeEnvironment()).toBe('web_worker'); }); test('returns browser when a document is present', () => { getGlobalScopeSpy.mockReturnValue(asScope({ document: {} })); - expect(getWebEnvironment()).toBe('browser'); + expect(getRuntimeEnvironment()).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'); + 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(getWebEnvironment()).toBe('browser'); + expect(getRuntimeEnvironment()).toBe('browser'); }); test('ignores worker constructors the scope is not an instance of', () => { @@ -73,22 +73,22 @@ describe('getWebEnvironment', () => { document: {}, }), ); - expect(getWebEnvironment()).toBe('browser'); + 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(getWebEnvironment()).toBe('node'); + expect(getRuntimeEnvironment()).toBe('node'); }); test('returns unknown when nothing matches', () => { getGlobalScopeSpy.mockReturnValue(asScope({})); - expect(getWebEnvironment()).toBe('unknown'); + 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(getWebEnvironment()).toBe('unknown'); + expect(getRuntimeEnvironment()).toBe('unknown'); }); });