Skip to content

feat(guardrails): preserve Aliyun's upstream RequestId and correlate it to the gateway request - #781

Merged
jarvis9443 merged 3 commits into
mainfrom
feat/aliyun-upstream-diagnostics
Jul 17, 2026
Merged

feat(guardrails): preserve Aliyun's upstream RequestId and correlate it to the gateway request#781
jarvis9443 merged 3 commits into
mainfrom
feat/aliyun-upstream-diagnostics

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Problem

The aliyun_text_moderation guardrail dropped everything Aliyun reported about a moderation call. AliyunResponse never deserialized RequestId, so a caller's 422 content_filter could not be traced to a record in the Aliyun console.

Two things had to be true to fix that, and neither was:

  1. The Aliyun RequestId had to be captured and logged.
  2. It had to be joinable to the x-aisix-request-id the caller holds — otherwise the operator has an id they cannot look anything up with.

Where the RequestId comes from

Read x-acs-request-id off the response headers, falling back to the body's RequestId.

Verified against the live green-cip endpoint: the header is present on 2xx, on a JSON business error, and on 400/404 responses whose body is XML — it survives exactly the cases where the body is unreachable. The body alone would not, because Aliyun types Code inconsistently:

HTTP status Code
success / business error 200 int (200, 400)
transport/auth error 4xx string ("InvalidAccessKeyId.NotFound")

So the documented shape does not deserialize on the error path. Only the HTTP-200 shape is parsed; the error shape keeps its existing raw capped-body log, now with the header-sourced id alongside.

Timeouts and connection failures yield no id at all — those log an empty aliyun_request_id plus the failure bucket, so "Aliyun never answered" stays distinguishable from "we forgot to log it". An HTTP 200 with a non-JSON body now reports MalformedResponse rather than being bucketed as ServerError; the two want different fixes, and the bypass tag aliyun_bad_response is new.

Correlation

Nothing in the data plane put a request_id on a log line. ensure_request_id minted the id for the response header and telemetry, but no tracing span carried it — so all 38 guardrail log sites, across all six providers, were uncorrelated. This is a whole-class gap, not an Aliyun one.

ensure_request_id now opens a request{request_id=…} span that every guardrail inherits. Two places fall outside it and re-attach explicitly:

  • the four SSE generators, whose bodies hyper polls after the middleware returns;
  • the realtime session, which axum runs on a detached task.

The invariant is recorded in a new crates/aisix-proxy/AGENTS.md, because forgetting it on a future stream builder fails silently.

Log searchability

The fmt subscriber colorized unconditionally. On a piped stderr — every real deployment — the ANSI escapes land between a field's name and its value, so grep 'aliyun_request_id=<id>' matched nothing and fields were searchable only by bare value. Colorize only when stderr is a terminal.

Log-format change (worth a release note)

Two visible changes to what the gateway writes to stderr, both intended:

  • Every log line emitted while serving a request gains a request{request_id=…}: prefix, between the level and the target. A downstream parser anchored on the target still matches; one anchored on level target: as adjacent tokens would need adjusting.
  • Colour escapes disappear from piped/redirected stderr (they stay for a terminal). Anything that was stripping ANSI keeps working; anything that was matching field=value starts working.

Behavior for callers

Unchanged. Aliyun's RequestId is deliberately not exposed in the response envelope or a header: callers get x-aisix-request-id and quote it, operators resolve the rest from the log. This discloses no moderation vendor and adds no wire contract to maintain.

Content safety

Two leaks, one of them live before this PR.

The moderation result. A real high response echoes the offending text back:

{ "Label": "inappropriate_oral", "RiskWords": "傻逼,弄死你,死你全家",
  "RiskPositions": [{ "StartPos": 3, "EndPos": 5, "RiskWord": "傻逼" }] }

Only Label is deserialized, so per #153 there is nowhere for matched content to leak.

The 4xx error body — a pre-existing leak, found in review. A wrong access-key secret makes green-cip answer SignatureDoesNotMatch quoting the whole StringToSign, which embeds our percent-encoded ServiceParameters — the caller's prompt — plus the AccessKey id. RPC v1 signature errors echo the canonicalized request by design. The 4xx path (added in #773) logged that body verbatim, so one fat-fingered credential wrote every moderated prompt into the gateway log.

Now only the error Code is logged (SignatureDoesNotMatch, InvalidAccessKeyId.NotFound, InvalidAction.NotFound). It is a symbolic error class from a closed vocabulary, so it structurally cannot carry request content, and it still answers what the body log was for: telling a wrong key from a wrong region/endpoint. The body is read (capped) to extract the code, never logged; the code arrives as JSON from the RPC layer and XML from the endpoint layer, so both shapes are handled, with nothing emitted for an unrecognized body.

Extracting the code needs its own read budget, separate from the crate's 2 KiB log-snippet cap — a snippet can stop anywhere, a parse cannot. Code is the last member of the error object, sitting after the StringToSign echo, so the bigger the caller's prompt the further out it lands. Measured live: a 1 980-char Chinese prompt gives a 30 457-byte body with Code at offset 30 426 (the content is percent-encoded twice inbound, ~15 bytes per source char). At 2 KiB the JSON truncated mid-Message and aliyun_code came back empty — i.e. the most common misconfiguration would have reported no diagnosis. The parse budget is 64 KiB, covering the 2 000-char content cap at 4 bytes per char.

What an operator now sees

```text
INFO request{request_id=12179fb5-5c3c-4a2a-a411-d73746379cc6}: aisix_guardrails::aliyun:
aliyun text moderation blocked content row=aliyun-live-guard service="llm_query_moderation"
aliyun_request_id=019F6EF3-6F9A-5A25-9EED-256CB0E26448 aliyun_code=200
aliyun_risk_level=high aliyun_labels=inappropriate_oral,inappropriate_profanity,violent_incidents
```

A block logs at info (the default level); a clean pass logs the same fields at debug, so a per-request line is not spent by default.

Tests

  • Unit: 2xx block (incl. the no-leak assertion against a real body), 2xx clean pass, timeout, malformed JSON, 5xx, 4xx, and a header-less business error.
  • Unit: the SignatureDoesNotMatch body from a live response, asserting the canary prompt / AccessKey id / StringToSign never reach a log; plus extract_error_code against both live error shapes.
  • E2E (guardrail-aliyun-request-id-e2e.test.ts): asserts one log line carries both ids — two lines each holding one would not let an operator join them — across the non-streaming path, chat SSE, and /v1/messages streamed output (a second, structurally different generator).
  • The live smoke test (--ignored) now also asserts the real endpoint yields the diagnostics we log.

Baseline

LiteLLM has no Aliyun guardrail, so there is no direct baseline. Against its general moderation guardrails, all three decisions align with or exceed it: its Bedrock hook redacts matched spans while keeping labels (redact_nested_match_and_regex_keys, "only non-sensitive labels + scores (no offsets / raw input)"); it correlates guardrail events via a request-root span; and it does not expose guardrail providers' request ids to callers.

Docs: paired PR against api7/docs.

Fixes api7/AISIX-Cloud#1060
Fixes api7/AISIX-Cloud#1092

Summary by CodeRabbit

  • Improvements
    • Aliyun moderation logs now include request IDs, moderation codes, risk levels, and labels across successful and failed requests.
    • Malformed moderation responses are reported distinctly from upstream server errors.
    • Request correlation is preserved for streaming responses and realtime connections, improving troubleshooting.
    • Terminal logs now automatically enable colors only when supported.
  • Privacy
    • Sensitive provider content, including matched risk words and positions, is excluded from logs and streaming responses.

…it to the gateway request

The `aliyun_text_moderation` guardrail dropped everything Aliyun reported
about a moderation call: `AliyunResponse` never deserialized `RequestId`,
so a caller's 422 `content_filter` could not be traced to a record in the
Aliyun console.

Capture and log the upstream diagnostics — `RequestId`, `Code`, `Message`,
`RiskLevel` and the matched `Label`s — on every path, and make them joinable
to the caller's `x-aisix-request-id`.

## Where the RequestId comes from

Read `x-acs-request-id` off the response headers, falling back to the body's
`RequestId`. Verified against the live green-cip endpoint: the header is
present on 2xx, on a JSON business error, and on 400/404 responses whose body
is XML — i.e. it survives exactly the cases where the body is unreachable.
The body alone would not: on an HTTP error Aliyun types `Code` as a symbolic
string (`InvalidAccessKeyId.NotFound`) rather than the int it uses on HTTP
200, so the documented shape does not deserialize there.

Timeouts and connection failures produce no id at all; those log an empty
`aliyun_request_id` plus the failure bucket, so "Aliyun never answered" is
distinguishable from "we forgot to log it". An HTTP 200 carrying a non-JSON
body now reports `MalformedResponse` instead of being bucketed as
`ServerError` — the two want different fixes.

## Correlation

Nothing in the data plane put a `request_id` on a log line: `ensure_request_id`
minted the id for the header and telemetry, but no span carried it, so all 38
guardrail log sites across all six providers were uncorrelated. The middleware
now opens a `request{request_id=…}` span, which every guardrail inherits.

Streamed response bodies are polled after the middleware returns, so the four
SSE generators re-attach the span to the body stream, and the realtime session
attaches it to the future axum spawns.

## Log searchability

The fmt subscriber colorized unconditionally. On a piped stderr — every real
deployment — the escapes land between a field's name and its value, so
`grep 'aliyun_request_id=<id>'` matched nothing. Colorize only for a terminal.

## Not exposed to callers

Aliyun's `RequestId` stays out of the response envelope. Callers get
`x-aisix-request-id` and quote it; operators resolve the rest from the log.
This keeps the moderation vendor undisclosed and adds no wire contract.

## Content safety

A real `high` response echoes the offending text back in `RiskWords` and
`RiskPositions` (`"RiskWords": "傻逼,弄死你,死你全家"`). Only `Label` is
deserialized, so per #153 there is nowhere for matched content to leak;
the tests assert it against a real response body.

Tests: unit coverage for 2xx block/pass, 4xx, 5xx, timeout, malformed JSON
and a header-less business error; an E2E asserting one log line carries both
ids, for the non-streaming and streamed-output paths.

Fixes api7/AISIX-Cloud#1060
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Aliyun moderation now records sanitized upstream diagnostics across success and failure paths, including request IDs, business codes, risk levels, and labels. Proxy request spans are propagated through streaming responses and detached WebSocket tasks so guardrail logs retain gateway request correlation.

Changes

Aliyun diagnostics

Layer / File(s) Summary
Aliyun response contract and diagnostics
crates/aisix-guardrails/src/aliyun.rs
The Aliyun call contract returns diagnostics alongside moderation outcomes, distinguishes malformed responses, and extracts safe request, code, risk, message, and label fields.
Aliyun execution and structured logging
crates/aisix-guardrails/src/aliyun.rs
HTTP headers, status codes, timeouts, body parsing, and business codes are handled while moderation logs diagnostic fields once per request.
Diagnostics path coverage
crates/aisix-guardrails/src/aliyun.rs
Tests cover risky, clean, timeout, malformed, HTTP-error, and business-error responses, including sensitive-field omission and live diagnostic assertions.

Request-span correlation

Layer / File(s) Summary
Request span and stream adapter
crates/aisix-proxy/src/request_id.rs, crates/aisix-proxy/AGENTS.md
Request middleware instruments handler futures, and in_request_span re-enters the captured span for each stream poll.
Streaming and upgrade propagation
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/messages.rs, crates/aisix-proxy/src/realtime.rs, crates/aisix-proxy/src/responses.rs, crates/aisix-proxy/src/responses_bridge.rs
SSE, Anthropic, response bridge, and WebSocket paths retain request context during deferred processing.
Log formatting and correlation E2E coverage
crates/aisix-obs/src/lib.rs, tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts
ANSI output is terminal-aware, and E2E tests verify gateway and Aliyun request IDs are logged together without sensitive content leakage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Proxy
  participant AliyunGuardrail
  participant AliyunAPI
  participant Logs
  Client->>Proxy: send moderation request
  Proxy->>AliyunGuardrail: invoke input or output guardrail
  AliyunGuardrail->>AliyunAPI: submit moderation request
  AliyunAPI-->>AliyunGuardrail: response headers and body
  AliyunGuardrail->>Logs: record Aliyun and gateway request IDs
  Proxy-->>Client: filtered response or content_filter error
Loading
sequenceDiagram
  participant RequestMiddleware
  participant StreamingHandler
  participant StreamAdapter
  participant GuardrailLogger
  RequestMiddleware->>StreamingHandler: instrument handler with request span
  StreamingHandler->>StreamAdapter: wrap deferred response stream
  StreamAdapter->>GuardrailLogger: poll stream within request span
  GuardrailLogger-->>StreamAdapter: emit correlated end-of-stream diagnostics
Loading

Possibly related issues

  • api7/AISIX-Cloud#1060 — The PR implements Aliyun RequestId, diagnostic logging, error-path coverage, and sensitive-data redaction.

Possibly related PRs

  • api7/aisix#491 — Both modify end-of-stream output-guardrail handling in the Anthropic streaming pipeline.
  • api7/aisix#506 — Both directly modify the Aliyun text moderation guardrail implementation.
  • api7/aisix#773 — Both update Aliyun guardrail error-response handling and diagnostics logging.

Suggested reviewers: moonming


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error crates/aisix-guardrails/src/aliyun.rs:292-297 logs raw response_body for non-2xx Aliyun errors, which can expose echoed content or auth material. Parse and log only allowlisted fields (x-acs-request-id, Code, Message) or an opaque status bucket; never log raw upstream bodies.
E2e Test Quality Review ⚠️ Warning FAIL: the new E2E suite only covers chat/completions, leaves the other rewritten SSE/WS paths untested, and the streamed case depends on the first test’s config warmup. Add an independent readiness probe for the streamed test (or move it to beforeAll), add E2Es for messages/responses/realtime, and assert the stream does not leak Aliyun IDs.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes capture Aliyun RequestId, log code/message/risk labels, preserve content_filter behavior, and add unit/E2E coverage for the requested cases.
Out of Scope Changes check ✅ Passed The added span propagation, tracing, and log-color tweaks support the same observability goal and do not appear unrelated to the issue.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preserving Aliyun RequestId and correlating it with the gateway request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aliyun-upstream-diagnostics

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: 6

Caution

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

⚠️ Outside diff range comments (1)
crates/aisix-guardrails/src/aliyun.rs (1)

256-269: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Apply the timeout across response decoding. tokio::time::timeout only wraps send(), so resp.json().await still runs outside the deadline and can hang after headers arrive. Keep the timeout classification for body-read failures too.

🤖 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-guardrails/src/aliyun.rs` around lines 256 - 269, The timeout
currently covers only the HTTP send operation; update the request flow around
the response handling so response body decoding via resp.json().await also runs
within self.timeout. Preserve the existing AliyunFailure::Timeout classification
for deadlines and classify body-read failures consistently with the current I/O
error path, while retaining diagnostics behavior.

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-guardrails/src/aliyun.rs`:
- Around line 277-300: Update the non-success response handling around the
status classification and the corresponding 1167-1216 flow to read the capped
body before branching on 429, 5xx, or other errors. Best-effort parse supported
JSON/XML envelopes and retain only RequestId, Code, and Message, with
authentication material redacted; log these structured allowlisted fields
instead of raw response_body, while preserving the existing AliyunFailure
classification and diagnostic returns.
- Around line 303-316: Update the successful `body.code == 200` handling in the
Aliyun response parsing flow to require a present, non-null, recognized
`RiskLevel` value of none, low, medium, or high; return
`AliyunFailure::MalformedResponse` for missing or unknown levels instead of
defaulting to clean. Apply the same validation in the additional response path
around the referenced logic, and add tests covering missing, null, and unknown
risk levels.

In `@crates/aisix-proxy/src/request_id.rs`:
- Around line 38-50: Update the response-header handling in the request ID
middleware around the extension ID and tracing span setup so the caller-facing
x-aisix-request-id always equals id. Replace the behavior that preserves a
handler-set differing header with unconditional stamping of the extension ID, or
validate and reject mismatches before preserving a header; keep the tracing span
bound to that same ID.

In `@tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts`:
- Around line 310-348: Expand the request-correlation E2E coverage beyond the
existing chat SSE test to include Messages normalized and passthrough streams,
Responses passthrough and bridge streams, and realtime WebSocket sessions. For
each endpoint, verify the gateway request ID and the corresponding Aliyun
request ID are joined in emitted logs, preserving the existing skip/setup
behavior and using the appropriate streaming or session interaction.
- Around line 310-327: Make the streamed-output test self-contained by waiting
for configuration propagation before issuing its fetch request. Add the same
readiness probe used by the preceding input test within the streaming test, or
move that probe into the shared beforeAll setup, while preserving the existing
skip behavior and request assertions.
- Around line 330-335: Update the streamed output assertions following the
response body read in the e2e test to also verify that wire does not contain
gatewayRequestId, while preserving the existing error-event and RISK_WORDS
assertions.

---

Outside diff comments:
In `@crates/aisix-guardrails/src/aliyun.rs`:
- Around line 256-269: The timeout currently covers only the HTTP send
operation; update the request flow around the response handling so response body
decoding via resp.json().await also runs within self.timeout. Preserve the
existing AliyunFailure::Timeout classification for deadlines and classify
body-read failures consistently with the current I/O error path, while retaining
diagnostics 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: 8518d23a-27d3-4697-be92-d3ce27c3c2e9

📥 Commits

Reviewing files that changed from the base of the PR and between 0a2a8b5 and 7afa1fb.

📒 Files selected for processing (10)
  • crates/aisix-guardrails/src/aliyun.rs
  • crates/aisix-obs/src/lib.rs
  • crates/aisix-proxy/AGENTS.md
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/messages.rs
  • crates/aisix-proxy/src/realtime.rs
  • crates/aisix-proxy/src/request_id.rs
  • crates/aisix-proxy/src/responses.rs
  • crates/aisix-proxy/src/responses_bridge.rs
  • tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts

Comment thread crates/aisix-guardrails/src/aliyun.rs
Comment thread crates/aisix-guardrails/src/aliyun.rs
Comment thread crates/aisix-proxy/src/request_id.rs
Comment thread tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts
Comment thread tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts
Comment thread tests/e2e/src/cases/guardrail-aliyun-request-id-e2e.test.ts
…ler's prompt back

A wrong access-key SECRET makes green-cip answer `SignatureDoesNotMatch` with
the whole StringToSign in `Message` — and the StringToSign embeds our
percent-encoded `ServiceParameters`, i.e. the caller's prompt, alongside the
AccessKey id. The 4xx path logged that body verbatim, so one fat-fingered
credential wrote every moderated prompt into the gateway log.

Verified against the live endpoint: a real AccessKey id with a wrong secret
returns

  "Message":"Specified signature is not matched with our calculation. server
  string to sign is:POST&%2F&AccessKeyId%3DLTAI…%26ServiceParameters%3D%257B
  %2522content%2522%253A%2520%2522CANARY_USER_PROMPT…"

Log only the error `Code` (`SignatureDoesNotMatch`, `InvalidAccessKeyId.NotFound`,
`InvalidAction.NotFound`). It is a symbolic error class from a closed vocabulary,
so it structurally cannot carry request content, and it still answers what #773
added the body log for: telling a wrong key from a wrong region/endpoint. The
body is still read (capped) to extract the code, never logged.

The code arrives in two shapes — JSON from the RPC layer, XML from the endpoint
layer — so `extract_error_code` handles both, and yields nothing rather than a
guess for an unrecognized body.

Reported by CodeRabbit on #781. Its suggested fix (allowlist RequestId + Code +
Message) would not have closed this: the leak is inside `Message`.

Also from that review:
- e2e: each case now waits for config propagation in `beforeAll` instead of
  inheriting the first test's wait, so any one runs alone.
- e2e: assert the streamed 422 doesn't carry Aliyun's RequestId either.
- e2e: cover /v1/messages streamed output — a second, structurally different
  generator, since a missing `in_request_span` fails silently per call site.
… the Code

The 4xx path reused the crate's log-snippet cap (2 KiB) to read a body it now
PARSES rather than logs, and those want different budgets: a snippet can stop
anywhere, a parse cannot.

`Code` is the last member of the error object, after the `Message` that quotes
the StringToSign — so the bigger the caller's prompt, the further out it sits.
Measured against the live endpoint: a 1 980-char Chinese prompt yields a
30 457-byte body with `Code` at offset 30 426 (the content is percent-encoded
twice on the way in, ~15 bytes per source char). At 2 KiB the JSON truncated
mid-`Message`, parsed as nothing, and `aliyun_code` came back empty — so the
single most common misconfiguration, a wrong access-key secret, reported no
diagnosis at all. The earlier fix only looked right because its test prompt was
28 chars.

Give the parse its own 64 KiB budget, which covers the dispatcher's 2 000-char
content cap even at 4 bytes per char. The body is still never logged; it is held
transiently to pull one symbolic token out.

Test: an error body big enough to carry a real prompt, with `Code` last as the
live endpoint places it. It fails at the old cap.
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