Closes #2051. errorCauseTag() shipped in #2027 written and tested but wired to nothing. This connects it: the two execution catch paths (sync call and task) now pass errorCause into mcp.tool_called, carrying the SQLSTATE or coded-error code, else the error's class name, capped at 64 chars. The rows this exists for are the UNKNOWN_ERROR residue, whose errorMessage is the constant "Något gick fel. Försök igen." and whose errorDetail is the English constant: 465 such rows in the last 30 days (create_voucher 58 of its 60 failures, query_journal 122) with nothing to cluster on. A five-character SQLSTATE is protocol vocabulary; a raw driver message can quote row values from a constraint violation and belongs in the server log, never in event_log, so the raw message is deliberately not captured. A plain `new Error(...)` tags null rather than 'Error': tagging everything is the same as tagging nothing. Pre-execution denials (scope, capability, validation, unknown tool) pass nothing because their errorCode already is the cause. Self-tested by unwiring one call site and watching the new test name it. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
191fb2cfce
commit
dd84d6c1bb
@@ -388,6 +388,59 @@ describe('mcp.tool_called telemetry', () => {
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
describe('errorCause: machine vocabulary for the unmapped residue (#2051)', () => {
|
||||
/**
|
||||
* 65.1% of real-agent errors in the 30 days before #2027 were
|
||||
* UNKNOWN_ERROR, whose errorMessage is the constant "Något gick fel.
|
||||
* Försök igen." and whose errorDetail is the English constant: nothing to
|
||||
* cluster on. errorCause carries errorCauseTag(err): the SQLSTATE or coded
|
||||
* error code, else the error's class name. Protocol vocabulary only,
|
||||
* because a raw driver message can quote row values from a constraint
|
||||
* violation and belongs in the server log, never in event_log.
|
||||
*/
|
||||
it('records the error class name when execute() dies unmapped', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
// Valid call for the key's reports:read scope; the harness supabase is a
|
||||
// bare vi.fn() mock, so execute() dies on it. Exactly the shape that
|
||||
// used to log UNKNOWN_ERROR with the constant message and nothing else.
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_get_trial_balance', arguments: {} })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.errorKind).toBe('execution')
|
||||
expect(event.errorCause).toBe('TypeError')
|
||||
})
|
||||
|
||||
it('stays null for a plain Error: the class name "Error" is noise, not vocabulary', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
// gnubok_load_skill throws `new Error("Skill not found: ...")`.
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_load_skill', arguments: { slug: 'definitely-does-not-exist' } })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.errorKind).toBe('execution')
|
||||
expect(event.errorCause).toBeNull()
|
||||
})
|
||||
|
||||
it('stays null on success and on pre-execution denials', async () => {
|
||||
const successPromise = captureNextToolCalledEvent()
|
||||
await handleMcpRequest(mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} }))
|
||||
expect((await successPromise).errorCause).toBeNull()
|
||||
|
||||
const deniedPromise = captureNextToolCalledEvent()
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_create_invoice', arguments: { customer_id: 'x', items: [] } })
|
||||
)
|
||||
const denied = await deniedPromise
|
||||
expect(denied.errorKind).toBe('scope_denied')
|
||||
expect(denied.errorCause).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT block the JSON-RPC response on telemetry: even if a handler throws', async () => {
|
||||
// Register a handler that throws synchronously. The bus already isolates
|
||||
// failures via Promise.allSettled, so the response should still arrive.
|
||||
|
||||
@@ -48,7 +48,7 @@ import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
|
||||
import { dbError } from '@/lib/errors/db-error'
|
||||
import { dbError, errorCauseTag } from '@/lib/errors/db-error'
|
||||
import { getStructuredError } from '@/lib/errors/get-structured-error'
|
||||
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
@@ -20509,6 +20509,15 @@ function emitToolCallTelemetry(payload: {
|
||||
* message_sv is already the domain message costs nothing.
|
||||
*/
|
||||
errorDetail?: string | null
|
||||
/**
|
||||
* Machine vocabulary for the unmapped failures (#2051): errorCauseTag(err),
|
||||
* i.e. the SQLSTATE or coded-error code, else the error's class name. For
|
||||
* UNKNOWN_ERROR rows message_sv is the constant "Något gick fel. Försök
|
||||
* igen.", so without this the residue cannot be clustered at all. Never the
|
||||
* raw driver message: that can quote row values from a constraint violation
|
||||
* and belongs in the server log, not in event_log.
|
||||
*/
|
||||
errorCause?: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
// null/empty while the key's user has no company yet (issue #1814): the
|
||||
@@ -20542,6 +20551,9 @@ function emitToolCallTelemetry(payload: {
|
||||
payload.errorDetail && payload.errorDetail !== payload.errorMessage
|
||||
? payload.errorDetail.slice(0, 500)
|
||||
: null,
|
||||
// Protocol vocabulary only (a SQLSTATE, a code, a class name), capped
|
||||
// hard: anything longer is a message pretending to be a tag.
|
||||
errorCause: payload.errorCause ? payload.errorCause.slice(0, 64) : null,
|
||||
requestId: payload.requestId,
|
||||
userId: payload.userId,
|
||||
companyId,
|
||||
@@ -21481,6 +21493,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorKind: 'execution',
|
||||
errorMessage: structured.error.message_sv,
|
||||
errorDetail: structured.error.message_en,
|
||||
errorCause: errorCauseTag(err),
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId: effectiveCompanyId,
|
||||
@@ -21574,6 +21587,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
// clustering when mining failures for gotchas.
|
||||
errorMessage: structured.error.message_sv,
|
||||
errorDetail: structured.error.message_en,
|
||||
errorCause: errorCauseTag(err),
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId: effectiveCompanyId,
|
||||
|
||||
Reference in New Issue
Block a user