import type { MiddlewareHandler } from 'hono' import type { RequestIdVariables } from 'hono/request-id' import type { Hex } from 'ox' import * as ApiKey from '../ApiKey.js' import type * as Auth from './Auth.js' import type * as EdgeCache from './EdgeCache.js' import type * as FundingProvider from './funding/Provider.js' import type * as Timing from './Timing.js' /** Hono variables read by the request logger and written by other layers. */ export type Variables = { /** Tempo chain selected for the request. */ chainId?: number | undefined /** Whether the original request exception was already reported. */ errorCaptured?: boolean | undefined /** Stable error code recorded by `Response.error` for the request log entry. */ errorCode?: string | undefined /** Whether the optional funding deposit count query failed. */ fundingDepositCountFailed?: true | undefined /** Bounded MPP relay outcome emitted by the credential routes. */ mpp?: Entry['mpp'] | undefined /** Bounded outcomes for funding providers attempted by the request. */ fundingProviderAttempts?: Entry['fundingProviderAttempts'] | undefined /** Whether the optional funding transfer count query failed. */ fundingTransferCountFailed?: true | undefined /** Redacted provider failure details recorded by an upstream provider. */ providerFailure?: Entry['provider'] | undefined /** Redacted provider failures when multiple upstream providers fail. */ providerFailures?: Entry['providerFailures'] | undefined /** Bounded JSON-RPC failure metadata recorded by a composed RPC handler. */ rpcResponse?: Entry['rpc'] | undefined /** Bounded sponsorship rejection details recorded by the relay app. */ sponsorship?: Sponsorship | undefined } type ErrorMetadata = { /** Bounded provider attempts attached to a caught error. */ fundingProviderAttempts?: Entry['fundingProviderAttempts'] | undefined /** Redacted first provider failure attached to a caught error. */ providerFailure?: Entry['provider'] | undefined /** Redacted provider failures attached to a caught error. */ providerFailures?: Entry['providerFailures'] | undefined } /** Hono environment shape used by the request logger. */ export type Environment = { Variables: Variables & EdgeCache.Variables & RequestIdVariables & Timing.Variables & { /** Authenticated or anonymous caller, set by auth middleware. */ principal?: Auth.Principal | undefined } } /** Log level, derived from the response status. */ export type Level = 'error' | 'info' | 'warn' /** Redacted upstream provider failure details. */ export type ProviderFailure = { /** Chain whose upstream request failed, when the request selected one. */ chainId?: number | undefined /** Stable provider error code, when one is safe to retain. */ code?: string | undefined /** Failure boundary that rejected the provider request. */ failure: 'http' | 'network' | 'payload' | 'query' | 'rate_limit' | 'timeout' | 'unknown' /** Provider identifier. */ id: string /** Provider operation identifier. */ operation: string /** Upstream HTTP status, when a response was received. */ status?: number | undefined } /** Bounded operational result for one funding quote provider attempt. */ export type FundingProviderAttempt = { /** Wall-clock duration of the provider attempt in milliseconds. */ durationMs: number /** Failure class, present only when the provider attempt failed. */ failure?: ProviderFailure['failure'] | undefined /** Provider identifier. */ id: FundingProvider.ProviderId /** Stable upstream operation identifier. */ operation: FundingProvider.ProviderOperation /** Provider attempt outcome. */ outcome: 'available' | 'failed' | 'thin' | 'unavailable' } /** JSON-RPC methods that can request managed sponsorship. */ export type SponsorshipMethod = | 'eth_fillTransaction' | 'eth_sendRawTransaction' | 'eth_sendRawTransactionSync' | 'eth_signRawTransaction' /** Stable client error codes for sponsorship rejections. */ export type SponsorshipReason = | 'api_key_forbidden' | 'api_key_required' | 'billing_past_due' | 'billing_required' | 'chain_id_unsupported' | 'fee_token_unsupported' | 'internal_error' | 'production_api_key_required' | 'project_id_invalid' | 'project_id_required' | 'spend_limit_exceeded' | 'tx_fee_limit_exceeded' /** Bounded diagnostics for sponsorship rejections in one JSON-RPC request. */ export type Sponsorship = { /** Common target chain, absent when rejected batch items disagree or are invalid. */ chainId?: number | undefined /** Number of internal sponsorship failures, capped at 65,535; absent when zero. */ internalErrors?: number | undefined /** Common sponsorship method, absent when rejected batch items use different methods. */ method?: SponsorshipMethod | undefined /** Sponsorship request outcome. */ outcome: 'rejected' /** Keccak256 of the caller's raw transaction, present only for a single-request body. */ payloadHash?: Hex.Hex | undefined /** Common client error code, absent when rejected batch items have different codes. */ reason?: SponsorshipReason | undefined /** Number of sponsorship policy rejections, capped at 65,535. */ rejections: number } /** One canonical request log entry. */ export type Entry = { /** `'hit'` when the edge response cache served the request without reaching the origin handlers. */ cache?: 'hit' | undefined /** Tempo chain selected for the request. */ chainId?: number | undefined /** Total request duration in milliseconds. */ duration: number /** Whether the original request exception was already reported. */ errorCaptured?: boolean | undefined /** Stable error code when the response is a JSON error envelope (e.g. `not_found`, `api_key_invalid`). */ errorCode?: string | undefined /** Whether the optional funding deposit count query failed. */ fundingDepositCountFailed?: true | undefined /** Bounded funding provider attempts made while serving the request. */ fundingProviderAttempts?: readonly FundingProviderAttempt[] | undefined /** Whether the optional funding transfer count query failed. */ fundingTransferCountFailed?: true | undefined /** Log level: 5xx `error`; 4xx or JSON-RPC failure `warn`; otherwise `info`. */ level: Level /** Request method. */ method: string /** Request pathname. */ path: string /** MPP payment reason when the request was paid per call. */ payment?: Auth.Payment['reason'] | undefined /** Operations still running when the response completed, with elapsed milliseconds. */ pendingTimings?: Record | undefined /** Bounded outcome for an MPP relay operation. */ mpp?: { /** Resolved Tempo chain selected by the credential. */ chainId: number /** Stable relay failure code, absent when the operation succeeded. */ errorCode?: string | undefined /** Idempotency claim outcome for broadcast operations. */ idempotency?: 'claimed' | 'pending' | 'replay' | undefined /** Whether a fee payer was supplied for the credential. */ feePayer: boolean /** Relay route operation. */ operation: 'broadcast' | 'verify' /** Relay operation outcome, independent of the HTTP status. */ outcome: 'failure' | 'success' } /** Caller identity. Raw API-key tokens and the super admin secret are never logged. */ principal?: | { /** Attributed super admin identity for super admin callers. */ actor?: string | undefined /** Whether the owning org's billing was active for API-key callers; drives request-metered billing. */ billingActive?: boolean | undefined /** Key environment for API-key callers. */ environment?: ApiKey.ApiKey['environment'] | undefined /** Principal id: the API key id, the public quota identity, the user id, or `super_admin`. */ id: string /** Owning organization id for API-key callers. */ orgId?: string | undefined /** Attributed project id for API-key callers. */ projectId?: string | undefined /** Principal kind. */ type: 'api_key' | 'public' | 'session' | 'super_admin' } | undefined /** Redacted upstream provider failure details, when a provider request fails. */ provider?: ProviderFailure | undefined /** Redacted failures for every provider when multiple providers fail. */ providerFailures?: readonly ProviderFailure[] | undefined /** Raw query string (no leading `?`) with API-key tokens redacted, when present. */ query?: string | undefined /** Rate limit state from the `RateLimit-*` response headers, when enforced. */ rateLimit?: | { /** Quota for the current window. */ limit: number /** Requests remaining in the current window. */ remaining: number /** Reset timestamp in Unix seconds. */ reset?: number | undefined /** Quota bucket scope, when available. */ scope?: string | undefined } | undefined /** Request id, also echoed in the `tempo-request-id` response header. */ requestId: string /** Matched handler path (bounded cardinality), e.g. `/v1/tokens/:address`; `/*` when a middleware (e.g. an edge-cache hit) short-circuited routing. */ route: string /** JSON-RPC failures returned in an otherwise successful HTTP response. */ rpc?: { /** Numeric JSON-RPC error code when every failed response shares one code. */ code?: number | undefined /** Stable application error code from `error.data.code`, when shared by every failed response. */ dataCode?: string | undefined /** Number of failed responses in the single or batch JSON-RPC response. */ errors: number /** Number of JSON-RPC server errors, capped at 65,535; absent when zero. */ serverErrors?: number | undefined } /** Bounded sponsorship rejection diagnostics, without request or response bodies. */ sponsorship?: Sponsorship | undefined /** HTTP response status code. JSON-RPC failures retain their protocol-native `200`. */ status: number /** Longest upstream operation duration per Server-Timing name, in milliseconds (e.g. RPC and TIDX calls). */ timings?: Record | undefined } /** Sink receiving one {@link Entry} per request. */ export type Emit = (entry: Entry, cause?: Error) => Promise | void /** * Emits one canonical structured log entry per request, after the response is * written. Register after `requestId()` and before the timing and edge-cache * layers so the entry covers edge-cache hits and includes the Server-Timing * metrics collected inside the request. * * The entry derives everything from state other layers already produce — * `requestId`, the auth principal, `RateLimit-*` headers, Server-Timing * metrics, the `errorCode` recorded by `Response.error`, JSON-RPC response * errors, and the edge-cache hit marker, so handlers never log per-request * lines themselves. */ export function middleware( options: middleware.Options = {}, ): MiddlewareHandler { const fn = options.emit ?? emit return async (c, next) => { const start = performance.now() await next() const duration = performance.now() - start const url = new URL(c.req.url) url.searchParams.delete('key') const query = ApiKey.redact(url.search.slice(1)) const status = c.res.status // Hono cannot resolve variable types through the generic `environment`, // so shared-variable reads are cast from the environment contract above // (the same pattern EdgeCache uses for its cross-layer reads). const cause = c.error const error = cause as (Error & ErrorMetadata) | undefined const principal = c.get('principal' as never) as Auth.Principal | undefined const errorCaptured = c.get('errorCaptured' as never) as boolean | undefined const chainId = c.get('chainId' as never) as number | undefined const errorCode = c.get('errorCode' as never) as string | undefined const fundingDepositCountFailed = c.get('fundingDepositCountFailed' as never) as | true | undefined const fundingProviderAttempts = error?.fundingProviderAttempts ?? (c.get('fundingProviderAttempts' as never) as Entry['fundingProviderAttempts'] | undefined) const fundingTransferCountFailed = c.get('fundingTransferCountFailed' as never) as | true | undefined const mpp = c.get('mpp' as never) as Entry['mpp'] const provider = error?.providerFailure ?? (c.get('providerFailure' as never) as Entry['provider']) const providerFailures = error?.providerFailures ?? (c.get('providerFailures' as never) as Entry['providerFailures']) const rpc = c.get('rpcResponse' as never) as Entry['rpc'] const sponsorship = c.get('sponsorship' as never) as Entry['sponsorship'] // The `request` total metric duplicates `duration` (measured here, one // layer further out), so only operation timings are kept. const metrics = (c.get('serverTiming' as never) as Timing.Metric[] | undefined)?.filter( (metric) => metric.name !== 'request', ) const timings = metrics?.length ? Object.fromEntries( metrics.reduce((result, metric) => { const duration = round(metric.duration) const existing = result.get(metric.name) if (existing === undefined || duration > existing) result.set(metric.name, duration) return result }, new Map()), ) : undefined const activeTimings = c.get('activeTimings' as never) as Timing.Active[] | undefined const pendingTimings = activeTimings?.length ? Object.fromEntries( activeTimings.reduce((result, timing) => { const duration = round(performance.now() - timing.start) const existing = result.get(timing.name) if (existing === undefined || duration > existing) result.set(timing.name, duration) return result }, new Map()), ) : undefined const rateLimit = (() => { const limit = c.res.headers.get('RateLimit-Limit') const remaining = c.res.headers.get('RateLimit-Remaining') const reset = c.res.headers.get('RateLimit-Reset') const scope = c.res.headers.get('RateLimit-Scope') if (limit === null || remaining === null) return undefined return { limit: Number(limit), remaining: Number(remaining), ...(reset === null ? {} : { reset: Number(reset) }), ...(scope === null ? {} : { scope }), } })() const entry = { ...(c.get('edgeCacheStatus' as never) === 'hit' ? { cache: 'hit' as const } : {}), ...(chainId === undefined ? {} : { chainId }), duration: round(duration), ...(errorCaptured === undefined ? {} : { errorCaptured }), ...(errorCode === undefined ? {} : { errorCode }), ...(fundingDepositCountFailed === undefined ? {} : { fundingDepositCountFailed }), ...(fundingProviderAttempts === undefined ? {} : { fundingProviderAttempts }), ...(fundingTransferCountFailed === undefined ? {} : { fundingTransferCountFailed }), level: status >= 500 || rpcServerError(rpc?.code) || (rpc?.serverErrors ?? 0) > 0 || (sponsorship?.internalErrors ?? 0) > 0 || mpp?.errorCode === 'unknown' ? 'error' : status >= 400 || rpc || sponsorship ? 'warn' : 'info', method: c.req.method, ...(mpp === undefined ? {} : { mpp }), path: url.pathname, ...(principal?.payment === undefined ? {} : { payment: principal.payment.reason }), ...(pendingTimings === undefined ? {} : { pendingTimings }), ...(principal === undefined ? {} : { principal: { ...(principal.type === 'super_admin' ? { actor: principal.actor } : {}), ...(principal.type === 'api_key' ? { billingActive: principal.apiKey.billingActive === true } : {}), ...(principal.type === 'api_key' ? { environment: principal.environment } : {}), id: principal.id, ...(principal.type === 'api_key' ? { orgId: principal.orgId } : {}), ...(principal.type === 'api_key' && principal.projectId !== undefined ? { projectId: principal.projectId } : {}), type: principal.type, }, }), ...(provider === undefined ? {} : { provider }), ...(providerFailures === undefined ? {} : { providerFailures }), ...(query ? { query } : {}), ...(rateLimit === undefined ? {} : { rateLimit }), requestId: c.get('requestId' as never) as string, route: c.req.routePath, ...(rpc === undefined ? {} : { rpc }), ...(sponsorship === undefined ? {} : { sponsorship }), status, ...(timings === undefined ? {} : { timings }), } satisfies Entry const result = fn(entry, cause) if (result instanceof Promise) { const promise = result.catch((error) => console.error('request log sink failed', error)) // Unit tests and non-Worker runtimes do not always provide an execution // context. In Workers, waitUntil keeps async sinks alive without holding // the response open; elsewhere the promise is deliberately fire-and-forget. try { c.executionCtx.waitUntil(promise) } catch { void promise } } } } const rpcDataCode = /^[a-z][a-z0-9_]{0,63}$/ /** Extracts bounded error metadata from an already-parsed JSON-RPC response. */ export function rpcErrors(body: unknown): Entry['rpc'] { const values = Array.isArray(body) ? body : [body] const errors = values.flatMap((value) => { if (!value || typeof value !== 'object') return [] const record = value as Record if (record['jsonrpc'] !== '2.0' || !record['error'] || typeof record['error'] !== 'object') return [] const error = record['error'] as Record if ( !Number.isInteger(error['code']) || Number(error['code']) < -2_147_483_648 || Number(error['code']) > 2_147_483_647 ) return [] const data = error['data'] const dataCode_value = data && typeof data === 'object' ? (data as Record)['code'] : undefined const dataCode = typeof dataCode_value === 'string' ? dataCode_value : undefined return [ { code: error['code'] as number, ...(dataCode && rpcDataCode.test(dataCode) ? { dataCode } : {}), }, ] }) if (errors.length === 0) return undefined const codes = new Set(errors.map((error) => error.code)) const dataCodes = new Set(errors.map((error) => error.dataCode).filter(Boolean)) const serverErrors = errors.filter((error) => rpcServerError(error.code)).length return { ...(codes.size === 1 ? { code: errors[0]!.code } : {}), ...(dataCodes.size === 1 && errors.every((error) => error.dataCode !== undefined) ? { dataCode: errors[0]!.dataCode } : {}), errors: Math.min(errors.length, 65_535), ...(serverErrors === 0 ? {} : { serverErrors: Math.min(serverErrors, 65_535) }), } } function rpcServerError(code: number | undefined) { return code === -32603 } export declare namespace middleware { /** Options for the request logger middleware. */ type Options = { /** Log sink. Defaults to {@link emit}: the level-matched console method, passed the entry as a structured object. */ emit?: Emit | undefined } } /** * Default log sink: writes the entry as a structured object through the * console method matching its level. Passing the object (not a pre-serialized * JSON string) lets Cloudflare Workers Logs index the fields for filtering * and querying; Node prints the same object inline on stdout/stderr. */ export function emit(entry: Entry) { if (entry.level === 'error') console.error(entry) else if (entry.level === 'warn') console.warn(entry) else console.log(entry) } /** Severity ranking used to filter entries below a minimum level. */ const severity = { error: 2, info: 0, warn: 1 } as const /** * An {@link Emit} that drops entries below `min` and otherwise delegates to * {@link emit}. `'warn'` keeps warn/error (e.g. production, where the request * metrics sink carries successful-request volume instead of one info log each). */ export function withMinLevel(min: Level): Emit { return (entry) => { if (severity[entry.level] < severity[min]) return emit(entry) } } function round(value: number) { return Math.round(value * 10) / 10 }