Skip to content

Observability & Structured Logs

BVE Gateway emits one structured JSON log line per request to the Cloudflare log stream. Consume the stream with bunx wrangler tail during development, or ship it to S3, R2, Splunk, or Datadog via Cloudflare Logpush in production.

Terminal window
# Stream live logs from production
bun run tail
# Stream live logs from local dev server
bunx wrangler tail --env dev

Each log line is a single JSON object. level maps directly to the console API used (infoconsole.log, warnconsole.warn, errorconsole.error) so Cloudflare Workers Logs can filter by severity without post-processing.

FieldTypeDescription
level"info" | "warn" | "error"info for 2xx/3xx, warn for 4xx, error for 5xx
type"request"Always "request" for the per-request structured log
timestampISO 8601 stringUTC timestamp of when the request arrived (YYYY-MM-DDTHH:mm:ss.mmmZ)
requestIdUUID stringGateway-generated UUID, returned to clients as X-Request-Id and embedded in all error payloads
methodstringHTTP method (GET, POST, etc.)
pathstringRequest path, credential-pattern-redacted
statusintegerHTTP response status code
latencyMsintegerWall-clock milliseconds from request received to response headers sent
FieldTypeConditionDescription
routePatternstringMatched handler existsNormalized Hono route pattern (e.g. /v1/chat/completions, /admin/api-keys/:id). Differs from path — use this for grouping by endpoint in Logpush without raw IDs obscuring aggregates
querystringQuery string presentURL query string (after ?), capped at 200 chars. Credential-like params (api_key, token, secret, etc.) are replaced with [REDACTED]
requestSizeintegerContent-Length header presentParsed request body size in bytes. Absent for GET/OPTIONS and streaming uploads
responseSizeintegerContent-Length response header presentResponse body size in bytes. Absent for streaming responses (SSE/NDJSON) and audio/image pass-throughs
clientRequestIdstringClient sent X-Request-IdClient-supplied request ID, preserved for cross-system tracing. Absent when the client did not send X-Request-Id or the value fails the safe-character check (alphanumeric + -_., ≤ 128 chars)
FieldTypeConditionDescription
slowtruelatencyMs > 5000Quick flag for slow requests — filter without parsing integers
streamtrueSSE or NDJSON responsePresent on streaming responses. High latencyMs is expected (stream stays open until the client disconnects)
webSearchtrueweb_search_options presentSet when the POST /v1/chat/completions body included web_search_options, indicating the upstream performed a real-time web search. Filter Logpush by webSearch=true to monitor web-search traffic share
upstreamLatencyMsintegerUpstream call madeTime in milliseconds from the upstream HTTP request to response headers received. Absent for gateway-rejected requests (401, 403, 429 from middleware)
slowUpstreamtrueupstreamLatencyMs > 3000Quick flag for slow upstream responses
gatewayOverheadMsintegerupstreamLatencyMs presentmax(0, latencyMs - upstreamLatencyMs). Covers auth cache lookups, D1 allowlist queries, Durable Object quota checks, and request validation. Use to distinguish “Fuelix is slow” from “gateway is slow”
FieldTypeConditionDescription
keyIdUUID stringAuthenticated requestAPI key UUID
keyNamestringAuthenticated requestAPI key display name, credential-pattern-redacted
keyExpiresAtISO 8601 stringKey has an expiryExpiry timestamp. Use in Logpush to alert on soon-to-expire keys without querying D1
keyRpmLimitinteger429 rate_limit_exceededConfigured requests-per-minute limit for the key. Makes rejection log entries self-contained — pair with quotaReason and keyRpdLimit for Logpush alert rules
keyRpdLimitinteger429 rate_limit_exceededConfigured requests-per-day limit for the key
keyMonthlyReqLimitinteger429 rate_limit_exceeded, limit setConfigured monthly request cap. Absent for keys with no monthly request limit
keyMonthlyTokenLimitinteger429 rate_limit_exceeded, limit setConfigured monthly token cap. Absent for keys with no monthly token limit
FieldTypeConditionDescription
modelstringModel present in requestModel ID from the request body, credential-pattern-redacted
providerstringModel recognizedModel creator inferred from the model ID prefix. Values: openai, anthropic, google, cohere, meta, mistral, cursor, deepseek, wasikan. Absent for D1-only custom models. Matches the bve_provider annotation from GET /v1/models

Provider inference examples:

Model prefixProvider
gpt-*, o1, o3, o4-mini, tts-*, whisper-*, text-embedding-*openai
claude-*, c-haiku-4-5anthropic
gemini-*, gemma-*, imagen-*google
command-*, embed-*cohere
llama-*meta
mistral-*, mixtral-*mistral
cursor-c-*cursor
FieldTypeConditionDescription
upstreamStatusintegerUpstream call madeHTTP status returned by Fuelix before any gateway normalization
upstreamRequestIdstringUpstream returned a request IDFuelix’s own request ID from the x-request-id (OpenAI, Groq) or request-id (Anthropic) response header. Use this to correlate a BVE log entry with the corresponding entry in Fuelix’s logs
completionIdstringNon-streaming buffered responseThe id field from the upstream JSON response (e.g. chatcmpl-abc123, msg_01abc123, resp_abc123). Absent for streaming, embeddings, and Gemini

Each cache status is "HIT" (fresh), "STALE" (past soft TTL, served stale + background refresh scheduled), or "MISS" (synchronous D1/upstream fetch). Monitor these to detect elevated D1 round-trips or cache invalidation storms.

FieldConditionDescription
authCacheStatusAll authenticated requestsAPI key SWR cache (20 s soft / 30 s hard TTL). HIT = no D1 query; MISS = synchronous D1 lookup
modelsCacheStatusGET /v1/modelsUpstream model list cache (4 min soft / 5 min hard TTL)
modelAllowlistCacheStatusJSON requests with a model fieldPer-model global D1 allowlist cache (20 s / 30 s). Present even on rejected requests
tokenCacheStatusKeys with monthly_token_limitMonthly token total cache (20 s / 30 s). Present even on 429 quota rejections

Token fields are only present for non-streaming buffered JSON responses where usage is synchronously available. All are absent for streaming, embeddings, audio, and gateway-level rejections.

FieldTypeConditionDescription
promptTokensintegerUsage availableInput tokens consumed
completionTokensintegerUsage availableOutput tokens generated
totalTokensintegerUsage availablepromptTokens + completionTokens
cachedTokensintegerNon-zero cached tokensPrompt tokens served from provider cache. For OpenAI: billed at 50% of normal rate. For Anthropic (cache_read_input_tokens): billed at ~10% of normal rate
cacheWriteTokensintegerAnthropic only, non-zeroPrompt tokens written to Anthropic’s cache for the first time, billed at 125% of normal. Absent for all other providers
reasoningTokensintegerOpenAI o-series, non-zeroInternal chain-of-thought reasoning tokens (not visible in the response). Counts toward completionTokens but billed separately
FieldTypeConditionDescription
errorCodestringGateway or upstream errorMachine-readable rejection code (e.g. invalid_api_key, rate_limit_exceeded, model_not_available, invalid_json)
quotaReasonstring429 from quota middlewareHuman-readable quota limit description (e.g. Rate limit exceeded: 60 requests per minute)
errorParamstringField-level validation failureDotted field path of the failing parameter (e.g. messages[2].tool_call_id). Absent for auth failures and JSON parse errors
retryAfterinteger429 or 503 responseSeconds parsed from the Retry-After response header. Present on quota 429s and global rate limiter 503s
FieldTypeDescription
cfRaystringCloudflare Ray ID from cf-ray request header
ipstringClient IP from cf-connecting-ip (Cloudflare-verified)
countrystringClient country from request.cf.country (Cloudflare GeoIP)
colostringCloudflare data center that processed the request (e.g. SJC, LHR)
httpVersionstringTransport protocol (HTTP/1.1, HTTP/2, HTTP/3). Absent in local dev
uastringClient User-Agent, capped at 200 chars, credential-pattern-redacted
FieldTypeDescription
workerIdUUID stringVersion UUID from the CF_VERSION_METADATA binding. Always present in production when the binding is configured. Also set as X-BVE-Worker response header
workerTagstringOptional deploy tag set via bunx wrangler deploy --tag <tag>. Use in CI to correlate log entries with a specific release
{
"level": "info",
"type": "request",
"timestamp": "2026-05-30T09:42:15.123Z",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"method": "POST",
"path": "/v1/chat/completions",
"status": 200,
"latencyMs": 1842,
"routePattern": "/v1/chat/completions",
"requestSize": 312,
"upstreamLatencyMs": 1789,
"gatewayOverheadMs": 53,
"keyId": "550e8400-e29b-41d4-a716-446655440000",
"keyName": "ci-bot",
"model": "gpt-4o",
"provider": "openai",
"upstreamStatus": 200,
"upstreamRequestId": "req_abc123",
"completionId": "chatcmpl-xyz789",
"authCacheStatus": "HIT",
"modelAllowlistCacheStatus": "HIT",
"promptTokens": 245,
"completionTokens": 83,
"totalTokens": 328,
"cfRay": "8a1b2c3d4e5f6789-SJC",
"ip": "1.2.3.4",
"country": "US",
"colo": "SJC",
"httpVersion": "HTTP/2",
"workerId": "aaaa1111-bbbb-2222-cccc-3333dddd4444"
}

Pipe bun run tail output through jq to filter in real time.

Terminal window
# All errors (5xx)
bun run tail | grep '"level":"error"'
# All 4xx (client errors)
bun run tail | grep '"level":"warn"'
# Slow requests (> 5 s total)
bun run tail | jq 'select(.slow == true)'
# Slow upstream (Fuelix taking > 3 s)
bun run tail | jq 'select(.slowUpstream == true)'
# All web-search requests
bun run tail | jq 'select(.webSearch == true)'
# Streaming responses only
bun run tail | jq 'select(.stream == true)'
# All Anthropic Claude traffic
bun run tail | jq 'select(.provider == "anthropic")'
# All Google Gemini traffic
bun run tail | jq 'select(.provider == "google")'
# All Cohere traffic
bun run tail | jq 'select(.provider == "cohere")'
# Traffic for a specific model
bun run tail | jq 'select(.model == "gpt-4o")'
# Auth cache misses (D1 round-trips)
bun run tail | jq 'select(.authCacheStatus == "MISS")'
# Quota rejections (rate_limit_exceeded)
bun run tail | jq 'select(.errorCode == "rate_limit_exceeded")'
# Quota rejections with configured limits (self-contained 429 log — no admin API lookup needed)
bun run tail | jq 'select(.errorCode == "rate_limit_exceeded") | {keyId, keyName, quotaReason, keyRpmLimit, keyRpdLimit, keyMonthlyReqLimit, keyMonthlyTokenLimit}'
# Rate-limited keys with a tight RPM limit (< 60) — distinguish tight quota from abuse
bun run tail | jq 'select(.errorCode == "rate_limit_exceeded" and (.keyRpmLimit // 999) < 60)'
# Requests using Anthropic prompt cache (any cached tokens)
bun run tail | jq 'select(.cachedTokens != null)'
# Requests that wrote to Anthropic cache (first write, 125% cost)
bun run tail | jq 'select(.cacheWriteTokens != null)'
# Reasoning token usage (OpenAI o-series)
bun run tail | jq 'select(.reasoningTokens != null)'
# Requests by a specific API key
bun run tail | jq 'select(.keyId == "550e8400-e29b-41d4-a716-446655440000")'
# High gateway overhead (> 200 ms — auth/quota/D1 slowness)
bun run tail | jq 'select((.gatewayOverheadMs // 0) > 200)'
# Compute cache hit rate for a key
bun run tail | jq '
select(.provider == "anthropic" and .promptTokens != null) |
{cachedTokens: (.cachedTokens // 0), promptTokens}
'

To ship logs to an external destination (S3, R2, Splunk, Datadog, etc.), configure a Cloudflare Logpush job for the Workers Trace Events dataset in the Cloudflare dashboard:

  1. Go to Cloudflare Dashboard → Analytics & Logs → Logpush
  2. Create a new job for Workers Trace Events
  3. Select the bve-gateway Worker
  4. Set your destination (R2 bucket, S3, HTTP endpoint, etc.)
  5. Apply a filter if needed (e.g. Outcome = ok for success-only logs)

Each delivered record includes the ConsoleLog fields containing your structured JSON. Parse the Message field with jq or your SIEM’s JSON extractor to access individual fields.

  • upstreamRequestId comes from the x-request-id response header.
  • cachedTokens reflects usage.prompt_tokens_details.cached_tokens (billed at 50% of normal).
  • reasoningTokens reflects usage.completion_tokens_details.reasoning_tokens (o-series only).
  • completionId format: chatcmpl-* for chat, cmpl-* for legacy completions, resp-* for Responses API.
  • upstreamRequestId comes from the request-id response header (not x-request-id).
  • cachedTokens reflects usage.cache_read_input_tokens (billed at ~10% of normal).
  • cacheWriteTokens reflects usage.cache_creation_input_tokens (billed at 125%, first write only).
  • completionId format: msg_*.
  • Rate limit headers (anthropic-ratelimit-*) are forwarded to clients for SDK backoff.
  • provider is google for all gemini-*, gemma-*, and imagen-* models.
  • Gemini streaming uses NDJSON (not SSE); stream: true is still set in the log.
  • upstreamRequestId is absent — Fuelix’s Gemini adapter does not return a request ID header.
  • completionId is absent — Gemini responses do not include a top-level id field.
  • reasoningTokens are included in completionTokens for Gemini 2.5 thinking models (sourced from usageMetadata.thoughtsTokenCount).
  • upstreamRequestId comes from the x-cohere-request-id response header.
  • Both Cohere v1 (event_type: stream-end, meta.billed_units) and v2 (type: message-end, delta.usage) streaming formats are parsed for token counts.
  • completionId is absent — Cohere responses do not include a top-level id field in the OpenAI-compatible format.
  • Models running on Groq infrastructure are identified by their model ID prefix: llama-*provider: "meta", mixtral-*provider: "mistral".
  • upstreamRequestId comes from the x-groq-request-id response header.
  • x-groq-processing-time (server-side inference seconds) is forwarded to clients for latency diagnostics but is not in the structured log. Use upstreamLatencyMs and gatewayOverheadMs for end-to-end analysis.
  • OpenRouter-routed requests show the actual model selected after provider routing in x-openrouter-model (forwarded to clients). The model log field reflects the model ID the client requested.
  • x-or-cache-status (HIT/MISS) and x-or-remaining-tokens are forwarded to clients but not in the structured log.
  • Slash-style routing prefixes (e.g. openai/gpt-4o) are rejected at the gateway with errorCode: "invalid_value" before reaching Fuelix.