From d382f145fc448bb52eec5f6002d33d6d6df817b3 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 11 Sep 2026 18:50:29 +0000 Subject: [PATCH 1/3] feat: record live URL and session reuse telemetry --- src/lib/agent-client.ts | 11 ++++++++- src/tools/agent.ts | 21 +++++++++++++++++ test/tools/agent.spec.ts | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index d4ff09d..9fd2a32 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -175,6 +175,7 @@ export const isRetryableUpgradeError = (err: unknown): boolean => { }; const sessions = new Map(); +const createdAt = new WeakMap(); // In-flight session creations keyed by session key. Concurrent // getOrCreateSession callers await the same promise instead of each // opening their own WebSocket. @@ -730,6 +731,7 @@ export const getOrCreateSession = async ( os?: string, humanlike?: boolean, record?: boolean, + onSession?: (reused: boolean, ageMs: number) => void, ): Promise => { sweepSessions(); // Reusing on a bare call guessed "same task" — but every concurrent task in a @@ -758,6 +760,7 @@ export const getOrCreateSession = async ( ) { assertCompatibleRecordingMode(existing, record); existing.lastUsedAt = Date.now(); + onSession?.(true, Math.max(0, Date.now() - createdAt.get(existing)!)); return existing; } @@ -766,6 +769,7 @@ export const getOrCreateSession = async ( if (inFlight) { const session = await inFlight; assertCompatibleRecordingMode(session, record); + onSession?.(true, Math.max(0, Date.now() - createdAt.get(session)!)); return session; } @@ -827,6 +831,7 @@ export const getOrCreateSession = async ( skillState: createSkillState(), lastUsedAt: Date.now(), }; + createdAt.set(session, Date.now()); // Auto-cleanup on close ws.on('close', (code: number, reason: Buffer) => { @@ -848,7 +853,9 @@ export const getOrCreateSession = async ( pending.set(key, creation); try { - return await creation; + const session = await creation; + onSession?.(false, 0); + return session; } finally { // Clear the placeholder whether connect succeeded or threw, so a failed // attempt doesn't block future retries. @@ -922,6 +929,7 @@ export const closeSession = ( echoedSessionId?: string, integrationId?: string, allowedDomains?: string[], + onSession?: (reused: boolean, ageMs: number) => void, ): void => { const key = getSessionKey( mcpSessionId, @@ -936,6 +944,7 @@ export const closeSession = ( ); const session = sessions.get(key); if (session) { + onSession?.(true, Math.max(0, Date.now() - createdAt.get(session)!)); try { session.ws.close(); } catch { diff --git a/src/tools/agent.ts b/src/tools/agent.ts index a39b500..fde6f91 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -748,11 +748,21 @@ export function registerAgentTools( } let lastCategory: ErrorCategory | undefined; + let liveUrlId: string | undefined; + let sessionReused = false; + let sessionAgeMs = 0; + const onSession = (reused: boolean, ageMs: number) => { + sessionReused = reused; + sessionAgeMs = ageMs; + }; const sendAnalytics = (success: boolean, err?: unknown) => { analytics?.fireToolRequest(token, 'browserless_agent', { ...mcpSource, ...(prompt ? { _prompt: prompt } : {}), + ...(liveUrlId ? { live_url_id: liveUrlId } : {}), + session_reused: sessionReused, + session_age_ms: sessionAgeMs, methods: commands.map((c) => c.method).join(','), command_count: commands.length, api_url: apiUrl, @@ -799,6 +809,7 @@ export function registerAgentTools( echoedSessionId, integrationId, allowedDomains, + onSession, ); sendAnalytics(true); return [{ type: 'text' as const, text: 'Browser session closed.' }]; @@ -827,6 +838,7 @@ export function registerAgentTools( os, humanlike, record, + onSession, ); } catch (connErr: unknown) { sendAnalytics(false, connErr); @@ -843,6 +855,8 @@ export function registerAgentTools( } const runCommands = async (isRetry: boolean): Promise => { + onSession(false, 0); + liveUrlId = undefined; let agentSession; try { agentSession = await getOrCreateSession( @@ -861,6 +875,7 @@ export function registerAgentTools( os, humanlike, record, + onSession, ); } catch (connErr: unknown) { // No retry when the server gave a definitive 4xx — re-attempting @@ -1079,6 +1094,12 @@ export function registerAgentTools( ); } + if (cmd.method === 'liveURL') { + const result = resp.result as { liveURLId?: unknown } | undefined; + if (typeof result?.liveURLId === 'string') { + liveUrlId = result.liveURLId; + } + } results.push({ method: cmd.method, result: resp.result }); } diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index c35b352..168e83e 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -1202,6 +1202,57 @@ describe('browserless_agent _prompt capture', () => { expect(props).to.not.have.property('error_category'); }); + it('joins a live URL result and distinguishes reused from idle-evicted sessions', async () => { + const clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + const srv = await makeRespondingServer((method) => + method === 'liveURL' + ? { + liveURLId: 'handoff-123', + liveURL: 'https://example.com/live?i=handoff-123', + } + : {}, + ); + try { + const { execute, fire } = registerWithAnalytics({ + ...mockConfig, + browserlessApiUrl: srv.url, + }); + const context = { ...mockContext, sessionId: 'handoff-telemetry' }; + const params = { method: 'liveURL', sessionId: 'handoff-session' }; + await execute(params, context); + expect(fire.lastCall.args[2]).to.include({ + live_url_id: 'handoff-123', + session_reused: false, + session_age_ms: 0, + }); + clock.setSystemTime(16_000); + await execute( + { method: 'getCookies', sessionId: params.sessionId }, + context, + ); + expect(fire.lastCall.args[2]).to.include({ + session_reused: true, + session_age_ms: 15_000, + }); + expect(fire.lastCall.args[2]).not.to.have.property('live_url_id'); + expect(srv.hits()).to.equal(1); + + // Strictly greater than the idle TTL, measured from the previous call. + clock.setSystemTime(916_001); + await execute( + { method: 'getCookies', sessionId: params.sessionId }, + context, + ); + expect(fire.lastCall.args[2]).to.include({ + session_reused: false, + session_age_ms: 0, + }); + expect(srv.hits()).to.equal(2); + } finally { + await srv.close(); + } + }); + it('fires exactly one event carrying the classified category on failure', async () => { const { execute, fire } = registerWithAnalytics(mockConfig); From ebca103819d32d635a21a085cf70e311e081d7d2 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 11 Sep 2026 20:05:52 +0000 Subject: [PATCH 2/3] fix: cover telemetry on validation errors and reconnects --- src/lib/agent-client.ts | 3 + src/lib/define-tool.ts | 3 + src/tools/agent.ts | 25 ++++++- test/lib/agent-client.spec.ts | 26 ++++++++ test/tools/agent.spec.ts | 120 +++++++++++++++++++++++++++++++++- 5 files changed, 173 insertions(+), 4 deletions(-) diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index 9fd2a32..8c6a7a4 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -870,6 +870,7 @@ export const send = async ( method: string, params: Record = {}, timeoutMs?: number, + onSession?: (reused: boolean, ageMs: number) => void, ): Promise => { if (session.ws.readyState !== WebSocket.OPEN) { if (!session.reconnecting) { @@ -897,6 +898,7 @@ export const send = async ( if (session.ws !== ws) { session.ws = ws; session.msgId = 0; + createdAt.set(session, Date.now()); const key = [...sessions.entries()].find(([, s]) => s === session)?.[0]; if (key) { @@ -908,6 +910,7 @@ export const send = async ( }); } } + onSession?.(false, 0); } session.msgId++; diff --git a/src/lib/define-tool.ts b/src/lib/define-tool.ts index d8ac1a4..825a151 100644 --- a/src/lib/define-tool.ts +++ b/src/lib/define-tool.ts @@ -87,6 +87,8 @@ export interface ToolDefinition { description: string; parameters: ZodType

; annotations?: ToolAnnotations; + /** Defaults also included when validation fails before run(). */ + analyticsDefaults?: Record; /** Throw UserError if any URL in params is invalid. Runs before progress 0. */ validateUrl?: (params: P) => void; /** Override the default ProfileNotFoundError → UserError message. */ @@ -191,6 +193,7 @@ export function defineTool( const enrich = (props: Record) => { const success = normalizeSuccess(props); return { + ...def.analyticsDefaults, ...props, success, duration_ms: Date.now() - startedAt, diff --git a/src/tools/agent.ts b/src/tools/agent.ts index fde6f91..fb7f7fb 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -599,6 +599,7 @@ export function registerAgentTools( defineTool(server, config, analytics, { name: 'browserless_agent', + analyticsDefaults: { session_reused: false, session_age_ms: 0 }, description: (compliant ? COMPLIANT_AGENT_SYSTEM_PROMPT @@ -925,7 +926,13 @@ export function registerAgentTools( } if (cmd.method === 'reportSkillOutcome') { try { - await send(agentSession, cmd.method, cmd.params); + await send( + agentSession, + cmd.method, + cmd.params, + undefined, + onSession, + ); } catch { // noop } @@ -954,7 +961,13 @@ export function registerAgentTools( let resp; try { - resp = await send(agentSession, cmd.method, outboundParams); + resp = await send( + agentSession, + cmd.method, + outboundParams, + undefined, + onSession, + ); } catch (sendErr: unknown) { destroySession( mcpSessionId, @@ -1157,7 +1170,13 @@ export function registerAgentTools( let autoDownloads: DownloadEntry[] = []; if (!closedDuringBatch && last.method !== 'getDownloads') { try { - const dl = await send(agentSession, 'getDownloads', {}); + const dl = await send( + agentSession, + 'getDownloads', + {}, + undefined, + onSession, + ); autoDownloads = (dl.result as { downloads?: DownloadEntry[] } | undefined) ?.downloads ?? []; diff --git a/test/lib/agent-client.spec.ts b/test/lib/agent-client.spec.ts index 28dc92b..bee19d8 100644 --- a/test/lib/agent-client.spec.ts +++ b/test/lib/agent-client.spec.ts @@ -11,14 +11,40 @@ import { sessionHandle, dropMcpSession, UpgradeError, + send, } from '../../src/lib/agent-client.js'; import type { ProxyOptions } from '../../src/@types/types.js'; import { makeAcceptingServer, makeRejectingServer, makeStallingServer, + makeRespondingServer, } from '../helpers/upgrade-server.js'; +describe('agent-client reconnection telemetry', () => { + afterEach(() => sinon.restore()); + + it('reports a fresh connection when send reconnects an acquired session', async () => { + const clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + const server = await makeRespondingServer(() => ({})); + try { + const session = await getOrCreateSession( + 'reconnect-telemetry', + server.url, + 'tok', + ); + session.ws.terminate(); + clock.setSystemTime(9000); + const acquired = sinon.spy(); + await send(session, 'getCookies', {}, undefined, acquired); + expect(acquired.calledOnceWithExactly(false, 0)).to.equal(true); + expect(server.hits()).to.equal(2); + } finally { + await server.close(); + } + }); +}); + describe('agent-client buildAgentWsUrl', () => { // A base carrying a query used to concatenate into path `/` — the raw CDP // socket — so every agent method came back as -32601 "wasn't found". diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index 168e83e..69b12ca 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -1237,8 +1237,18 @@ describe('browserless_agent _prompt capture', () => { expect(fire.lastCall.args[2]).not.to.have.property('live_url_id'); expect(srv.hits()).to.equal(1); + // Exactly the idle TTL still reuses the same session. + clock.setSystemTime(916_000); + await execute( + { method: 'getCookies', sessionId: params.sessionId }, + context, + ); + expect(fire.lastCall.args[2]).to.include({ + session_reused: true, + session_age_ms: 915_000, + }); // Strictly greater than the idle TTL, measured from the previous call. - clock.setSystemTime(916_001); + clock.setSystemTime(1_816_001); await execute( { method: 'getCookies', sessionId: params.sessionId }, context, @@ -1271,9 +1281,117 @@ describe('browserless_agent _prompt capture', () => { success: false, error_category: 'user_error', analytics_version: 2, + session_reused: false, + session_age_ms: 0, + }); + }); + + it('reports one new acquisition and one reuse for concurrent calls', async () => { + sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + const srv = await makeRespondingServer(() => ({})); + try { + const { execute, fire } = registerWithAnalytics({ + ...mockConfig, + browserlessApiUrl: srv.url, + }); + await Promise.all( + [1, 2].map(() => + execute( + { method: 'getCookies', sessionId: 'concurrent-analytics' }, + { ...mockContext, sessionId: 'concurrent-analytics' }, + ), + ), + ); + expect(srv.hits()).to.equal(1); + expect( + fire + .getCalls() + .map((c) => c.args[2].session_reused) + .sort(), + ).to.deep.equal([false, true]); + expect( + fire.getCalls().map((c) => c.args[2].session_age_ms), + ).to.deep.equal([0, 0]); + } finally { + await srv.close(); + } + }); + + it('includes session defaults when validation rejects before acquisition', async () => { + const { execute, fire } = registerWithAnalytics({ + ...mockConfig, + complianceMode: true, + }); + try { + await execute({ commands: [{ method: 'notACommand' }] }, mockContext); + expect.fail('expected validation error'); + } catch (error) { + expect((error as Error).message).to.include( + 'not available on this endpoint', + ); + } + expect(fire.calledOnce).to.equal(true); + expect(fire.firstCall.args[2]).to.include({ + success: false, + session_reused: false, + session_age_ms: 0, }); }); + it('retains a minted ID on a later batch error and reports close reuse', async () => { + const clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + const srv = await makeRespondingServer((method) => + method === 'liveURL' + ? { liveURLId: 'batch-handoff', liveURL: 'https://example.com/live' } + : new AgentErrorFrame({ + code: 'SELECTOR_NOT_FOUND', + message: 'missing element', + }), + ); + try { + const { execute, fire } = registerWithAnalytics({ + ...mockConfig, + browserlessApiUrl: srv.url, + }); + const context = { ...mockContext, sessionId: 'batch-telemetry' }; + try { + await execute( + { + sessionId: 'batch-session', + commands: [ + { method: 'liveURL' }, + { method: 'click', params: { selector: '#missing' } }, + ], + }, + context, + ); + expect.fail('expected command error'); + } catch (error) { + expect((error as Error).message).to.include('missing element'); + } + expect(fire.firstCall.args[2]).to.include({ + success: false, + live_url_id: 'batch-handoff', + session_reused: false, + session_age_ms: 0, + }); + clock.setSystemTime(3500); + await execute({ method: 'close', sessionId: 'batch-session' }, context); + expect(fire.lastCall.args[2]).to.include({ + session_reused: true, + session_age_ms: 2500, + }); + expect(fire.lastCall.args[2]).not.to.have.property('live_url_id'); + await execute({ method: 'close', sessionId: 'batch-session' }, context); + expect(fire.lastCall.args[2]).to.include({ + session_reused: false, + session_age_ms: 0, + }); + } finally { + await srv.close(); + } + }); + it('does NOT inject _prompt on the compliant surface', () => { const { added } = registerWithAnalytics({ ...mockConfig, From 35207abd69e3c8dd765e04b19d9d014823af2d47 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 11 Sep 2026 20:19:29 +0000 Subject: [PATCH 3/3] fix: retain live URL correlation across failed retries --- src/tools/agent.ts | 1 - test/tools/agent.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tools/agent.ts b/src/tools/agent.ts index fb7f7fb..8fca41a 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -857,7 +857,6 @@ export function registerAgentTools( const runCommands = async (isRetry: boolean): Promise => { onSession(false, 0); - liveUrlId = undefined; let agentSession; try { agentSession = await getOrCreateSession( diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index 69b12ca..9190728 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -1399,6 +1399,48 @@ describe('browserless_agent _prompt capture', () => { }); expect((added.parameters as any).shape).to.not.have.property('_prompt'); }); + + it('retains a minted ID when a later fatal error retries unsuccessfully', async () => { + let liveCalls = 0; + const srv = await makeRespondingServer((method) => { + if (method === 'liveURL' && liveCalls++ === 0) { + return { liveURLId: 'before-retry' }; + } + return new AgentErrorFrame({ + code: 'BROWSER_CRASHED', + message: 'browser crashed', + }); + }); + try { + const { execute, fire } = registerWithAnalytics({ + ...mockConfig, + browserlessApiUrl: srv.url, + }); + try { + await execute( + { + sessionId: 'retry-handoff', + commands: [ + { method: 'liveURL' }, + { method: 'click', params: { selector: '#next' } }, + ], + }, + { ...mockContext, sessionId: 'retry-handoff' }, + ); + expect.fail('expected retry failure'); + } catch (error) { + expect((error as Error).message).to.include('browser crashed'); + } + expect(srv.hits()).to.equal(2); + expect(fire.calledOnce).to.equal(true); + expect(fire.firstCall.args[2]).to.include({ + success: false, + live_url_id: 'before-retry', + }); + } finally { + await srv.close(); + } + }); }); describe('browserless_agent session handle on errors', () => {