feat: Add Browser Use extension version to telemetry (no-changelog) (#36602)

This commit is contained in:
Bernhard Wittmann 2026-08-20 15:05:29 +00:00 committed by GitHub
parent 53f2750b3f
commit f7ddca72bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 343 additions and 6 deletions

View File

@ -227,7 +227,10 @@ export type { Logger } from './logger';
export const createDomainAccessTracker: typeof DomainAccessMod.createDomainAccessTracker =
lazyFunction(() => loadDomainAccess().createDomainAccessTracker);
export type { DomainAccessTracker } from './domain-access';
export type { SubmitLangsmithUserFeedbackOptions } from './tracing/langsmith-tracing';
export type {
BrowserExtensionTraceContext,
SubmitLangsmithUserFeedbackOptions,
} from './tracing/langsmith-tracing';
export const emitAgentSnapshotTraceEvent: typeof AgentSnapshotEventMod.emitAgentSnapshotTraceEvent =
lazyFunction(() => loadAgentSnapshotEvent().emitAgentSnapshotTraceEvent);

View File

@ -41,6 +41,8 @@ describe('createDetachedSubAgentTraceFactory', () => {
agent_id: 'agent-1',
n8n_version: '1.123.4',
workflow_sdk_version: '0.13.0',
browser_connection_state: 'connected',
browser_extension_version: '0.0.6',
},
},
},
@ -61,7 +63,53 @@ describe('createDetachedSubAgentTraceFactory', () => {
metadata: { task_id: 'task-1' },
n8nVersion: '1.123.4',
workflowSdkVersion: '0.13.0',
browserExtension: { connectionState: 'connected', version: '0.0.6' },
}),
);
});
it.each([
[
'connected without a reported version',
{ browser_connection_state: 'connected' },
{ connectionState: 'connected' },
],
[
'disconnected',
{ browser_connection_state: 'disconnected' },
{ connectionState: 'disconnected' },
],
['absent from the parent', {}, undefined],
['not a known state', { browser_connection_state: 'sideways' }, undefined],
])(
'propagates the browser extension dimensions when the parent is %s',
async (_case, parentMetadata, expected) => {
mockCreateDetachedSubAgentTraceContext.mockResolvedValue({
rootRun: { id: 'sub-root', traceId: 'sub-trace' },
});
const context = {
threadId: 'thread-1',
runId: 'run-1',
userId: 'user-1',
orchestratorAgentId: 'orchestrator-1',
tracing: {
projectName: 'project-1',
rootRun: { traceId: 'root-trace' },
actorRun: { id: 'actor-run', metadata: parentMetadata },
},
} as unknown as OrchestrationContext;
const createTrace = createDetachedSubAgentTraceFactory(context, {
agentId: 'sub-agent',
role: 'builder',
kind: 'background',
});
await createTrace();
expect(mockCreateDetachedSubAgentTraceContext).toHaveBeenCalledWith(
expect.objectContaining({ browserExtension: expected }),
);
},
);
});

View File

@ -3,6 +3,7 @@ import {
getCurrentOtelSpanContext,
getCurrentTraceToolCallId,
mergeCurrentTraceMetadata,
type BrowserExtensionTraceContext,
} from '../../tracing/langsmith-tracing';
import type {
InstanceAiTraceContext,
@ -11,6 +12,19 @@ import type {
OrchestrationContext,
} from '../../types';
/** Rebuilds the parent's browser-extension dimensions so sub-agent tool calls stay sliceable. */
function readBrowserExtensionFromMetadata(
metadata: Record<string, unknown> | undefined,
): BrowserExtensionTraceContext | undefined {
const state = metadata?.browser_connection_state;
if (state !== 'connected' && state !== 'disconnected') return undefined;
const version = metadata?.browser_extension_version;
return {
connectionState: state,
...(typeof version === 'string' ? { version } : {}),
};
}
type ToolRegistry = OrchestrationContext['domainTools'];
interface StartSubAgentTraceOptions {
@ -84,6 +98,9 @@ export function createDetachedSubAgentTraceFactory(
typeof context.tracing.actorRun.metadata?.workflow_sdk_version === 'string'
? context.tracing.actorRun.metadata.workflow_sdk_version
: undefined;
const parentBrowserExtension = readBrowserExtensionFromMetadata(
context.tracing.actorRun.metadata,
);
const activeSpanContext = getCurrentOtelSpanContext();
const spawnedByToolCallId = getCurrentTraceToolCallId();
@ -102,6 +119,7 @@ export function createDetachedSubAgentTraceFactory(
metadata: options.metadata,
n8nVersion: parentN8nVersion,
workflowSdkVersion: parentWorkflowSdkVersion,
browserExtension: parentBrowserExtension,
agentId: options.agentId,
role: options.role,
kind: options.kind,

View File

@ -439,6 +439,7 @@ describe('createInstanceAiTraceContext', () => {
userId: 'user-1',
input: { message: 'What workflows do I have?' },
metadata: { n8n_version: '2.19.0' },
browserExtension: { connectionState: 'connected', version: '0.0.7' },
});
expect(tracing?.getTelemetry).toBeDefined();
@ -466,6 +467,8 @@ describe('createInstanceAiTraceContext', () => {
user_id: 'user-1',
agent_role: 'orchestrator',
n8n_version: '2.19.0',
browser_extension_version: '0.0.7',
browser_connection_state: 'connected',
execution_mode: 'foreground',
trace_kind: 'message_turn',
langsmith_trace_id: tracing?.rootRun.traceId,
@ -480,6 +483,36 @@ describe('createInstanceAiTraceContext', () => {
await telemetry.provider?.shutdown();
});
it('records a connected extension that reports no version distinctly from no extension', async () => {
const connected = await createInstanceAiTraceContext({
threadId: 'thread-old-ext',
messageId: 'message-old-ext',
runId: 'run-old-ext',
userId: 'user-1',
input: { message: 'What workflows do I have?' },
browserExtension: { connectionState: 'connected' },
});
expect(connected?.rootRun.metadata).toEqual(
expect.objectContaining({ browser_connection_state: 'connected' }),
);
expect(connected?.rootRun.metadata).not.toHaveProperty('browser_extension_version');
const absent = await createInstanceAiTraceContext({
threadId: 'thread-no-ext',
messageId: 'message-no-ext',
runId: 'run-no-ext',
userId: 'user-1',
input: { message: 'What workflows do I have?' },
browserExtension: { connectionState: 'disconnected' },
});
expect(absent?.rootRun.metadata).toEqual(
expect.objectContaining({ browser_connection_state: 'disconnected' }),
);
expect(absent?.rootRun.metadata).not.toHaveProperty('browser_extension_version');
});
it('does not mutate LangSmith tracing environment flags while creating a context', async () => {
delete process.env.LANGSMITH_TRACING;
delete process.env.LANGCHAIN_TRACING_V2;

View File

@ -685,6 +685,11 @@ function getOrCreateProxyClient(proxyConfig: ServiceProxyConfig): Client {
return client;
}
export interface BrowserExtensionTraceContext {
connectionState: 'connected' | 'disconnected';
version?: string;
}
interface CreateInstanceAiTraceContextOptions {
projectName?: string;
threadId: string;
@ -698,6 +703,7 @@ interface CreateInstanceAiTraceContextOptions {
metadata?: Record<string, unknown>;
n8nVersion?: string;
workflowSdkVersion?: string;
browserExtension?: BrowserExtensionTraceContext;
/** When set, traces are routed through the AI service proxy instead of directly to LangSmith. */
proxyConfig?: ServiceProxyConfig;
}
@ -1637,6 +1643,14 @@ async function buildBaseMetadata(
...(options.workflowSdkVersion !== undefined
? { workflow_sdk_version: options.workflowSdkVersion }
: {}),
...(options.browserExtension
? {
browser_connection_state: options.browserExtension.connectionState,
...(options.browserExtension.version !== undefined
? { browser_extension_version: options.browserExtension.version }
: {}),
}
: {}),
...(options.modelId !== undefined
? { model_id: serializeModelIdForTrace(options.modelId) }
: {}),

View File

@ -368,3 +368,17 @@ describe('external messages (direct connect flow)', () => {
expect(firstResult()).toEqual({ connected: false });
});
});
describe('buildRelayWsUrl', () => {
const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/abc?token=bu_x';
it('adds the extension version while preserving the existing query', async () => {
const { buildRelayWsUrl } = await import('./background');
const url = new URL(buildRelayWsUrl(RELAY_URL, '0.0.7'));
expect(url.searchParams.get('extensionVersion')).toBe('0.0.7');
expect(url.searchParams.get('token')).toBe('bu_x');
expect(url.pathname).toBe('/browser-use/extension/abc');
});
});

View File

@ -24,6 +24,13 @@ interface ConnectionState {
let activeConnection: ConnectionState | null = null;
/** A query param rather than a header because `WebSocket` cannot set request headers. */
export function buildRelayWsUrl(relayUrl: string, version: string): string {
const url = new URL(relayUrl);
url.searchParams.set('extensionVersion', version);
return url.toString();
}
// ---------------------------------------------------------------------------
// Relay URL storage (for deduplicating connect.html tabs)
// ---------------------------------------------------------------------------
@ -424,7 +431,7 @@ async function connectToRelay(
disconnect();
try {
const ws = new WebSocket(relayUrl);
const ws = new WebSocket(buildRelayWsUrl(relayUrl, chrome.runtime.getManifest().version));
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {

View File

@ -44,6 +44,7 @@ vi.mock('@n8n/mcp-browser', () => {
const mcpBrowserMock: {
__createdRelays: Array<{
onExtensionConnect?: () => void;
onExtensionDisconnect?: () => void;
attachExtension: Mock;
attachController: Mock;
stop: Mock;
@ -52,6 +53,7 @@ const mcpBrowserMock: {
} = mcpBrowser as unknown as {
__createdRelays: Array<{
onExtensionConnect?: () => void;
onExtensionDisconnect?: () => void;
attachExtension: Mock;
attachController: Mock;
stop: Mock;
@ -61,11 +63,14 @@ const mcpBrowserMock: {
const USER_ID = 'user-1';
function extensionRequest(sessionId: string, token: string | null) {
function extensionRequest(sessionId: string, token: string | null, extensionVersion?: unknown) {
const ws = { close: vi.fn() };
const req = {
params: { sessionId },
query: token === null ? {} : { token },
query: {
...(token === null ? {} : { token }),
...(extensionVersion === undefined ? {} : { extensionVersion }),
},
headers: {},
socket: { remoteAddress: '127.0.0.1' },
ws,
@ -281,6 +286,88 @@ describe('InstanceAiBrowserSessionService', () => {
expect(telemetry.track).toHaveBeenCalledWith('Instance AI Browser connected', {
user_id: USER_ID,
browser_extension_version: null,
});
});
});
describe('extension version', () => {
it('tracks the version reported on the upgrade', async () => {
const { sessionId, extToken, relay } = await createSession(service);
service.handleExtensionUpgrade(extensionRequest(sessionId, extToken, '0.0.7').req);
relay.onExtensionConnect?.();
expect(telemetry.track).toHaveBeenCalledWith('Instance AI Browser connected', {
user_id: USER_ID,
browser_extension_version: '0.0.7',
});
expect(service.getExtensionTraceContext(USER_ID)).toEqual({
connectionState: 'connected',
version: '0.0.7',
});
});
it.each([
['omitted, as pre-instrumentation extensions do', undefined],
['not version-shaped', 'v0.0.7-beta'],
['not a string', ['0.0.7']],
['too long to be a Chrome version', '1.2.3.4.5'],
])('records null when the version is %s', async (_case, reported) => {
const { sessionId, extToken, relay } = await createSession(service);
const { req, ws } = extensionRequest(sessionId, extToken, reported);
service.handleExtensionUpgrade(req);
relay.onExtensionConnect?.();
expect(telemetry.track).toHaveBeenCalledWith('Instance AI Browser connected', {
user_id: USER_ID,
browser_extension_version: null,
});
// An unusable version must not cost the user their connection.
expect(relay.attachExtension).toHaveBeenCalledWith(ws);
expect(ws.close).not.toHaveBeenCalled();
});
it('re-reads the version on reconnect, so a silent auto-update is picked up', async () => {
const { sessionId, extToken, relay } = await createSession(service);
service.handleExtensionUpgrade(extensionRequest(sessionId, extToken, '0.0.6').req);
relay.onExtensionConnect?.();
relay.onExtensionDisconnect?.();
service.handleExtensionUpgrade(extensionRequest(sessionId, extToken, '0.0.7').req);
relay.onExtensionConnect?.();
expect(service.getExtensionTraceContext(USER_ID)).toEqual({
connectionState: 'connected',
version: '0.0.7',
});
});
it('reports connected with no version for a pre-instrumentation extension', async () => {
const { sessionId, extToken, relay } = await createSession(service);
service.handleExtensionUpgrade(extensionRequest(sessionId, extToken).req);
relay.onExtensionConnect?.();
expect(service.getExtensionTraceContext(USER_ID)).toEqual({ connectionState: 'connected' });
});
it('reports disconnected for a user with no session', () => {
expect(service.getExtensionTraceContext('nobody')).toEqual({
connectionState: 'disconnected',
});
});
it('reports no version while disconnected', async () => {
const { sessionId, extToken, relay } = await createSession(service);
service.handleExtensionUpgrade(extensionRequest(sessionId, extToken, '0.0.7').req);
relay.onExtensionConnect?.();
relay.onExtensionDisconnect?.();
expect(service.getExtensionTraceContext(USER_ID)).toEqual({
connectionState: 'disconnected',
});
});
});

View File

@ -208,6 +208,7 @@ import {
createOrchestratorRunControl,
createSandbox,
createWorkspace,
createInstanceAiTraceContext,
loadInstanceAiRuntimeSkillSource,
resumeAgentRun,
setupSandboxWorkspace,
@ -1941,6 +1942,7 @@ type SuspendedRunResumeServiceInternals = {
trackInFlightExecution: Mock;
rebuildAgentForResume: Mock;
threadPushRef: { get: Mock };
browserSessionService: { getExtensionTraceContext: Mock };
};
function createSuspendedRunResumeService(): SuspendedRunResumeServiceInternals {
@ -1983,6 +1985,9 @@ function createSuspendedRunResumeService(): SuspendedRunResumeServiceInternals {
service.processResumedStream = vi.fn();
service.rebuildAgentForResume = vi.fn();
service.threadPushRef = { get: vi.fn(() => undefined) };
service.browserSessionService = {
getExtensionTraceContext: vi.fn(() => ({ connectionState: 'disconnected' })),
};
return service;
}
@ -2463,6 +2468,23 @@ describe('InstanceAiService — suspended run user revalidation', () => {
);
});
it('stamps the browser extension dimensions on the approval-resume trace', async () => {
const service = createSuspendedRunResumeService();
service.revalidateActiveUser.mockResolvedValue(fakeUser);
service.browserSessionService.getExtensionTraceContext.mockReturnValue({
connectionState: 'connected',
version: '0.0.7',
});
await service.resumeSuspendedRun('user-1', 'req-1', { approved: true });
expect(service.tracing.createOrchestratorResumeTraceContext).toHaveBeenCalledWith(
expect.objectContaining({
browserExtension: { connectionState: 'connected', version: '0.0.7' },
}),
);
});
it('passes the revalidated user into the resumed stream', async () => {
const service = createSuspendedRunResumeService();
const freshUser = { id: 'user-1', disabled: false } as User;
@ -4517,6 +4539,7 @@ describe('InstanceAiService — user message persistence on cancel', () => {
instanceAiErrorReporter: { beginRun: Mock; endRun: Mock };
schedulePlannedTasks: Mock;
taskProjector: { syncFromWorkflowLoop: Mock };
browserSessionService: { getExtensionTraceContext: Mock };
};
function createCancelPersistenceService(): ExecuteRunInternals {
@ -4534,6 +4557,9 @@ describe('InstanceAiService — user message persistence on cancel', () => {
beginRun: vi.fn(() => Symbol('error-reporter-execution')),
endRun: vi.fn(),
};
service.browserSessionService = {
getExtensionTraceContext: vi.fn(() => ({ connectionState: 'disconnected' })),
};
return service;
}
@ -4558,6 +4584,51 @@ describe('InstanceAiService — user message persistence on cancel', () => {
);
});
it('passes the connected extension version to the trace', async () => {
const service = createCancelPersistenceService();
service.browserSessionService.getExtensionTraceContext.mockReturnValue({
connectionState: 'connected',
version: '0.0.7',
});
const abortController = new AbortController();
abortController.abort();
await service.executeRun(fakeUser, 'thread-1', 'run-1', 'banana', abortController);
expect(createInstanceAiTraceContext).toHaveBeenCalledWith(
expect.objectContaining({
browserExtension: { connectionState: 'connected', version: '0.0.7' },
}),
);
});
it('separates a connected extension that reports no version from no extension at all', async () => {
const connected = createCancelPersistenceService();
connected.browserSessionService.getExtensionTraceContext.mockReturnValue({
connectionState: 'connected',
});
const firstAbort = new AbortController();
firstAbort.abort();
await connected.executeRun(fakeUser, 'thread-1', 'run-1', 'banana', firstAbort);
expect(createInstanceAiTraceContext).toHaveBeenCalledWith(
expect.objectContaining({ browserExtension: { connectionState: 'connected' } }),
);
});
it('leaves the trace version undefined when no extension is connected', async () => {
const service = createCancelPersistenceService();
const abortController = new AbortController();
abortController.abort();
await service.executeRun(fakeUser, 'thread-1', 'run-1', 'banana', abortController);
expect(createInstanceAiTraceContext).toHaveBeenCalledWith(
expect.objectContaining({ browserExtension: { connectionState: 'disconnected' } }),
);
});
it('does not persist an empty user message', async () => {
const service = createCancelPersistenceService();
const abortController = new AbortController();

View File

@ -5,6 +5,18 @@ export const BROWSER_USE_WS_NAMESPACE = '/browser-use';
export const CDP_TOKEN_HEADER = 'x-n8n-cdp-token';
export const EXTENSION_VERSION_QUERY_PARAM = 'extensionVersion';
const EXTENSION_VERSION_PATTERN = /^\d{1,5}(\.\d{1,5}){0,3}$/;
/** A malformed or absent value reads as "not reported" rather than failing the upgrade. */
export function parseExtensionVersion(value: unknown): string | null {
if (typeof value !== 'string' || !EXTENSION_VERSION_PATTERN.test(value)) {
return null;
}
return value;
}
export interface BrowserUseUpgradeRequest extends Request {
ws: WebSocket;
}

View File

@ -7,6 +7,7 @@ import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { BrowserExtensionTraceContext } from '@n8n/instance-ai';
import type {
BrowserConnection,
CDPRelayServer,
@ -30,6 +31,8 @@ import { BrowserLocalMcpServer } from './browser-local-mcp-server';
import {
BROWSER_USE_WS_NAMESPACE,
CDP_TOKEN_HEADER,
EXTENSION_VERSION_QUERY_PARAM,
parseExtensionVersion,
type BrowserUseUpgradeRequest,
} from './browser-use-ws.constants';
@ -47,6 +50,7 @@ interface BrowserSession {
hasConnectedOnce: boolean;
connected: boolean;
connectedAt: Date | null;
extensionVersion: string | null;
connection: BrowserConnection;
mcpServer: BrowserLocalMcpServer;
}
@ -116,6 +120,15 @@ export class InstanceAiBrowserSessionService {
return this.sessions.get(userId)?.connected ?? false;
}
getExtensionTraceContext(userId: string): BrowserExtensionTraceContext {
const session = this.sessions.get(userId);
if (!session?.connected) return { connectionState: 'disconnected' };
return {
connectionState: 'connected',
...(session.extensionVersion !== null ? { version: session.extensionVersion } : {}),
};
}
handleExtensionUpgrade(req: BrowserUseUpgradeRequest): void {
const session = this.sessionsBySessionId.get(req.params.sessionId);
const token = typeof req.query.token === 'string' ? req.query.token : null;
@ -123,6 +136,7 @@ export class InstanceAiBrowserSessionService {
req.ws.close(4003, 'Invalid auth token');
return;
}
session.extensionVersion = parseExtensionVersion(req.query[EXTENSION_VERSION_QUERY_PARAM]);
session.relay.attachExtension(req.ws);
}
@ -187,6 +201,7 @@ export class InstanceAiBrowserSessionService {
hasConnectedOnce: false,
connected: false,
connectedAt: null,
extensionVersion: null,
connection: toolkit.connection,
mcpServer: new BrowserLocalMcpServer(toolkit, toolContext, this.logger),
};
@ -221,8 +236,14 @@ export class InstanceAiBrowserSessionService {
name: 'browser_connect',
arguments: {},
});
this.telemetry.track('Instance AI Browser connected', { user_id: userId });
this.logger.info('Browser Use extension connected', { userId });
this.telemetry.track('Instance AI Browser connected', {
user_id: userId,
browser_extension_version: session.extensionVersion,
});
this.logger.info('Browser Use extension connected', {
userId,
extensionVersion: session.extensionVersion,
});
this.pushState(userId);
}

View File

@ -3685,6 +3685,9 @@ export class InstanceAiService {
// Shared with createExecutionEnvironment so one ProxyTokenManager backs tracing + the run.
const proxyRunConfig = await this.createProxyRunConfig(user);
// Read per run: Chrome auto-updates extensions silently.
const browserExtension = this.browserSessionService.getExtensionTraceContext(user.id);
// Create the trace before run-start so the SSE event carries traceId (modelId lands at finalization).
if (resumeReason) {
tracing = await this.tracing.createOrchestratorResumeTraceContext({
@ -3703,6 +3706,7 @@ export class InstanceAiService {
? { build_task_id: plannedBuild.buildTaskId }
: {}),
},
browserExtension,
});
} else {
tracing = await createInstanceAiTraceContext({
@ -3715,6 +3719,7 @@ export class InstanceAiService {
proxyConfig: proxyRunConfig.tracingProxyConfig,
n8nVersion: N8N_VERSION,
workflowSdkVersion: WORKFLOW_SDK_VERSION,
browserExtension,
});
}
@ -5198,6 +5203,7 @@ export class InstanceAiService {
? { build_task_id: plannedBuild.buildTaskId }
: {}),
},
browserExtension: this.browserSessionService.getExtensionTraceContext(activeUser.id),
register: false,
});
const effectiveTracing = resumeTracing ?? tracing;

View File

@ -6,6 +6,7 @@ import {
orchestratorAgentId,
releaseTraceClient,
submitLangsmithUserFeedback,
type BrowserExtensionTraceContext,
type InstanceAiTraceContext,
type ManagedBackgroundTask,
type ModelConfig,
@ -202,6 +203,7 @@ export class InstanceAiTracingService {
proxyConfig?: ServiceProxyConfig;
resumeReason: OrchestratorResumeReason;
metadata?: Record<string, unknown>;
browserExtension?: BrowserExtensionTraceContext;
/** Defer process-local registration until the durable resume claim succeeds. */
register?: boolean;
}): Promise<InstanceAiTraceContext | undefined> {
@ -226,6 +228,7 @@ export class InstanceAiTracingService {
},
n8nVersion: N8N_VERSION,
workflowSdkVersion: WORKFLOW_SDK_VERSION,
browserExtension: options.browserExtension,
});
if (tracing) {