Skip to content

fix(observability): name the failure on the access log line#819

Merged
jarvis9443 merged 3 commits into
mainfrom
fix/access-log-error-field
Jul 24, 2026
Merged

fix(observability): name the failure on the access log line#819
jarvis9443 merged 3 commits into
mainfrom
fix/access-log-error-field

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

A failed request left status=… latency_ms=… as its entire trace. That line is identical whether the upstream returned 502, DNS failed, a pooled connection was reaped, or the kernel gave up on an unanswered SYN.

On the /v1/messages native path it is also the only record — that handler emits no WARN either. Diagnosing AISIX-Cloud#1093 from a customer log capture meant turning on hyper's connection-layer TRACE and reading connect error for …: ConnectError("tcp connect error", Os { code: 110, kind: TimedOut }) to recover a cause the gateway already held in hand at the moment it wrote the access log.

Change

AccessLog gains error_kind + error, filled from the ProxyError every handler already has on its failure branch:

status=502 latency_ms=6747 … error_kind="upstream_status"
  error="upstream returned HTTP 502: upstream exploded"
  • error_kindProxyError::kind, a stable token to filter or alert on without parsing prose.
  • error — the reason.
  • Success lines carry neither, so error_kind= means "something failed" and nothing else.

Wired through all 15 handlers, not just the reported one: the gap was in the shared struct, so every endpoint had it. /mcp and /a2a are the two that stay None — their dispatch returns an already-rendered Response, so no typed error reaches the log point (noted inline). On /v1/realtime, session_error travels beside close_status and is set in all four failing branches (504 idle timeout, 502 upstream stream error, both 400 guardrail blocks).

attempt::access_log_error is deliberately not attempt_error_from_proxy: that one leaves the message empty for every non-bridge variant, which is right for a per-attempt record (the class is the point) but would put a failed request straight back to naming no cause. It reuses the existing control-char strip + 2048 cap, so the line stays one-line-greppable and #1065's double-clipping does not return. U+2028/U+2029 are stripped explicitly — they are Zl/Zp, so is_control() forwards them.

Behaviour

Additive only — a new pair of optional fields on an existing log line. No status, envelope, or telemetry change. The reason text is the same Display already sent to the client and to UsageEvent.error_message, so no new information is exposed.

Scope boundary

The issue also asks for a termination-reason taxonomy (downstream_remote_disconnect, upstream_disconnect, gateway_timeout) so a client cancellation stops showing up as a plain 200. That is not in this PR. It is a behaviour change rather than a logging one: a client-side transport error on /v1/realtime currently keeps close_status 200, and RequestOutcome::from_status maps that to success, so giving it a failing status would move every operator's realtime success rate and the alerts on it. Each reason needs its own status decision, and it reaches past realtime. Called out explicitly in-code at the one place it bites.

Tests

  • access-log rendering: failure fields present; absent on a success line
  • class/reason extraction: every ProxyError variant contributes a message, control chars and U+2028/U+2029 stripped, cap enforced, and the Timeout.cause added for this issue survives into the line

Test exception: no handler-level log-capture test. A scoped subscriber cannot observe events emitted through the axum router under the full suite — AccessLog::emit's callsite interest is cached process-wide by whichever test reaches it first, so the capture comes back empty (verified: same thread, global max-level INFO, emit_access_log provably invoked with the right status; each test passes when run alone, and a direct synchronous emit() captures fine). Handler wiring is enforced by the type system instead: every call site must pass Option<&ProxyError> explicitly, and the compiler enumerated all 36 of them.

Fixes api7/AISIX-Cloud#1093

A failed request left `status=… latency_ms=…` as its entire trace. That
line is identical whether the upstream returned 502, DNS failed, a pooled
connection was reaped, or the kernel gave up on an unanswered SYN — and
on the `/v1/messages` native path it is the ONLY record, because that
handler emits no WARN either. Diagnosing AISIX-Cloud#1093 from a customer
log capture meant reading hyper's connection-layer TRACE output to
recover what the gateway already knew at the point it logged the line.

`AccessLog` gains `error_kind` + `error`, filled from the `ProxyError`
every handler already holds on its failure branch:

    status=502 latency_ms=6747 … error_kind="upstream_status"
      error="upstream returned HTTP 502: upstream exploded"

`error_kind` is `ProxyError::kind` — a stable token to filter or alert
on; `error` is the reason. Success lines carry neither, so `error_kind=`
means "something failed" and nothing else.

Wired through all 15 handlers rather than just the reported one: the gap
was in the shared struct, so every endpoint had it. `/mcp` and `/a2a`
are the two that stay `None` — their dispatch returns an already-rendered
`Response`, so no typed error reaches the log point; noted inline.

`attempt::access_log_error` is deliberately not `attempt_error_from_proxy`:
that one leaves the message empty for non-bridge variants, which is right
for a per-attempt record but would put a failed request straight back to
naming no cause. It reuses the existing control-char strip + 2048 cap so
the line stays one-line-greppable and #1065's double-clipping doesn't
come back.

Tests: access-log rendering (failure fields present, absent on success),
and the class/reason extraction incl. control-char stripping, the cap,
and the `Timeout.cause` added for this issue surviving into the line.

No handler-level log-capture test: a scoped subscriber cannot observe
events emitted through the axum router under the full suite — the
callsite interest for `AccessLog::emit` is cached process-wide by
whichever test reaches it first, so the capture comes back empty (each
test passes alone). Handler wiring is enforced by the type system
instead: every call site must pass `Option<&ProxyError>` explicitly.

Fixes api7/AISIX-Cloud#1093
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Access logs gain optional error_kind and sanitized error fields. Proxy handlers now pass ProxyError context on failures and leave these fields unset on success or pre-rendered responses. Video and realtime telemetry use the same propagation pattern.

Changes

Structured access-log error metadata

Layer / File(s) Summary
Access-log error contract and sanitization
crates/aisix-obs/src/access_log.rs, crates/aisix-proxy/src/attempt.rs
AccessLog emits optional failure fields, while shared sanitization strips control characters and caps error messages. Tests cover success, failure, all ProxyError variants, and message limits.
Typed error propagation through request handlers
crates/aisix-proxy/{audio,chat,completions,count_tokens,embeddings,images,jobs,messages,passthrough,rerank,responses}.rs
Handlers pass None on success and Some(&err) on failure; access-log helpers derive and record error_kind and error.
Rendered-response and realtime failure paths
crates/aisix-proxy/src/{a2a,mcp,realtime}.rs
A2A and MCP leave typed error fields unset for pre-rendered responses, while realtime records handshake, timeout, filtering, and upstream stream failures.
Video telemetry error propagation
crates/aisix-proxy/src/videos.rs
Video submission, lookup, and content telemetry passes optional proxy errors into AccessLog.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Coverage is unit/helper-only; no real request→handler→access-log E2E asserts exist, and realtime still leaves Some(Err(_)) as 200 with no error classification. Add an E2E test that drives a real handler and checks the emitted access log on failure, and classify realtime client-receive errors with a dedicated session_error/status instead of a clean close.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR adds structured failure classification and sanitized error messages to access logs across handlers, satisfying the observability goal in #1093.
Out of Scope Changes check ✅ Passed The changes stay focused on access-log error fields, handler wiring, and sanitization without unrelated functional expansion.
Security Check ✅ Passed PASS: New access-log error text is derived from sanitized ProxyError/BridgeError display; no auth headers/cookies/secrets are logged, and realtime transport URLs are query-redacted.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding named failure information to access logs.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/access-log-error-field

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/aisix-proxy/src/chat.rs (1)

415-428: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Log typed pre-dispatch failures as well.

These calls cover only dispatch failures. chat_completions returns typed JSON-rejection errors before this branch, while count_tokens returns typed auth/body-rejection errors before its dispatch call; those common 400/401/403/413 responses still produce no handler access record or failure metadata.

  • crates/aisix-proxy/src/chat.rs#L415-L428: emit an access record before the JSON-rejection return, using client.request_id and unknown routing/provider metadata.
  • crates/aisix-proxy/src/count_tokens.rs#L115-L125: emit an access record before auth and body-rejection returns, using unknown metadata when authentication did not resolve.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/chat.rs` around lines 415 - 428, Emit access records
for typed pre-dispatch failures before their JSON-rejection returns: in
crates/aisix-proxy/src/chat.rs at lines 415-428, update chat_completions to use
client.request_id with unknown routing/provider metadata; in
crates/aisix-proxy/src/count_tokens.rs at lines 115-125, add equivalent logging
before auth and body-rejection returns, using unknown metadata when
authentication has not resolved.
crates/aisix-proxy/src/audio.rs (1)

1186-1211: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add source-blind endpoint tests for the propagated fields.

The helper tests validate rendering and sanitization, but not that each handler emits Some(&err) on failure and omits both fields on success. Add observable access-log tests for all changed routes, including both streaming and non-streaming chat paths.

  • crates/aisix-proxy/src/audio.rs#L1186-L1211: cover transcription, translation, and speech success/failure logs.
  • crates/aisix-proxy/src/chat.rs#L3854-L3889: cover chat success and typed failures in streaming and non-streaming modes.
  • crates/aisix-proxy/src/completions.rs#L659-L688: cover completion success/failure logs.
  • crates/aisix-proxy/src/count_tokens.rs#L436-L461: cover count-token success/failure logs.
  • crates/aisix-proxy/src/images.rs#L465-L490: cover image-generation success/failure logs.

As per coding guidelines, “test every wired endpoint,” including streaming and non-streaming branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/audio.rs` around lines 1186 - 1211, Add source-blind
endpoint tests verifying access logs emit Some(&err) for failures and omit both
error fields on success. Cover transcription, translation, and speech in
crates/aisix-proxy/src/audio.rs:1186-1211; streaming and non-streaming chat
success and typed failures in crates/aisix-proxy/src/chat.rs:3854-3889;
completion success/failure in crates/aisix-proxy/src/completions.rs:659-688;
count-token success/failure in crates/aisix-proxy/src/count_tokens.rs:436-461;
and image-generation success/failure in
crates/aisix-proxy/src/images.rs:465-490, exercising each handler’s wired
endpoint branches.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/attempt.rs`:
- Around line 161-165: Update sanitize_error_message so its character filter
excludes Unicode line separators U+2028 and U+2029 in addition to control
characters, then apply the existing MAX_ATTEMPT_ERROR_MESSAGE_CHARS cap and
collection behavior unchanged.

In `@crates/aisix-proxy/src/realtime.rs`:
- Line 591: Update run_session’s shared completion path to retain an
Option<ProxyError> for post-upgrade failures: create sanitized errors in the
idle-timeout and upstream-stream-error branches, while leaving successful
completion as None, then pass that option instead of the unconditional None so
the access log includes error_kind and error.

---

Outside diff comments:
In `@crates/aisix-proxy/src/audio.rs`:
- Around line 1186-1211: Add source-blind endpoint tests verifying access logs
emit Some(&err) for failures and omit both error fields on success. Cover
transcription, translation, and speech in
crates/aisix-proxy/src/audio.rs:1186-1211; streaming and non-streaming chat
success and typed failures in crates/aisix-proxy/src/chat.rs:3854-3889;
completion success/failure in crates/aisix-proxy/src/completions.rs:659-688;
count-token success/failure in crates/aisix-proxy/src/count_tokens.rs:436-461;
and image-generation success/failure in
crates/aisix-proxy/src/images.rs:465-490, exercising each handler’s wired
endpoint branches.

In `@crates/aisix-proxy/src/chat.rs`:
- Around line 415-428: Emit access records for typed pre-dispatch failures
before their JSON-rejection returns: in crates/aisix-proxy/src/chat.rs at lines
415-428, update chat_completions to use client.request_id with unknown
routing/provider metadata; in crates/aisix-proxy/src/count_tokens.rs at lines
115-125, add equivalent logging before auth and body-rejection returns, using
unknown metadata when authentication has not resolved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 72167c1e-bcce-42ef-8c78-5d52c24b3304

📥 Commits

Reviewing files that changed from the base of the PR and between 5418367 and e3e9348.

📒 Files selected for processing (17)
  • crates/aisix-obs/src/access_log.rs
  • crates/aisix-proxy/src/a2a.rs
  • crates/aisix-proxy/src/attempt.rs
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/completions.rs
  • crates/aisix-proxy/src/count_tokens.rs
  • crates/aisix-proxy/src/embeddings.rs
  • crates/aisix-proxy/src/images.rs
  • crates/aisix-proxy/src/jobs.rs
  • crates/aisix-proxy/src/mcp.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/passthrough.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/rerank.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/videos.rs

Comment thread crates/aisix-proxy/src/attempt.rs
Comment thread crates/aisix-proxy/src/realtime.rs Outdated
Review catch on the previous commit: `run_session` sets `close_status`
to 504 (idle timeout), 502 (upstream stream error), or 400 (guardrail
block), but the shared completion path passed `None` — so exactly the
failures this PR exists to explain stayed nameless on the realtime
endpoint. A `session_error` now travels beside `close_status`, set in
all four failing branches, `None` only for a clean close.

CodeRabbit flagged the 504/502 pair; the two 400 guardrail blocks are
the same class and are wired here too.

Also strip U+2028/U+2029 in `sanitize_error_message`. They are `Zl`/`Zp`,
not `Cc`, so `is_control()` forwards them and a viewer that breaks lines
on them would split a record — the exact property the sanitizer exists
to guarantee.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/realtime.rs`:
- Around line 448-451: The realtime client receive error path must not leave
session_error as None while reporting a successful close status. Update the
client receive branch handling Some(Err(_)) to record a dedicated client-side
ProxyError and corresponding failing close status, or explicitly preserve and
document the exclusion if intentional; add an E2E assertion covering the chosen
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3317d628-44d1-4ad3-8716-4681583bee11

📥 Commits

Reviewing files that changed from the base of the PR and between e3e9348 and 057d83c.

📒 Files selected for processing (2)
  • crates/aisix-proxy/src/attempt.rs
  • crates/aisix-proxy/src/realtime.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/aisix-proxy/src/attempt.rs

Comment thread crates/aisix-proxy/src/realtime.rs Outdated
Review follow-up: the comment claimed `session_error` is `None` only
for a clean close, which is not true — a client-side transport error
(`Some(Err(_))` on the receive half) also leaves it `None`, keeping
`close_status` 200.

Documented rather than reclassified. Turning that into a failure is a
behaviour change, not a logging one: `RequestOutcome::from_status`
would flip those sessions from `success` to `client_error` and move
every operator's realtime success rate. It belongs with the
termination-reason taxonomy (`downstream_remote_disconnect` and
friends) AISIX-Cloud#1093 asks for separately, which needs its own
status decision.
@jarvis9443
jarvis9443 merged commit 47942dc into main Jul 24, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the fix/access-log-error-field branch July 24, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant