From 2c4d153399f48723d5eb4680379426029356e44c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Jun 2026 10:13:00 -0400 Subject: [PATCH 1/2] feat: route browser telemetry directly to the VM by default Add "telemetry" to the default KERNEL_BROWSER_ROUTING_SUBRESOURCES list so telemetry SSE streams are routed straight to the browser VM, and change the telemetry stream method path from /browsers/{id}/telemetry to /browsers/{id}/telemetry/stream so the direct-routing rewrite yields {base_url}/telemetry/stream on the VM (the VM's /telemetry is a different, non-streaming endpoint). DEPENDS ON the control-plane PR renaming the public endpoint /browsers/{id}/telemetry -> /browsers/{id}/telemetry/stream. Until that deploys, telemetry.stream() only works via direct routing. Verified with a live smoke test against prod: the telemetry stream request is rewritten to the VM proxy host (.../telemetry/stream?jwt=...), the Authorization header is stripped, and an api_call telemetry event arrives within ~1s of generating activity. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/smoke-browser-telemetry.ts | 141 ++++++++++++++++++++++++++++ scripts/smoke-browser-telemetry | 7 ++ src/lib/browser-routing.ts | 2 +- tests/lib/browser-routing.test.ts | 36 ++++++- 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 examples/smoke-browser-telemetry.ts create mode 100755 scripts/smoke-browser-telemetry diff --git a/examples/smoke-browser-telemetry.ts b/examples/smoke-browser-telemetry.ts new file mode 100644 index 0000000..27f4c50 --- /dev/null +++ b/examples/smoke-browser-telemetry.ts @@ -0,0 +1,141 @@ +import Kernel from '@onkernel/sdk'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function normalizeURL(input: unknown): string { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return (input as Request).url; +} + +function authHeaderPresent(input: unknown, init?: RequestInit): boolean { + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + return headers.has('authorization'); +} + +async function main() { + // Telemetry is now a default routing subresource; set the env var explicitly to be safe. + process.env['KERNEL_BROWSER_ROUTING_SUBRESOURCES'] = 'curl,telemetry'; + + const records: Array<{ url: string; auth: boolean }> = []; + const realFetch: typeof fetch = fetch; + + const kernel = new Kernel({ + baseURL: process.env['KERNEL_BASE_URL'] || 'https://api.onkernel.com', + fetch: async (input, init) => { + records.push({ url: normalizeURL(input), auth: authHeaderPresent(input, init as RequestInit) }); + return realFetch(input as any, init as any); + }, + }); + + let sessionID: string | undefined; + + try { + console.log(`Using Kernel API ${kernel.baseURL}`); + const browser = await kernel.browsers.create({ + headless: true, + timeout_seconds: 120, + telemetry: { enabled: true }, + }); + sessionID = browser.session_id; + console.log(`Created browser ${sessionID}`); + + const route = kernel.browserRouteCache.get(sessionID); + assert(route, `expected a cached route for session ${sessionID}`); + const baseHost = new URL(route.baseURL).host; + console.log(`Cached VM base_url host: ${baseHost}`); + + const recordsBeforeStream = records.length; + const stream = await kernel.browsers.telemetry.stream(sessionID); + console.log(`Opened telemetry stream`); + + // The telemetry stream request should be the most recent recorded request. + const streamReq = records[records.length - 1]; + assert(streamReq, 'no recorded request for telemetry stream'); + assert(records.length > recordsBeforeStream, 'telemetry stream did not produce an outbound request'); + + const streamURL = new URL(streamReq.url); + console.log(`Telemetry stream outbound URL: ${streamReq.url} (auth=${streamReq.auth})`); + + assert( + streamURL.host === baseHost, + `telemetry stream host ${streamURL.host} did not match VM base_url host ${baseHost}`, + ); + assert(streamURL.host !== 'api.onkernel.com', 'telemetry stream was NOT routed (still api.onkernel.com)'); + assert( + streamURL.pathname.endsWith('/telemetry/stream'), + `telemetry stream path ${streamURL.pathname} did not end with /telemetry/stream`, + ); + assert(!!streamURL.searchParams.get('jwt'), 'telemetry stream URL missing jwt query param'); + assert(!streamReq.auth, 'Authorization header was NOT stripped on the routed telemetry stream request'); + console.log( + `Routing confirmed: stream -> ${streamURL.host}${streamURL.pathname} (jwt present, auth stripped)`, + ); + + // Generate activity so the "api" telemetry category emits an event. + const activity = (async () => { + try { + await kernel.browsers.curl(sessionID!, { + url: 'https://example.com/', + method: 'GET', + response_encoding: 'utf8', + timeout_ms: 10_000, + }); + console.log('Generated activity via browsers.curl'); + } catch (error) { + console.error('activity curl failed', error); + } + })(); + + let eventCount = 0; + const deadline = Date.now() + 25_000; + const reader = (async () => { + for await (const event of stream) { + eventCount += 1; + console.log( + `telemetry event #${eventCount}: seq=${(event as any)?.seq} type=${(event as any)?.event?.type}`, + ); + break; + } + })(); + + await activity; + await Promise.race([ + reader, + new Promise((resolve) => { + const timer = setInterval(() => { + if (eventCount > 0 || Date.now() > deadline) { + clearInterval(timer); + resolve(); + } + }, 250); + }), + ]); + + assert(eventCount >= 1, `expected at least one telemetry event within 25s, got ${eventCount}`); + console.log(`PASS telemetry stream received ${eventCount} event(s) over direct-routed VM connection`); + console.log(`SMOKE_RESULT eventsObserved=${eventCount} routedURL=${streamReq.url}`); + } finally { + if (sessionID) { + console.log(`Deleting browser ${sessionID}`); + try { + await kernel.browsers.deleteByID(sessionID); + } catch (error) { + console.error(`Failed to delete browser ${sessionID}`, error); + } + } + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/smoke-browser-telemetry b/scripts/smoke-browser-telemetry new file mode 100755 index 0000000..3f374f8 --- /dev/null +++ b/scripts/smoke-browser-telemetry @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +./node_modules/.bin/ts-node -r tsconfig-paths/register examples/smoke-browser-telemetry.ts "$@" diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 6580da6..bbedec2 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -28,7 +28,7 @@ export class BrowserRouteCache { } const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; -const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry']; const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/; const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/; const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/; diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 1ca285c..da87ad6 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -381,9 +381,41 @@ describe('browser routing', () => { ).rejects.toThrow(/unsupported HTTP method/i); }); - test('defaults browser routing subresources to curl when env is unset', async () => { + test('defaults browser routing subresources to curl and telemetry when env is unset', async () => { await withBrowserRoutingEnv(undefined, async () => { - expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']); + expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl', 'telemetry']); + }); + }); + + test('routes telemetry stream calls to the VM /telemetry/stream path by default', async () => { + await withBrowserRoutingEnv(undefined, async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + calls.push({ url, headers }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return new Response('id: 1\ndata: {"seq":1}\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.telemetry.stream('sess-1'); + + expect(calls[1]?.url).toBe('http://browser-session.test/browser/kernel/telemetry/stream?jwt=token-abc'); + expect(calls[1]?.headers.get('authorization')).toBeNull(); }); }); From 203054736d39a77e3e55895668130655a680b0c5 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Jun 2026 10:40:42 -0400 Subject: [PATCH 2/2] refactor(examples): rename telemetry example to browser-telemetry Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/browser-telemetry.ts | 30 ++++++ examples/smoke-browser-telemetry.ts | 141 ---------------------------- scripts/smoke-browser-telemetry | 7 -- 3 files changed, 30 insertions(+), 148 deletions(-) create mode 100644 examples/browser-telemetry.ts delete mode 100644 examples/smoke-browser-telemetry.ts delete mode 100755 scripts/smoke-browser-telemetry diff --git a/examples/browser-telemetry.ts b/examples/browser-telemetry.ts new file mode 100644 index 0000000..e2dbe43 --- /dev/null +++ b/examples/browser-telemetry.ts @@ -0,0 +1,30 @@ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + // Create a browser with telemetry enabled so it emits events while it runs. + const browser = await kernel.browsers.create({ telemetry: { enabled: true } }); + + try { + // Telemetry is a default routing subresource, so the stream goes directly to the VM automatically. + const stream = await kernel.browsers.telemetry.stream(browser.session_id); + + // Make browser activity to generate telemetry. The "api" category emits an event per VM API call, + // so events arrive within ~1s. + for (let i = 0; i < 3; i++) { + await kernel.browsers.curl(browser.session_id, { url: 'https://example.com', method: 'GET' }); + } + + // Print a few events, then stop so the program terminates promptly. + let count = 0; + for await (const event of stream) { + console.log('telemetry event', event); + if (++count >= 3) break; + } + } finally { + await kernel.browsers.deleteByID(browser.session_id); + } +} + +void main(); diff --git a/examples/smoke-browser-telemetry.ts b/examples/smoke-browser-telemetry.ts deleted file mode 100644 index 27f4c50..0000000 --- a/examples/smoke-browser-telemetry.ts +++ /dev/null @@ -1,141 +0,0 @@ -import Kernel from '@onkernel/sdk'; - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message); - } -} - -function normalizeURL(input: unknown): string { - if (typeof input === 'string') { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - return (input as Request).url; -} - -function authHeaderPresent(input: unknown, init?: RequestInit): boolean { - const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); - return headers.has('authorization'); -} - -async function main() { - // Telemetry is now a default routing subresource; set the env var explicitly to be safe. - process.env['KERNEL_BROWSER_ROUTING_SUBRESOURCES'] = 'curl,telemetry'; - - const records: Array<{ url: string; auth: boolean }> = []; - const realFetch: typeof fetch = fetch; - - const kernel = new Kernel({ - baseURL: process.env['KERNEL_BASE_URL'] || 'https://api.onkernel.com', - fetch: async (input, init) => { - records.push({ url: normalizeURL(input), auth: authHeaderPresent(input, init as RequestInit) }); - return realFetch(input as any, init as any); - }, - }); - - let sessionID: string | undefined; - - try { - console.log(`Using Kernel API ${kernel.baseURL}`); - const browser = await kernel.browsers.create({ - headless: true, - timeout_seconds: 120, - telemetry: { enabled: true }, - }); - sessionID = browser.session_id; - console.log(`Created browser ${sessionID}`); - - const route = kernel.browserRouteCache.get(sessionID); - assert(route, `expected a cached route for session ${sessionID}`); - const baseHost = new URL(route.baseURL).host; - console.log(`Cached VM base_url host: ${baseHost}`); - - const recordsBeforeStream = records.length; - const stream = await kernel.browsers.telemetry.stream(sessionID); - console.log(`Opened telemetry stream`); - - // The telemetry stream request should be the most recent recorded request. - const streamReq = records[records.length - 1]; - assert(streamReq, 'no recorded request for telemetry stream'); - assert(records.length > recordsBeforeStream, 'telemetry stream did not produce an outbound request'); - - const streamURL = new URL(streamReq.url); - console.log(`Telemetry stream outbound URL: ${streamReq.url} (auth=${streamReq.auth})`); - - assert( - streamURL.host === baseHost, - `telemetry stream host ${streamURL.host} did not match VM base_url host ${baseHost}`, - ); - assert(streamURL.host !== 'api.onkernel.com', 'telemetry stream was NOT routed (still api.onkernel.com)'); - assert( - streamURL.pathname.endsWith('/telemetry/stream'), - `telemetry stream path ${streamURL.pathname} did not end with /telemetry/stream`, - ); - assert(!!streamURL.searchParams.get('jwt'), 'telemetry stream URL missing jwt query param'); - assert(!streamReq.auth, 'Authorization header was NOT stripped on the routed telemetry stream request'); - console.log( - `Routing confirmed: stream -> ${streamURL.host}${streamURL.pathname} (jwt present, auth stripped)`, - ); - - // Generate activity so the "api" telemetry category emits an event. - const activity = (async () => { - try { - await kernel.browsers.curl(sessionID!, { - url: 'https://example.com/', - method: 'GET', - response_encoding: 'utf8', - timeout_ms: 10_000, - }); - console.log('Generated activity via browsers.curl'); - } catch (error) { - console.error('activity curl failed', error); - } - })(); - - let eventCount = 0; - const deadline = Date.now() + 25_000; - const reader = (async () => { - for await (const event of stream) { - eventCount += 1; - console.log( - `telemetry event #${eventCount}: seq=${(event as any)?.seq} type=${(event as any)?.event?.type}`, - ); - break; - } - })(); - - await activity; - await Promise.race([ - reader, - new Promise((resolve) => { - const timer = setInterval(() => { - if (eventCount > 0 || Date.now() > deadline) { - clearInterval(timer); - resolve(); - } - }, 250); - }), - ]); - - assert(eventCount >= 1, `expected at least one telemetry event within 25s, got ${eventCount}`); - console.log(`PASS telemetry stream received ${eventCount} event(s) over direct-routed VM connection`); - console.log(`SMOKE_RESULT eventsObserved=${eventCount} routedURL=${streamReq.url}`); - } finally { - if (sessionID) { - console.log(`Deleting browser ${sessionID}`); - try { - await kernel.browsers.deleteByID(sessionID); - } catch (error) { - console.error(`Failed to delete browser ${sessionID}`, error); - } - } - } -} - -void main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/scripts/smoke-browser-telemetry b/scripts/smoke-browser-telemetry deleted file mode 100755 index 3f374f8..0000000 --- a/scripts/smoke-browser-telemetry +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -./node_modules/.bin/ts-node -r tsconfig-paths/register examples/smoke-browser-telemetry.ts "$@"