Skip to content

Commit 6bf10d0

Browse files
rgarciaclaude
authored andcommitted
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) <noreply@anthropic.com>
1 parent 53ff39d commit 6bf10d0

4 files changed

Lines changed: 183 additions & 3 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Kernel from '@onkernel/sdk';
2+
3+
function assert(condition: unknown, message: string): asserts condition {
4+
if (!condition) {
5+
throw new Error(message);
6+
}
7+
}
8+
9+
function normalizeURL(input: unknown): string {
10+
if (typeof input === 'string') {
11+
return input;
12+
}
13+
if (input instanceof URL) {
14+
return input.toString();
15+
}
16+
return (input as Request).url;
17+
}
18+
19+
function authHeaderPresent(input: unknown, init?: RequestInit): boolean {
20+
const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers);
21+
return headers.has('authorization');
22+
}
23+
24+
async function main() {
25+
// Telemetry is now a default routing subresource; set the env var explicitly to be safe.
26+
process.env['KERNEL_BROWSER_ROUTING_SUBRESOURCES'] = 'curl,telemetry';
27+
28+
const records: Array<{ url: string; auth: boolean }> = [];
29+
const realFetch: typeof fetch = fetch;
30+
31+
const kernel = new Kernel({
32+
baseURL: process.env['KERNEL_BASE_URL'] || 'https://api.onkernel.com',
33+
fetch: async (input, init) => {
34+
records.push({ url: normalizeURL(input), auth: authHeaderPresent(input, init as RequestInit) });
35+
return realFetch(input as any, init as any);
36+
},
37+
});
38+
39+
let sessionID: string | undefined;
40+
41+
try {
42+
console.log(`Using Kernel API ${kernel.baseURL}`);
43+
const browser = await kernel.browsers.create({
44+
headless: true,
45+
timeout_seconds: 120,
46+
telemetry: { enabled: true },
47+
});
48+
sessionID = browser.session_id;
49+
console.log(`Created browser ${sessionID}`);
50+
51+
const route = kernel.browserRouteCache.get(sessionID);
52+
assert(route, `expected a cached route for session ${sessionID}`);
53+
const baseHost = new URL(route.baseURL).host;
54+
console.log(`Cached VM base_url host: ${baseHost}`);
55+
56+
const recordsBeforeStream = records.length;
57+
const stream = await kernel.browsers.telemetry.stream(sessionID);
58+
console.log(`Opened telemetry stream`);
59+
60+
// The telemetry stream request should be the most recent recorded request.
61+
const streamReq = records[records.length - 1];
62+
assert(streamReq, 'no recorded request for telemetry stream');
63+
assert(records.length > recordsBeforeStream, 'telemetry stream did not produce an outbound request');
64+
65+
const streamURL = new URL(streamReq.url);
66+
console.log(`Telemetry stream outbound URL: ${streamReq.url} (auth=${streamReq.auth})`);
67+
68+
assert(
69+
streamURL.host === baseHost,
70+
`telemetry stream host ${streamURL.host} did not match VM base_url host ${baseHost}`,
71+
);
72+
assert(streamURL.host !== 'api.onkernel.com', 'telemetry stream was NOT routed (still api.onkernel.com)');
73+
assert(
74+
streamURL.pathname.endsWith('/telemetry/stream'),
75+
`telemetry stream path ${streamURL.pathname} did not end with /telemetry/stream`,
76+
);
77+
assert(!!streamURL.searchParams.get('jwt'), 'telemetry stream URL missing jwt query param');
78+
assert(!streamReq.auth, 'Authorization header was NOT stripped on the routed telemetry stream request');
79+
console.log(
80+
`Routing confirmed: stream -> ${streamURL.host}${streamURL.pathname} (jwt present, auth stripped)`,
81+
);
82+
83+
// Generate activity so the "api" telemetry category emits an event.
84+
const activity = (async () => {
85+
try {
86+
await kernel.browsers.curl(sessionID!, {
87+
url: 'https://example.com/',
88+
method: 'GET',
89+
response_encoding: 'utf8',
90+
timeout_ms: 10_000,
91+
});
92+
console.log('Generated activity via browsers.curl');
93+
} catch (error) {
94+
console.error('activity curl failed', error);
95+
}
96+
})();
97+
98+
let eventCount = 0;
99+
const deadline = Date.now() + 25_000;
100+
const reader = (async () => {
101+
for await (const event of stream) {
102+
eventCount += 1;
103+
console.log(
104+
`telemetry event #${eventCount}: seq=${(event as any)?.seq} type=${(event as any)?.event?.type}`,
105+
);
106+
break;
107+
}
108+
})();
109+
110+
await activity;
111+
await Promise.race([
112+
reader,
113+
new Promise<void>((resolve) => {
114+
const timer = setInterval(() => {
115+
if (eventCount > 0 || Date.now() > deadline) {
116+
clearInterval(timer);
117+
resolve();
118+
}
119+
}, 250);
120+
}),
121+
]);
122+
123+
assert(eventCount >= 1, `expected at least one telemetry event within 25s, got ${eventCount}`);
124+
console.log(`PASS telemetry stream received ${eventCount} event(s) over direct-routed VM connection`);
125+
console.log(`SMOKE_RESULT eventsObserved=${eventCount} routedURL=${streamReq.url}`);
126+
} finally {
127+
if (sessionID) {
128+
console.log(`Deleting browser ${sessionID}`);
129+
try {
130+
await kernel.browsers.deleteByID(sessionID);
131+
} catch (error) {
132+
console.error(`Failed to delete browser ${sessionID}`, error);
133+
}
134+
}
135+
}
136+
}
137+
138+
void main().catch((error) => {
139+
console.error(error);
140+
process.exitCode = 1;
141+
});

scripts/smoke-browser-telemetry

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
cd "$(dirname "$0")/.."
6+
7+
./node_modules/.bin/ts-node -r tsconfig-paths/register examples/smoke-browser-telemetry.ts "$@"

src/lib/browser-routing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class BrowserRouteCache {
2828
}
2929

3030
const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES';
31-
const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl'];
31+
const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry'];
3232
const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/;
3333
const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/;
3434
const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/;

tests/lib/browser-routing.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,41 @@ describe('browser routing', () => {
381381
).rejects.toThrow(/unsupported HTTP method/i);
382382
});
383383

384-
test('defaults browser routing subresources to curl when env is unset', async () => {
384+
test('defaults browser routing subresources to curl and telemetry when env is unset', async () => {
385385
await withBrowserRoutingEnv(undefined, async () => {
386-
expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']);
386+
expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl', 'telemetry']);
387+
});
388+
});
389+
390+
test('routes telemetry stream calls to the VM /telemetry/stream path by default', async () => {
391+
await withBrowserRoutingEnv(undefined, async () => {
392+
const calls: Array<{ url: string; headers: Headers }> = [];
393+
const kernel = new Kernel({
394+
apiKey: 'k',
395+
baseURL: 'https://api.example/',
396+
fetch: async (input, init?: RequestInit) => {
397+
const url = normalizeURL(input);
398+
const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers);
399+
calls.push({ url, headers });
400+
if (url === 'https://api.example/browsers') {
401+
return Response.json({
402+
session_id: 'sess-1',
403+
base_url: 'http://browser-session.test/browser/kernel',
404+
cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc',
405+
});
406+
}
407+
return new Response('id: 1\ndata: {"seq":1}\n\n', {
408+
status: 200,
409+
headers: { 'content-type': 'text/event-stream' },
410+
});
411+
},
412+
});
413+
414+
await kernel.browsers.create();
415+
await kernel.browsers.telemetry.stream('sess-1');
416+
417+
expect(calls[1]?.url).toBe('http://browser-session.test/browser/kernel/telemetry/stream?jwt=token-abc');
418+
expect(calls[1]?.headers.get('authorization')).toBeNull();
387419
});
388420
});
389421

0 commit comments

Comments
 (0)