fix(server): log underlying cause chain for failed D1 queries

Drizzle wraps the real D1 error in DrizzleQueryError.cause; both the access
log and the origin-detect catch only logged .message, surfacing just
"Failed query: <sql>" with no reason. Add formatError() to flatten the cause
chain and use it at both sites so D1 failures are diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saltbo
2026-06-10 00:42:50 -04:00
parent e4b497ac92
commit d2f3a34f05
4 changed files with 49 additions and 8 deletions
+3 -4
View File
@@ -3,6 +3,7 @@ import type { Context } from 'hono'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
import { formatError } from './lib/errors'
import { authMiddleware } from './middleware/auth'
import { imageHostingDomain } from './middleware/image-hosting-domain'
import { accessLog } from './middleware/logger'
@@ -50,8 +51,7 @@ export function createApp(platform: Platform, auth: Auth) {
app.use('/*', platformMiddleware(platform, auth))
app.use('/*', async (c, next) => {
const result = await ensureSitePublicOrigin(platform.db, c.req.url).catch((err) => {
const code = err instanceof Error ? err.message : String(err)
console.error(`site.public_origin.detect.error code=${code}`)
console.error(`site.public_origin.detect.error code=${formatError(err)}`)
return { origin: null, created: false }
})
@@ -66,8 +66,7 @@ export function createApp(platform: Platform, auth: Auth) {
trigger: 'runtime',
runtime: instanceTelemetryRuntime(platform),
}).catch((err) => {
const code = err instanceof Error ? err.message : String(err)
console.error(`instance.telemetry.initial_report.error code=${code}`)
console.error(`instance.telemetry.initial_report.error code=${formatError(err)}`)
})
waitUntil(c, task)
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { formatError } from './errors'
describe('formatError', () => {
it('returns the message for a plain error', () => {
expect(formatError(new Error('boom'))).toBe('boom')
})
it('flattens the cause chain (drizzle wraps the real D1 error in cause)', () => {
const d1 = new Error('D1_ERROR: Network connection lost')
const wrapped = new Error('Failed query: update "download_tasks" ...', { cause: d1 })
expect(formatError(wrapped)).toBe('Failed query: update "download_tasks" ... <- D1_ERROR: Network connection lost')
})
it('appends a non-Error cause', () => {
const err = new Error('outer', { cause: 'inner string' })
expect(formatError(err)).toBe('outer <- inner string')
})
it('stringifies a non-Error value', () => {
expect(formatError('plain string')).toBe('plain string')
})
})
+20
View File
@@ -0,0 +1,20 @@
/**
* Flatten an error and its `cause` chain into a single string.
*
* Drizzle wraps the underlying driver error in `DrizzleQueryError`, whose
* `message` is only `Failed query: <sql>`; the real D1 error lives in `.cause`.
* Logging just `.message` hides why the query failed — this surfaces the chain.
*/
export function formatError(error: unknown): string {
if (!(error instanceof Error)) return String(error)
const parts: string[] = [error.message]
let cause = (error as { cause?: unknown }).cause
while (cause instanceof Error) {
parts.push(cause.message)
cause = (cause as { cause?: unknown }).cause
}
if (cause !== undefined) parts.push(String(cause))
return parts.join(' <- ')
}
+3 -4
View File
@@ -1,5 +1,6 @@
import type { Context } from 'hono'
import { createMiddleware } from 'hono/factory'
import { formatError } from '../lib/errors'
import type { Env } from './platform'
export const accessLog = createMiddleware<Env>(async (c, next) => {
@@ -44,10 +45,8 @@ function accessLogFields(
)
}
if (error instanceof Error) {
fields.push(['error', error.message])
} else if (error) {
fields.push(['error', String(error)])
if (error !== undefined) {
fields.push(['error', formatError(error)])
}
return fields