Skip to content

feat(obs): pluggable observability-sink framework + Aliyun SLS exporter - #528

Merged
moonming merged 8 commits into
mainfrom
feat/obs-sink-framework
Jun 6, 2026
Merged

feat(obs): pluggable observability-sink framework + Aliyun SLS exporter#528
moonming merged 8 commits into
mainfrom
feat/obs-sink-framework

Conversation

@moonming

@moonming moonming commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Introduces a pluggable observability-sink framework and lands its first real vendor — Aliyun SLS — so the DP can ship per-request telemetry (and, later, full prompt/response) to customer log/analytics backends without a bespoke connector per vendor.

Driven by the pre-sales requirement on api7/AISIX-Cloud#687 (customer wants AI Gateway logs in Aliyun SLS) and the generic-integration design in api7/AISIX-Cloud#692.

Framework (one implementation per family, not per vendor)

  • ObservabilitySink trait + SinkCapabilities matrix (idempotency / ordering / batch-unit / partial-batch / streaming) so the shared pipeline adapts to a sink's wire contract as data, not a fork.
  • SinkPipeline — bounded mpsc → batch → retry/backoff → drop-with-metric, one per exporter.
  • ExporterPipelines manager — lazy get_or_create (immediate consistency) + retain GC.
  • The existing OTLP/HTTP fan-out is migrated onto this pipeline (OtlpSink), behaviour-preserving (the usage-client-source e2e is the regression gate).

Aliyun SLS exporter

  • AliyunSlsSink (http_batch family): one protobuf LogGroup per batch → lz4 block compression → SLS signature v1 (HMAC-SHA1)POST /logstores/<ls>/shards/lb. Built on the official aliyun-log-sdk-protobuf / aliyun-log-sdk-sign sub-crates over the existing rustls reqwest — no OpenSSL / native-tls is pulled in.
  • ExporterKind::AliyunSls carries a credential_ref, never the AccessKey — the plaintext key never rides the kine path. The DP resolves the ref from its local env (SLS_CRED_<REF>_AK_{ID,SECRET}, deliberately not the AISIX_ prefix the config loader claims). BYOK variants slot into the same resolution point later.
  • fan_out generalized to dispatch per kind over one shared ExporterPipelines + client; all 7 proxy call sites unchanged.

Key design decisions

  • No plaintext credentials in etcdcredential_ref + local env resolution; rejects_plaintext_credentials_in_config pins it at the serde + schema layers.
  • Field mapping serializes UsageEvent (single-sources the schema + its skip_serializing_if rules) rather than hand-listing 35 fields; opt-in prompt/response land as flat fields, absent on the default metadata-only path so prompts cannot leak.
  • Error classification mirrors OtlpSink (5xx/408/429 → transient) plus SLS errorCode-aware promotion, so a 4xx quota/busy/clock-skew is retried, not dropped (a log sink must not drop under throttling).
  • Endpoint handling: a bare region host → https://<project>.<host>; a scheme-qualified loopback (the e2e mock) is used verbatim.

Test plan

  • Unit: aisix-core 204 passed, aisix-obs 75 passed (sink encode/sign/lz4 round-trip decode, error classification incl. quota→transient, credential resolver, schema validators). clippy -D warnings clean; schemas/ regenerated (schema-drift gate).
  • L2 mock e2e (aliyun-sls-exporter-e2e): a dashboard-configured aliyun_sls exporter makes the real aisix binary post a signed lz4-protobuf PutLogs to a mock SLS receiver on a real chat — asserts logstore path, SLS headers, lz4 framing, and that Authorization carries the env-resolved AccessKey id (secret never on the wire). ✅ passing.
  • OTLP regression gate (usage-client-source-e2e): ✅ 2/2 — confirms the per-kind dispatch generalization preserved the OTLP path.
  • Real-SLS smoke (sls_smoke, #[ignore] + env-gated): validates the v1 signature / lz4 / protobuf against the live SLS endpoint (the thing a mock cannot check). Runs in the new sls-smoke CI job with the SLS secrets injected; fork PRs skip cleanly. PutLogs→2xx is the hard assertion (Permanent error fails fast, Transient retries); GetLogs readback is best-effort with diagnostics pending its first real run.

Deliberately out of scope (follow-ups)

  • Content capture (full prompt/response into SLS — the customer's primary ask): the sink already emits content when a record carries it; the hot-path capture + size-cap + per-exporter gating is the next milestone. Current fan-out is metadata-only (parity with OTLP).
  • CP-side aliyun_sls support (AISIX-Cloud / Go) — the DP accepts the kind via admin API + kine now; the dashboard/CP flow is separate per feat(routing): sticky weighted routing (A/B / canary) #687 ("DP first, then CP").
  • Type rename OtlpHttpFanOut → ExporterFanOut and wiring the fan-out GC/shutdown loop — mechanical follow-ups kept out to avoid churn.
  • 4 pre-existing tsc --noEmit errors in unrelated e2e files tracked in e2e: pre-existing tsc --noEmit errors in cache-scenarios / request-id-uuid tests #527 (not touched here; my additions typecheck clean).

Refs: api7/AISIX-Cloud#687, api7/AISIX-Cloud#692

Summary by CodeRabbit

  • New Features

    • Added Aliyun SLS as an observability exporter backend with secure credential references and pluggable sink support.
    • Introduced resilient delivery pipelines with batching, retry, backpressure, and per-sink health/stats.
  • Tests

    • Added CI smoke test and end-to-end test validating Aliyun SLS export behavior and headers.
  • Chores

    • Updated CI to run the new SLS verification.

moonming added 7 commits June 5, 2026 15:48
Introduce the capability-typed `ObservabilitySink` adapter contract in
aisix-obs (AISIX-Cloud#692, phase F1) that the shared delivery pipeline
and concrete sinks (Aliyun SLS first) build on:

- `ObservabilitySink` trait (#[async_trait], object-safe `Arc<dyn>`):
  encode-and-deliver one batch, plus committed-marker resume (default
  `None` for at-least-once sinks) and a health probe.
- `SinkCapabilities` + `IdempotencyScheme`/`IdempotencyMarker` +
  `ChannelKey` + `OrderingScope`/`BatchUnit` — the capability matrix
  that makes HTTP / object-store / warehouse differences data, not a
  fork in the pipeline.
- `SinkRecord` wraps the existing `UsageEvent` metadata (single-sourced)
  plus opt-in `SinkContent`; metadata-only is the default so the path
  cannot carry a prompt. `EventBatch` Arc-shares records for fan-out.
- `SinkError` (Transient/Permanent), `SinkAck`, `SinkHealth`.

No wiring into the request hot path yet (phase F2). Unit tests cover the
types and dyn-compatibility; existing otlp_http_sink tests unaffected.
Generalise the aisix-server telemetry worker (bounded mpsc -> batch ->
flush) into a reusable `SinkPipeline` that every sink runs behind
(AISIX-Cloud#692, phase F2), and add what telemetry deliberately
skipped:

- retry with exponential backoff for `SinkError::Transient` batches,
  capped at `max_backoff`;
- drop-with-metric backpressure — queue-full, retry-exhausted, and
  permanent failures are all counted (`SinkStats`) and logged, not
  silently lost like the per-event otlp fan-out;
- one pipeline per sink (independent queue + stats) so a slow/down sink
  can't stall another or the request hot path;
- graceful shutdown drain.

`SinkHandle::try_enqueue` is non-blocking and drops on a full bounded
queue. Flow control lives in the pipeline; wire encoding and per-request
byte chunking stay in the sink. Not wired into the request path yet
(phase F3). 8 unit tests cover batching, retry, drop accounting,
age-flush and backoff; workspace compile and existing tests unaffected.
Make OTLP/HTTP-JSON traces a first-class `ObservabilitySink` so it can run
behind the shared `SinkPipeline` (AISIX-Cloud#692, phase F3a):

- split the span encoder into `build_otlp_span` (one span) +
  `otlp_export_request` (wrap N spans into one ExportTraceServiceRequest);
  `build_otlp_traces_payload` stays a thin wrapper with byte-identical
  output, so the existing fan-out and its tests are untouched.
- add `OtlpSink`: batches every record's span into a single export request
  and one POST (vs the fan-out's N per-event spawns), and classifies
  failures — 5xx/408/429 and network errors are Transient (retryable),
  other 4xx are Permanent.

No hot-path rewiring yet; the existing `OtlpHttpFanOut` is unchanged and
still in use (the per-exporter pipeline manager + call-site migration is
phase F3b). 3 new tests (batched single request, 5xx->transient,
4xx->permanent); the 8 existing otlp tests pass as the regression gate.
Add `ExporterPipelines` (AISIX-Cloud#692, phase F3b) — the shared,
sink-agnostic manager that turns the snapshot's exporter set into running
`SinkPipeline`s. Reused by every pipeline-backed sink family (otlp,
http_batch, ...): the caller supplies a `build` closure mapping a desired
exporter spec to an `ObservabilitySink`.

- `reconcile(desired, key, fingerprint, build)` starts a pipeline per new
  exporter, stops pipelines whose key disappeared (cancel -> drain -> exit,
  not aborted), and rebuilds those whose fingerprint changed. `build` runs
  only for new/changed exporters, so unchanged pipelines keep running.
- `enqueue_to_all(&record)` fans an Arc-shared record into every pipeline,
  non-blocking on the request hot path (a full queue drops + counts there).
- `stats()` exposes per-exporter delivery counters; `shutdown()` drains all.

Self-contained and unit-tested (start/stop/rebuild/idempotent reconcile +
fan-out delivery); not wired into the request path yet (phase F3b-2).
Wire the otlp_http exporter path onto the framework (AISIX-Cloud#692,
phase F3b): `OtlpHttpFanOut::fan_out` now resolves a per-exporter
`SinkPipeline` via the manager and enqueues one span record into it,
instead of spawning a fire-and-forget POST per (event, exporter). The
`fan_out` signature is unchanged, so all 8 proxy hot-path call sites stay
untouched.

- Reshape `ExporterPipelines` from a reconcile-from-loop model to lazy
  `get_or_create` + `retain`. Lazy-on-first-sighting (like the old permit
  map) is immediately consistent with the snapshot — a just-added
  exporter receives the very next request — which a reconcile poll would
  race (and flake the otlp e2e).
- Delivery now batches (1s flush), retries transient failures with
  backoff, and drops-with-metric under backpressure (the pipeline's
  bounded queue), replacing the per-exporter Semaphore(64) cap. Removed
  the obsolete MAX_INFLIGHT / permits_for / in_flight_for / post_one and
  the two #113 semaphore tests; added one that asserts the new path
  delivers a span to a real receiver.

Validated: clippy -D warnings clean, 64 unit tests, and the
usage-client-source real-chain e2e (register exporter -> real chat ->
assert span at the receiver) passes against the built DP + a real etcd.

Follow-up (not blocking): wire the server GC loop (gc/retain for removed
exporters) + shutdown drain; otlp is best-effort and the exporter set is
bounded, matching the prior never-pruned behavior.
Add `aliyun_sls` as the second observability-exporter kind, delivering
request events to Aliyun SLS via a signed PutLogs call.

- AliyunSlsSink (http_batch family): one LogGroup per batch, lz4 block
  compression, SLS signature v1 (HMAC-SHA1), POST to
  /logstores/<ls>/shards/lb. Uses the official aliyun-log-sdk-protobuf /
  aliyun-log-sdk-sign sub-crates over the existing rustls reqwest, so no
  OpenSSL / native-tls is pulled in.
- Field mapping serializes UsageEvent (single-sources the schema and its
  skip_serializing_if rules); opt-in prompt/response land as flat fields,
  absent on the default metadata-only path so prompts cannot leak.
- Error classification mirrors OtlpSink (5xx/408/429 -> transient) plus
  SLS errorCode-aware promotion, so a 4xx quota/busy/skew is retried
  rather than dropped.
- ExporterKind::AliyunSls carries a credential_ref, never the AccessKey:
  the plaintext key never rides the kine path. The DP resolves the ref
  from its local env (SLS_CRED_<REF>_AK_{ID,SECRET}; deliberately not the
  AISIX_ prefix, which the config loader claims via deny_unknown_fields).
- fan_out generalized to dispatch per kind over one shared
  ExporterPipelines + client (the #692 design); call sites unchanged.
  The OtlpHttpFanOut -> ExporterFanOut rename is deferred as a mechanical
  follow-up.
- Adds an env-gated, #[ignore] real-SLS smoke test that validates the
  signature / lz4 / protobuf against the live endpoint.

Refs: api7/AISIX-Cloud#687, api7/AISIX-Cloud#692
- L2 e2e: a dashboard-configured aliyun_sls exporter makes the real DP
  post a signed lz4-protobuf PutLogs to a mock SLS receiver on each chat.
  Asserts the logstore path, SLS headers, lz4 framing, and that the
  Authorization carries the env-resolved AccessKey id (proving the
  credential_ref -> env path), with the secret never on the wire. Body
  field-mapping is covered by the Rust round-trip unit test; real-endpoint
  acceptance by the smoke.
- harness: AppOverrides.extraEnv injects non-config secrets (the SLS
  AccessKey) past the AISIX_* strip.
- ci: a sls-smoke job runs the #[ignore] real-SLS smoke with the SLS
  secrets injected as env; fork PRs (no secrets) skip cleanly via the
  test's env-gate.

Refs: api7/AISIX-Cloud#687
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 2d18fb0c-b21b-43be-b10a-b7e7fb44165d

📥 Commits

Reviewing files that changed from the base of the PR and between 55b8286 and a8b40ae.

📒 Files selected for processing (1)
  • crates/aisix-obs/src/sink/sls.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/aisix-obs/src/sink/sls.rs

📝 Walkthrough

Walkthrough

Adds a pluggable observability-sink framework, refactors OTLP delivery into pipeline-backed batching/retry, and introduces an Aliyun SLS sink with credential-ref resolution, signing, lz4+protobuf encoding, plus schema, tests, and CI/e2e integrations.

Changes

Pluggable Observability Sink Platform with Aliyun SLS Backend

Layer / File(s) Summary
Configuration and schema for exporter kinds
crates/aisix-core/src/models/observability_exporter.rs, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/models/mod.rs, schemas/resources/observability_exporter.schema.json
ExporterKind::AliyunSls variant added; AliyunSlsConfig struct captures endpoint, project, logstore, and credential_ref (no plaintext credentials). JSON schema enforces per-kind required fields, endpoint validation, and rejects plaintext credential keys.
Sink adapter interface and core types
crates/aisix-obs/src/sink/capabilities.rs, crates/aisix-obs/src/sink/mod.rs, crates/aisix-obs/src/sink/record.rs
ObservabilitySink trait unifies sink contract: name, capabilities, batch delivery with idempotency markers, startup resume, healthcheck. SinkError (transient/permanent), SinkAck (delivery result with retry index), SinkRecord (payload with optional content), EventBatch (batch container). SinkCapabilities describes idempotency, ordering, batch limits, and streaming behavior.
Pipeline queue, batching, and retry orchestration
crates/aisix-obs/src/sink/pipeline.rs, crates/aisix-obs/src/sink/manager.rs
SinkPipeline worker drains bounded MPSC queue, buffers into batches, flushes by count/timer/shutdown, and retries transient failures with capped exponential backoff. SinkHandle provides non-blocking enqueue with drop counting. ExporterPipelines manager lazy-creates per-exporter pipelines on fingerprint, rebuilds on config change, and stops obsolete pipelines via retain.
OTLP HTTP sink refactored to pipeline framework
crates/aisix-obs/src/otlp_http_sink.rs
OtlpHttpFanOut moved from fire-and-forget per-event model to pipeline-based fan-out via ExporterPipelines. New OtlpSink implements ObservabilitySink, batching spans into single OTLP/HTTP-JSON ExportTraceServiceRequest. Centralized span encoding (build_otlp_span, otlp_export_request). HTTP status classification: 5xx/429/timeout → transient; other non-2xx → permanent. Added gc and shutdown lifecycle controls.
Aliyun SLS sink implementation
crates/aisix-obs/src/sink/sls.rs
AliyunSlsSink implements ObservabilitySink for Aliyun Simple Log Service. Maps SinkRecords to protobuf Logs, builds LogGroup, lz4-compresses, signs, and POSTs to /logstores/<logstore>/shards/lb. Credential resolution from SLS_CRED_<slug>_AK_ID/SECRET env vars. Error classification: 4xx quota/throttle, 5xx → transient; others → permanent. Includes request validation, error parsing, and ignored smoke test with real PutLogs/GetLogs readback.
Workspace dependencies and module exports
Cargo.toml, crates/aisix-obs/Cargo.toml, crates/aisix-obs/src/lib.rs
Added Aliyun SLS crates (aliyun-log-sdk-protobuf, aliyun-log-sdk-sign, lz4_flex), async-trait, and http. aisix-obs re-exports sink module and types; aisix-core re-exports AliyunSlsConfig.
E2E tests and CI integration
.github/workflows/ci.yml, tests/e2e/src/cases/aliyun-sls-exporter-e2e.test.ts, tests/e2e/src/harness/app.ts
CI adds sls-smoke job to run real Aliyun SLS PutLogs test with injected credentials. E2E test spins up mock SLS HTTP receiver, configures aliyun_sls exporter, drives chat, and asserts PutLogs call shape/headers (protobuf content-type, lz4 compression, auth prefix). Test harness gains extraEnv support for non-config secrets.

🎯 4 (Complex) | ⏱️ ~75 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

Independent audit of #528 (finding M1): SinkCapabilities.max_batch_bytes
is advertised but enforced nowhere — SinkPipeline batches by record
count, and AliyunSlsSink encodes the whole batch into one PutLogs.
Harmless on today's metadata-only path, but a silent Permanent-drop once
content capture makes a batch exceed SLS's PutLogs body limit.

Downgrade the capability to its honest current state (batch_unit:
Records, max_batch_bytes: None — parity with OtlpSink) so it no longer
promises an unenforced ceiling. Byte-aware chunking (the real fix, in
append_batch per the pipeline's sink-self-chunks design) is filed as
\#529 and blocks the content-capture milestone.
@moonming

moonming commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Independent audit (CLAUDE.md §7) — results + dispositions

A fresh general-purpose agent reviewed this PR cold (no shared context), verifying the wire/secret/regression claims against the actual Aliyun SLS SDK source, not just the description. No HIGH findings. Wire-signing order, lz4-block choice, credential_ref isolation, secret hygiene, and OTLP regression-safety all verified at source level; unit tests + clippy -D warnings green.

MEDIUM

  • M1 — max_batch_bytes advertised but enforced nowhere → resolved in code (a8b40ae). The capability claimed a 3 MB ceiling, but the pipeline batches by record count and the sink encodes the whole batch into one PutLogs. Harmless on today's metadata-only path (~100 × ~1 KB), but a silent Permanent-drop once content capture makes a batch exceed SLS's body limit. Fixed by downgrading the SLS capability to its honest current state (batch_unit: Records, max_batch_bytes: None — parity with OtlpSink). The real fix — byte-aware chunking in append_batch (per the pipeline's sink-self-chunks design) + reconciling the contradictory capability docs — is filed as obs sink: byte-aware chunking for SLS PutLogs (blocks content capture) #529 and blocks the content-capture milestone.

LOW (non-blocking)

  • L1 — is_transient_error_code omits a RequestTimeout body code. Accepted, no change: an HTTP 408 is already promoted by the status arm, and a body errorCode:"RequestTimeout" on a non-408 status is unattested in the SLS error reference — adding an unverified code conflicts with our research-discipline rule. Will revisit if a real run surfaces it.
  • L2 — description says "7 proxy call sites", there are actually 8 (completions, chat, messages, responses, embeddings, rerank, audio, images). All 8 are identical in shape and unchanged (not in the diff); fan_out's signature is preserved. Cosmetic doc inaccuracy, corrected here.
  • L3 — SLS log __time__ is ingest-now(), not occurred_at. Accepted, no change: this is the defensively-correct choice (avoids SLS time-skew rejection on delayed retries); the real event time survives as the occurred_at content field. The auditor concurred it's intentional, not a bug.

Bottom line from the audit: safe to merge after M1, which is now addressed. Remaining items are LOW and justified above.

@moonming
moonming merged commit 881c2e2 into main Jun 6, 2026
9 checks passed
moonming added a commit that referenced this pull request Jun 8, 2026
The real-credential Aliyun SLS validation moved to the control plane as a
full-chain e2e (cp-api config → live DP → real logstore readback,
api7/AISIX-Cloud#709). Removes the now-dead mod smoke (sls_smoke_putlogs_and_readback
+ helpers) and its CI job; the mock-SLS L2 e2e remains the DP-side sink coverage.

Refs #528, #532, api7/AISIX-Cloud#709.
@jarvis9443
jarvis9443 deleted the feat/obs-sink-framework branch June 25, 2026 06:25
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