-
Notifications
You must be signed in to change notification settings - Fork 35
feat(observability): measure response tokens and cache status #1063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cf91a3a
94f4324
f5f9ada
d45a2de
8f2ea29
53347f8
e25bb23
7f5b9f2
ed21eba
72f36a9
71054b8
32e8ce0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,6 +76,62 @@ const SKIP_RECORDING_TOOLS = new Set([ | |
| * Detect if an error is a Chrome/CDP connection error that may be recoverable | ||
| * by reconnecting to the browser. | ||
| */ | ||
| export function estimateOutputTokensFromChars(chars: number): number { | ||
| // Heuristic only; intentionally avoids provider-specific tokenizer deps. | ||
| return Math.max(0, Math.ceil(chars / 4)); | ||
| } | ||
|
|
||
| function stringifyResultPayload(result: MCPResult): string { | ||
| try { | ||
| return JSON.stringify(result); | ||
| } catch { | ||
| return Array.isArray(result.content) | ||
| ? result.content.map((c) => c.text ?? c.data ?? '').join('') | ||
| : ''; | ||
| } | ||
| } | ||
|
|
||
| const CACHE_STATUS_LABELS = new Set(['HIT', 'MISS', 'BYPASS', 'ERROR']); | ||
| const CACHE_KEY_VERSION_LABEL_RE = /^v?\d{1,3}$/i; | ||
|
|
||
| function normalizeCacheStatusLabel(raw: string): string { | ||
| const normalized = raw.trim().toUpperCase(); | ||
| return CACHE_STATUS_LABELS.has(normalized) ? normalized : 'UNKNOWN'; | ||
| } | ||
|
|
||
| function normalizeCacheKeyVersionLabel(raw: unknown): string { | ||
| if (raw === undefined || raw === null || raw === '') return 'unknown'; | ||
| const normalized = String(raw).trim(); | ||
| if (normalized === '') return 'unknown'; | ||
| return CACHE_KEY_VERSION_LABEL_RE.test(normalized) ? normalized : 'other'; | ||
| } | ||
|
|
||
| export function extractCacheStatus(result: MCPResult): { status: string; keyVersion: string } | null { | ||
| const raw = (result as Record<string, unknown>)._cache | ||
| ?? (result as Record<string, unknown>).cache | ||
| ?? (result as Record<string, unknown>).cacheStatus; | ||
| if (typeof raw === 'string') { | ||
| return { status: normalizeCacheStatusLabel(raw), keyVersion: 'unknown' }; | ||
| } | ||
| if (raw && typeof raw === 'object') { | ||
| const obj = raw as Record<string, unknown>; | ||
| const status = typeof obj.status === 'string' ? obj.status : typeof obj.cacheStatus === 'string' ? obj.cacheStatus : null; | ||
| if (!status) return null; | ||
| const keyVersion = obj.keyVersion ?? obj.version ?? 'unknown'; | ||
| return { | ||
| status: normalizeCacheStatusLabel(status), | ||
| keyVersion: normalizeCacheKeyVersionLabel(keyVersion), | ||
| }; | ||
| } | ||
| if (result.structuredContent && typeof result.structuredContent.cacheStatus === 'string') { | ||
| return { | ||
| status: normalizeCacheStatusLabel(result.structuredContent.cacheStatus), | ||
| keyVersion: normalizeCacheKeyVersionLabel(result.structuredContent.cacheKeyVersion), | ||
| }; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| export function isConnectionError(error: unknown): boolean { | ||
| if (error instanceof OpenChromeConnectionError) return true; | ||
| const message = formatError(error); | ||
|
|
@@ -1236,7 +1292,7 @@ export class MCPServer { | |
| } catch { | ||
| // best-effort | ||
| } | ||
| return { | ||
| const deniedResult: MCPResult = { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
|
|
@@ -1245,6 +1301,8 @@ export class MCPServer { | |
| ], | ||
| isError: true, | ||
| }; | ||
| this.recordToolOutputObservability(toolName, deniedResult); | ||
| return deniedResult; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1271,7 +1329,7 @@ export class MCPServer { | |
| } catch { | ||
| // best-effort | ||
| } | ||
| return { | ||
| const forbiddenResult: MCPResult = { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
|
|
@@ -1280,6 +1338,8 @@ export class MCPServer { | |
| ], | ||
| isError: true, | ||
| }; | ||
| this.recordToolOutputObservability(toolName, forbiddenResult); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| return forbiddenResult; | ||
| } | ||
|
|
||
| // Handle the expand_tools meta-tool before normal tool lookup | ||
|
|
@@ -1305,9 +1365,11 @@ export class MCPServer { | |
| text += `\n\nNewly available tools:\n${JSON.stringify(newTools, null, 2)}\n\nYou can now call these tools directly by name.`; | ||
| } | ||
|
|
||
| return { | ||
| const expandResult: MCPResult = { | ||
| content: [{ type: 'text', text }], | ||
| }; | ||
| this.recordToolOutputObservability(toolName, expandResult); | ||
| return expandResult; | ||
| } | ||
|
|
||
| const tool = this.tools.get(toolName); | ||
|
|
@@ -1320,10 +1382,12 @@ export class MCPServer { | |
| if (requiredFields && requiredFields.length > 0) { | ||
| const missing = requiredFields.filter((field) => !(field in toolArgs) || toolArgs[field] === undefined || toolArgs[field] === null); | ||
| if (missing.length > 0) { | ||
| return { | ||
| const missingArgsResult: MCPResult = { | ||
| content: [{ type: 'text', text: `Error: Missing required argument(s): ${missing.join(', ')}` }], | ||
| isError: true, | ||
| }; | ||
| this.recordToolOutputObservability(toolName, missingArgsResult); | ||
| return missingArgsResult; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1392,7 +1456,7 @@ export class MCPServer { | |
| if (!rateResult.allowed) { | ||
| console.error(`[MCPServer] Rate limit exceeded for session ${sessionId}, retry after ${rateResult.retryAfterSec}s`); | ||
| try { getMetricsCollector().inc('openchrome_rate_limit_rejections_total', withTenantLabel({ tool: toolName })); } catch { /* best-effort */ } | ||
| return { | ||
| const rateLimitResult: MCPResult = { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
|
|
@@ -1401,6 +1465,8 @@ export class MCPServer { | |
| ], | ||
| isError: true, | ||
| }; | ||
| this.recordToolOutputObservability(toolName, rateLimitResult); | ||
| return rateLimitResult; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1440,7 +1506,7 @@ export class MCPServer { | |
| }); | ||
|
|
||
| if (reconnectResult !== 'reconnected') { | ||
| return { | ||
| const reconnectResultPayload: MCPResult = { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
|
|
@@ -1449,6 +1515,8 @@ export class MCPServer { | |
| ], | ||
| isError: true, | ||
| }; | ||
| this.recordToolOutputObservability(toolName, reconnectResultPayload); | ||
| return reconnectResultPayload; | ||
| } | ||
| console.error(`[MCPServer] Reconnection complete, proceeding with "${toolName}"`); | ||
| } | ||
|
|
@@ -1812,7 +1880,7 @@ export class MCPServer { | |
| } | ||
| } | ||
|
|
||
| if (compressionConfig?.enabled && compressionConfig?.trackSavings) { | ||
| if (compressionConfig?.enabled && compressionConfig?.trackSavings && !(result as Record<string, unknown>)._compression) { | ||
| (result as Record<string, unknown>)._compression = { | ||
| level: compressionConfig.level ?? 'light', | ||
| verbosity, | ||
|
|
@@ -1825,7 +1893,9 @@ export class MCPServer { | |
| // the substituted input, returned it inside a JSON blob, or surfaced | ||
| // it via an error message) with `${SECRET:NAME}` placeholders. No-op | ||
| // when --secrets was not passed. | ||
| return redactSecrets(result); | ||
| const finalResult = redactSecrets(result); | ||
| this.recordToolOutputObservability(toolName, finalResult); | ||
| return finalResult; | ||
| } catch (error) { | ||
| const message = formatError(error); | ||
| const abortReason = isClientDisconnect(error) ? 'client_disconnect' : null; | ||
|
|
@@ -1978,7 +2048,45 @@ export class MCPServer { | |
|
|
||
| // Secrets redaction (#834) — see success path. Error messages can | ||
| // include the literal value (e.g. "type ... failed for value X"). | ||
| return redactSecrets(errResult); | ||
| const finalErrResult = redactSecrets(errResult); | ||
| this.recordToolOutputObservability(toolName, finalErrResult); | ||
| return finalErrResult; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| private recordToolOutputObservability(toolName: string, result: MCPResult): void { | ||
| try { | ||
| const metrics = getMetricsCollector(); | ||
| const payload = stringifyResultPayload(result); | ||
| const bytes = Buffer.byteLength(payload, 'utf8'); | ||
| metrics.observe('openchrome_tool_output_bytes', withTenantLabel({ tool: toolName }), bytes); | ||
| metrics.observe('openchrome_tool_estimated_tokens', withTenantLabel({ tool: toolName }), estimateOutputTokensFromChars(payload.length)); | ||
|
Comment on lines
+2063
to
+2064
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| const compression = (result as Record<string, unknown>)._compression; | ||
| if (compression && typeof compression === 'object') { | ||
| const originalChars = (compression as Record<string, unknown>).originalChars; | ||
| const compressedChars = (compression as Record<string, unknown>).compressedChars; | ||
| const level = String((compression as Record<string, unknown>).level ?? 'unknown'); | ||
| if (typeof originalChars === 'number' && typeof compressedChars === 'number' && originalChars > compressedChars) { | ||
|
Comment on lines
+2068
to
+2071
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| metrics.observe( | ||
| 'openchrome_tool_compression_saved_bytes', | ||
| withTenantLabel({ tool: toolName, mode: level }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| originalChars - compressedChars, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This metric is named Useful? React with 👍 / 👎. |
||
| ); | ||
| } | ||
| } | ||
|
|
||
| const cache = extractCacheStatus(result); | ||
| if (cache) { | ||
| metrics.inc('openchrome_cache_status_total', withTenantLabel({ | ||
| tool: toolName, | ||
| status: cache.status, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| key_version: cache.keyVersion, | ||
| })); | ||
| } | ||
| } catch { | ||
| // Metrics are best-effort and must never affect tool responses. | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -599,8 +599,14 @@ const handler: ToolHandler = async ( | |
| const statsLine = `[page_stats] url: ${result.pageStats.url} | title: ${result.pageStats.title} | scroll: ${result.pageStats.scrollX},${result.pageStats.scrollY} | viewport: ${result.pageStats.viewportWidth}x${result.pageStats.viewportHeight} | docSize: ${result.pageStats.scrollWidth}x${result.pageStats.scrollHeight}\n\n`; | ||
| const includePaginationDom = args.includePagination !== false; | ||
| const domPaginationSection = includePaginationDom ? formatPaginationSection(await detectPagination(page, tabId)) : ''; | ||
| const compressedText = statsLine + delta.content + nodeRefsBlock + domPaginationSection; | ||
| return { | ||
| content: [{ type: 'text', text: statsLine + delta.content + nodeRefsBlock + domPaginationSection }], | ||
| content: [{ type: 'text', text: compressedText }], | ||
| _compression: { | ||
| level: 'delta', | ||
| originalChars: outputText.length, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the delta path, Useful? React with 👍 / 👎. |
||
| compressedChars: compressedText.length, | ||
|
Comment on lines
+607
to
+608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Comment on lines
+607
to
+608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
| }, | ||
| }; | ||
| } | ||
| // If not delta (too many changes), fall through to full response | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
rawis an object but does not contain a stringstatus/cacheStatus, this earlyreturn nullprevents the laterstructuredContent.cacheStatusfallback from running. In mixed-shape results (for example,cache: { keyVersion: "v2" }plusstructuredContent.cacheStatus: "miss"),openchrome_cache_status_totalis silently dropped even though a valid cache status is present.Useful? React with 👍 / 👎.