From 0aa6f18de73841024525ea52669fb30cc2c6b22b Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 23 Jun 2026 21:39:28 -0700 Subject: [PATCH 1/6] feat: add AsyncLocalStorage shim for browser compatibility --- core/src/utils/async_hooks_shim.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 core/src/utils/async_hooks_shim.ts diff --git a/core/src/utils/async_hooks_shim.ts b/core/src/utils/async_hooks_shim.ts new file mode 100644 index 00000000..ae0790de --- /dev/null +++ b/core/src/utils/async_hooks_shim.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export class AsyncLocalStorage { + private store: T | undefined; + run(store: T, callback: () => R): R { + const previous = this.store; + this.store = store; + try { + return callback(); + } finally { + this.store = previous; + } + } + getStore(): T | undefined { + return this.store; + } +} From 5031db095f2131bdf2aee13ad5c210ad08ef5741 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 23 Jun 2026 21:39:39 -0700 Subject: [PATCH 2/6] build: alias node:async_hooks for browser build --- core/build.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/build.js b/core/build.js index 1c10c75e..da5b54f6 100644 --- a/core/build.js +++ b/core/build.js @@ -50,6 +50,12 @@ function build({ logLevel: 'info', }; + if (platform === 'browser') { + buildOptions.alias = { + 'node:async_hooks': './src/utils/async_hooks_shim.ts', + }; + } + // Prepend license header to the top of the file if (format === 'cjs' || bundle) { buildOptions.banner = {js: licenseHeaderText}; From 06cd7ca6f13a9f76a4e1529c0a96ba63f3754e0d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 23 Jun 2026 21:39:54 -0700 Subject: [PATCH 3/6] feat: implement configurable client labels and user agent parser --- core/src/utils/client_labels.ts | 75 ++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/core/src/utils/client_labels.ts b/core/src/utils/client_labels.ts index 1a803a98..e1e07e69 100644 --- a/core/src/utils/client_labels.ts +++ b/core/src/utils/client_labels.ts @@ -4,17 +4,50 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {version} from '../version.js'; - -import {isBrowser} from './env_aware_utils.js'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { version } from '../version.js'; +import { isBrowser } from './env_aware_utils.js'; const ADK_LABEL = 'google-adk'; const LANGUAGE_LABEL = 'gl-typescript'; const AGENT_ENGINE_TELEMETRY_TAG = 'remote_reasoning_engine'; const AGENT_ENGINE_TELEMETRY_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_AGENT_ENGINE_ID'; -// TODO: b/468053794 - Configurable client labels in AsyncLocalStorage and/or -// browser equivalent +export const EVAL_CLIENT_LABEL = `google-adk-eval/${version}`; + +const clientLabelLocalStorage = new AsyncLocalStorage(); + +export function parseUserAgent(userAgent: string): string { + if (!userAgent) { + return 'Browser'; + } + + // Edge + const edgeMatch = userAgent.match(/(?:Edg|Edge|EdgA)\/([0-9\.]+)/i); + if (edgeMatch) { + return `Edge/${edgeMatch[1]}`; + } + + // Firefox + const firefoxMatch = userAgent.match(/(?:Firefox|FxiOS)\/([0-9\.]+)/i); + if (firefoxMatch) { + return `Firefox/${firefoxMatch[1]}`; + } + + // Chrome + const chromeMatch = userAgent.match(/(?:Chrome|CriOS)\/([0-9\.]+)/i); + if (chromeMatch) { + return `Chrome/${chromeMatch[1]}`; + } + + // Safari + const safariMatch = userAgent.match(/Version\/([0-9\.]+).*Safari/i); + if (safariMatch) { + return `Safari/${safariMatch[1]}`; + } + + return 'Browser'; +} function _getDefaultLabels(): string[] { let frameworkLabel = `${ADK_LABEL}/${version}`; @@ -23,19 +56,39 @@ function _getDefaultLabels(): string[] { frameworkLabel = `${frameworkLabel}+${AGENT_ENGINE_TELEMETRY_TAG}`; } - // TODO: b/468051563 - Consider extracting browser name and version from - // userAgent string - // eslint-disable-next-line no-undef - const languageLabel = `${LANGUAGE_LABEL}/${isBrowser() ? window.navigator.userAgent : process.version}`; + let languageLabelDetail: string; + if (isBrowser()) { + // eslint-disable-next-line no-undef + languageLabelDetail = parseUserAgent(window.navigator.userAgent); + } else { + languageLabelDetail = process.version; + } + + const languageLabel = `${LANGUAGE_LABEL}/${languageLabelDetail}`; return [frameworkLabel, languageLabel]; } +export function runWithClientLabel(clientLabel: string, callback: () => R): R { + if (typeof clientLabel !== 'string' || clientLabel.trim() === '') { + throw new Error('Client label must be a non-empty string.'); + } + + const existingLabel = clientLabelLocalStorage.getStore(); + if (existingLabel) { + throw new Error('Client label already exists. You can only add one client label.'); + } + + return clientLabelLocalStorage.run(clientLabel, callback); +} + /** * Returns the current list of client labels that can be added to HTTP Headers. */ export function getClientLabels(): string[] { const labels = _getDefaultLabels(); - // TODO: b/468053794 - Configurable client labels in AsyncLocalStorage and/or - // browser equivalent + const contextLabel = clientLabelLocalStorage.getStore(); + if (contextLabel) { + labels.push(contextLabel); + } return labels; } From f9680167238a116ca12a5ee5fb9ca9142c379f88 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 23 Jun 2026 21:40:07 -0700 Subject: [PATCH 4/6] feat: export client label utilities from common.ts --- core/src/common.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/src/common.ts b/core/src/common.ts index 19109299..0018bb74 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -244,6 +244,11 @@ export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js'; export {zodObjectToSchema} from './utils/simple_zod_to_json.js'; export {GoogleLLMVariant} from './utils/variant_utils.js'; export {version} from './version.js'; +export { + runWithClientLabel, + EVAL_CLIENT_LABEL, + getClientLabels, +} from './utils/client_labels.js'; export {GCPSkillRegistry} from './skills/gcp_skill_registry.js'; export type {GCPSkillRegistryOptions} from './skills/gcp_skill_registry.js'; From 4bdd3caef3a8949566816ef4b5bda28654935919 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 24 Jun 2026 16:15:46 -0700 Subject: [PATCH 5/6] fix(client-labels): apply audit feedback, remove EVAL_CLIENT_LABEL and nesting checks, add integration test --- core/build.js | 2 +- core/src/common.ts | 6 +- core/src/utils/client_labels.ts | 61 ++++++------------ core/test/models/base_llm_test.ts | 11 ++++ core/test/utils/client_labels_test.ts | 92 ++++++++++++++++++++++++++- package-lock.json | 1 - 6 files changed, 124 insertions(+), 49 deletions(-) diff --git a/core/build.js b/core/build.js index da5b54f6..99d01f9d 100644 --- a/core/build.js +++ b/core/build.js @@ -50,7 +50,7 @@ function build({ logLevel: 'info', }; - if (platform === 'browser') { + if (platform === 'browser' && bundle) { buildOptions.alias = { 'node:async_hooks': './src/utils/async_hooks_shim.ts', }; diff --git a/core/src/common.ts b/core/src/common.ts index 0018bb74..046626fe 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -238,17 +238,13 @@ export type { VertexAiSearchToolParams, } from './tools/vertex_ai_search_tool.js'; export {VertexRagRetrievalTool} from './tools/vertex_rag_retrieval_tool.js'; +export {getClientLabels, runWithClientLabel} from './utils/client_labels.js'; export {LogLevel, getLogger, setLogLevel, setLogger} from './utils/logger.js'; export type {Logger} from './utils/logger.js'; export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js'; export {zodObjectToSchema} from './utils/simple_zod_to_json.js'; export {GoogleLLMVariant} from './utils/variant_utils.js'; export {version} from './version.js'; -export { - runWithClientLabel, - EVAL_CLIENT_LABEL, - getClientLabels, -} from './utils/client_labels.js'; export {GCPSkillRegistry} from './skills/gcp_skill_registry.js'; export type {GCPSkillRegistryOptions} from './skills/gcp_skill_registry.js'; diff --git a/core/src/utils/client_labels.ts b/core/src/utils/client_labels.ts index e1e07e69..dcf2aaaf 100644 --- a/core/src/utils/client_labels.ts +++ b/core/src/utils/client_labels.ts @@ -4,17 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { version } from '../version.js'; -import { isBrowser } from './env_aware_utils.js'; +import {AsyncLocalStorage} from 'node:async_hooks'; +import {version} from '../version.js'; +import {isBrowser} from './env_aware_utils.js'; const ADK_LABEL = 'google-adk'; const LANGUAGE_LABEL = 'gl-typescript'; const AGENT_ENGINE_TELEMETRY_TAG = 'remote_reasoning_engine'; const AGENT_ENGINE_TELEMETRY_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_AGENT_ENGINE_ID'; -export const EVAL_CLIENT_LABEL = `google-adk-eval/${version}`; - const clientLabelLocalStorage = new AsyncLocalStorage(); export function parseUserAgent(userAgent: string): string { @@ -22,28 +20,16 @@ export function parseUserAgent(userAgent: string): string { return 'Browser'; } - // Edge - const edgeMatch = userAgent.match(/(?:Edg|Edge|EdgA)\/([0-9\.]+)/i); - if (edgeMatch) { - return `Edge/${edgeMatch[1]}`; - } - - // Firefox - const firefoxMatch = userAgent.match(/(?:Firefox|FxiOS)\/([0-9\.]+)/i); - if (firefoxMatch) { - return `Firefox/${firefoxMatch[1]}`; - } - - // Chrome - const chromeMatch = userAgent.match(/(?:Chrome|CriOS)\/([0-9\.]+)/i); - if (chromeMatch) { - return `Chrome/${chromeMatch[1]}`; - } - - // Safari - const safariMatch = userAgent.match(/Version\/([0-9\.]+).*Safari/i); - if (safariMatch) { - return `Safari/${safariMatch[1]}`; + for (const [name, regex] of [ + ['Edge', /(?:Edg|Edge|EdgA)\/([0-9.]+)/i], + ['Firefox', /(?:Firefox|FxiOS)\/([0-9.]+)/i], + ['Chrome', /(?:Chrome|CriOS)\/([0-9.]+)/i], + ['Safari', /Version\/([0-9.]+).*Safari/i], + ] as const) { + const match = userAgent.match(regex); + if (match) { + return `${name}/${match[1]}`; + } } return 'Browser'; @@ -56,28 +42,23 @@ function _getDefaultLabels(): string[] { frameworkLabel = `${frameworkLabel}+${AGENT_ENGINE_TELEMETRY_TAG}`; } - let languageLabelDetail: string; - if (isBrowser()) { - // eslint-disable-next-line no-undef - languageLabelDetail = parseUserAgent(window.navigator.userAgent); - } else { - languageLabelDetail = process.version; - } + // eslint-disable-next-line no-undef + const languageLabelDetail = isBrowser() + ? parseUserAgent(window.navigator.userAgent) + : process.version; const languageLabel = `${LANGUAGE_LABEL}/${languageLabelDetail}`; return [frameworkLabel, languageLabel]; } -export function runWithClientLabel(clientLabel: string, callback: () => R): R { +export function runWithClientLabel( + clientLabel: string, + callback: () => R, +): R { if (typeof clientLabel !== 'string' || clientLabel.trim() === '') { throw new Error('Client label must be a non-empty string.'); } - const existingLabel = clientLabelLocalStorage.getStore(); - if (existingLabel) { - throw new Error('Client label already exists. You can only add one client label.'); - } - return clientLabelLocalStorage.run(clientLabel, callback); } diff --git a/core/test/models/base_llm_test.ts b/core/test/models/base_llm_test.ts index fe2fbc15..24c2020e 100644 --- a/core/test/models/base_llm_test.ts +++ b/core/test/models/base_llm_test.ts @@ -10,6 +10,7 @@ import { LlmRequest, LlmResponse, isBaseLlm, + runWithClientLabel, version, } from '@google/adk'; import {describe, expect, it} from 'vitest'; @@ -66,6 +67,16 @@ describe('BaseLlm', () => { expect(headers['x-goog-api-client']).toEqual(expectedValue); expect(headers['user-agent']).toEqual(expectedValue); }); + + it('should include context client label in tracking headers when run within runWithClientLabel', () => { + const llm = new TestLlm(); + const customLabel = 'my-custom-label'; + runWithClientLabel(customLabel, () => { + const headers = llm.getTrackingHeaders(); + expect(headers['x-goog-api-client']).toContain(customLabel); + expect(headers['user-agent']).toContain(customLabel); + }); + }); }); describe('isBaseLlm', () => { diff --git a/core/test/utils/client_labels_test.ts b/core/test/utils/client_labels_test.ts index 0d071d33..19037897 100644 --- a/core/test/utils/client_labels_test.ts +++ b/core/test/utils/client_labels_test.ts @@ -4,10 +4,54 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {getClientLabels, runWithClientLabel} from '@google/adk'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {getClientLabels} from '../../src/utils/client_labels.js'; +import {parseUserAgent} from '../../src/utils/client_labels.js'; describe('client_labels', () => { + describe('parseUserAgent', () => { + it('should parse Chrome UA', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'; + expect(parseUserAgent(ua)).toBe('Chrome/123.0.0.0'); + }); + + it('should parse Chrome iOS UA', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1'; + expect(parseUserAgent(ua)).toBe('Chrome/123.0.6312.52'); + }); + + it('should parse Firefox UA', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0'; + expect(parseUserAgent(ua)).toBe('Firefox/123.0'); + }); + + it('should parse Firefox iOS UA', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/123.0 Mobile/15E148 Safari/605.1.15'; + expect(parseUserAgent(ua)).toBe('Firefox/123.0'); + }); + + it('should parse Edge UA', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0'; + expect(parseUserAgent(ua)).toBe('Edge/123.0.0.0'); + }); + + it('should parse Safari UA', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15'; + expect(parseUserAgent(ua)).toBe('Safari/17.3'); + }); + + it('should fallback to Browser for unknown UA', () => { + expect(parseUserAgent('Unknown UA')).toBe('Browser'); + expect(parseUserAgent('')).toBe('Browser'); + }); + }); + describe('getClientLabels', () => { const originalEnv = process.env; @@ -52,9 +96,53 @@ describe('client_labels', () => { expect(adkLabel).not.toContain('remote_reasoning_engine'); }); - it('should return exactly two labels in Node.js environment', () => { + it('should return exactly two labels in Node.js environment by default', () => { const labels = getClientLabels(); expect(labels).toHaveLength(2); }); }); + + describe('runWithClientLabel', () => { + it('should append custom label in context', () => { + const customLabel = 'my-custom-label'; + runWithClientLabel(customLabel, () => { + const labels = getClientLabels(); + expect(labels).toContain(customLabel); + expect(labels).toHaveLength(3); + }); + }); + + it('should clean up custom label after callback', () => { + const customLabel = 'my-custom-label'; + runWithClientLabel(customLabel, () => { + // inside + }); + const labels = getClientLabels(); + expect(labels).not.toContain(customLabel); + expect(labels).toHaveLength(2); + }); + + it('should propagate label across async hops', async () => { + const customLabel = 'async-label'; + await runWithClientLabel(customLabel, async () => { + expect(getClientLabels()).toContain(customLabel); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(getClientLabels()).toContain(customLabel); + + await Promise.resolve(); + expect(getClientLabels()).toContain(customLabel); + }); + }); + + it('should throw error for empty label', () => { + expect(() => { + runWithClientLabel('', () => {}); + }).toThrow('Client label must be a non-empty string.'); + + expect(() => { + runWithClientLabel(' ', () => {}); + }).toThrow('Client label must be a non-empty string.'); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 7fe962c0..ab5736cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5171,7 +5171,6 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0" From efd1b8f8e9f7464d5495189d356e330a58a4565b Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 24 Jun 2026 16:28:47 -0700 Subject: [PATCH 6/6] fix(client-labels): fix eslint warning for window in ternary --- core/src/utils/client_labels.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/utils/client_labels.ts b/core/src/utils/client_labels.ts index dcf2aaaf..a426dc3b 100644 --- a/core/src/utils/client_labels.ts +++ b/core/src/utils/client_labels.ts @@ -42,9 +42,9 @@ function _getDefaultLabels(): string[] { frameworkLabel = `${frameworkLabel}+${AGENT_ENGINE_TELEMETRY_TAG}`; } - // eslint-disable-next-line no-undef const languageLabelDetail = isBrowser() - ? parseUserAgent(window.navigator.userAgent) + ? // eslint-disable-next-line no-undef + parseUserAgent(window.navigator.userAgent) : process.version; const languageLabel = `${LANGUAGE_LABEL}/${languageLabelDetail}`;