Skip to content

feat(obs): Langfuse exporter wired into chat completions - #21

Merged
moonming merged 1 commit into
mainfrom
feat/langfuse-exporter
Apr 20, 2026
Merged

feat(obs): Langfuse exporter wired into chat completions#21
moonming merged 1 commit into
mainfrom
feat/langfuse-exporter

Conversation

@moonming

Copy link
Copy Markdown
Member

Summary

New `aisix-obs::langfuse` module that pushes per-chat-completion
generation events to a Langfuse `/api/public/ingestion` endpoint.

There is no first-party Rust SDK for Langfuse, so this is a hand-rolled
HTTP client that:

  • Accepts events on a bounded mpsc channel (4096 cap) — emit is
    non-blocking, drops on overflow.
  • Drains the channel into batches of 50 (or every 1s, whichever fires
    first).
  • POSTs each batch with HTTP basic auth (public_key:secret_key) to
    `{host}/api/public/ingestion`.
  • Logs all upstream errors at WARN; never blocks the request hot path.

Wiring

  • Bootstrap (`aisix-server`): `langfuse::spawn(&cfg.observability)`
    returns `Ok(None)` when disabled, an opaque `LangfuseHandle` when
    enabled. The handle is held for the lifetime of the process.
  • `ProxyState` gains `langfuse: Option<Arc>` plus a
    `with_langfuse(...)` builder.
  • `chat::chat_completions` emits one event per request, success or
    failure — no behavior change when langfuse is `None`.

Tests (20 new)

  • Disabled config returns `None`
  • Enabled-without-host errors with `MissingHost`
  • Enabled-without-key-env errors with the specific env var name
  • Round-trip: wiremock upstream, emit one event, wait for the 1s
    flush, assert exactly one POST received
  • ISO timestamp round-trip including unix epoch + leap year
  • Base64 basic auth encoding matches the expected `pk:sk -> cGs6c2s=`
  • Channel-full does not block the emitter

Test plan

  • `cargo test --workspace` — 395 tests pass (+20 from baseline)
  • `cargo clippy --workspace --all-targets -- -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green
  • Streaming chat handler + other endpoints (messages/embeddings/
    rerank/audio/images/responses) emit langfuse events — deferred to
    a follow-up PR
    to keep this one reviewable

🤖 Generated with Claude Code

Per spec §3.6 / §9 / plan §4.9. New aisix-obs::langfuse module exports:
- LangfuseEvent / LangfuseSender / LangfuseHandle types
- spawn() that returns Ok(None) when disabled, or starts a background
  batch flusher (50 events or 1s, whichever comes first) when enabled

The exporter authenticates with HTTP basic (public_key:secret_key),
POSTs to {host}/api/public/ingestion in the documented batch shape
({batch: [{type: 'generation-create', body: {...}}]}), and never
blocks the request hot path — full queues drop the event silently.

Wired into:
- aisix-server bootstrap (spawn after metrics, hold handle for life
  of process)
- ProxyState (new optional langfuse: Option<Arc<LangfuseSender>>)
- chat::chat_completions (emits one event per request, success or
  failure)

Tests (20 new):
- Disabled config returns None
- Enabled-without-host errors clearly
- Enabled-without-key-env errors with the missing env name
- Wiremock round-trip: emit one event, wait for the 1s flush
  interval, assert the upstream received exactly one POST
- ISO timestamp round-trip including unix epoch + leap year
- Base64 basic auth encoding matches expected output
- Channel-full does not block the emitter

Streaming chat handler emission, plus other endpoints
(messages/embeddings/etc.), follow in a separate PR.
Copilot AI review requested due to automatic review settings April 20, 2026 00:32

Copilot AI 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.

Pull request overview

Adds an optional Langfuse ingestion exporter to the observability layer and wires it into the non-streaming chat completions handler so each request emits a generation event when enabled.

Changes:

  • Introduces aisix-obs::langfuse with a bounded, non-blocking event channel and background batch flusher to Langfuse /api/public/ingestion.
  • Wires a LangfuseSender into ProxyState and emits one LangfuseEvent per /v1/chat/completions request (success or failure).
  • Updates workspace plumbing (exports/deps) to support the new module.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
crates/aisix-server/src/main.rs Spawns optional Langfuse exporter and injects sender into ProxyState.
crates/aisix-proxy/src/state.rs Adds optional langfuse sender field + builder.
crates/aisix-proxy/src/chat.rs Emits LangfuseEvent on completion for success/failure paths.
crates/aisix-obs/src/lib.rs Exposes new langfuse module and re-exports its types.
crates/aisix-obs/src/langfuse.rs Implements batching exporter + tests.
crates/aisix-obs/Cargo.toml Adds deps for HTTP/JSON/base64/uuid and wiremock for tests.
crates/aisix-core/src/lib.rs Re-exports LangfuseConfig.
Cargo.lock Locks new dependencies pulled in by the exporter/tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

start_time: iso_offset(ev.latency),
end_time: now_iso.clone(),
status_message: (ev.status_code != 200)
.then(|| format!("upstream status {}", ev.status_code)),
Comment on lines +462 to +467
// SAFETY: tests run single-threaded by default and we only mutate
// env vars that are scoped to this test's spawn() call.
unsafe {
std::env::set_var("LANGFUSE_PUBLIC_KEY", "pk-test");
std::env::set_var("LANGFUSE_SECRET_KEY", "sk-test");
}
Comment on lines +88 to +99
// Optional Langfuse exporter — disabled in config by default.
// When enabled, the proxy gets an Arc<LangfuseSender> through
// ProxyState and emits one event per chat completion at
// end-of-request. We keep the handle alive for the lifetime of
// the process so the background flush task continues running.
let langfuse_handle = match langfuse::spawn(&cfg.observability) {
Ok(h) => h,
Err(e) => {
tracing::warn!(error = %e, "langfuse exporter disabled");
None
}
};

loop {
tokio::select! {
biased;
Comment on lines +31 to +32
/// never blocks the proxy thread; if Langfuse is offline we drop the
/// oldest events at the edges.
@moonming
moonming merged commit 5185b54 into main Apr 20, 2026
10 checks passed
@moonming
moonming deleted the feat/langfuse-exporter branch April 20, 2026 00:44
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
Output guardrails only inspected message.content, so client-visible
output that lives elsewhere bypassed content/DLP checks:

- tool_calls / Anthropic tool_use (normalized into message.extra) are
  now folded into a single guardrail-inspected text view via
  ChatResponse::guardrail_output_text(), used by the keyword, text-
  moderation, Bedrock, and Prompt Shield output checks (#3/#18/#21).
  Reasoning/thinking content is intentionally left out of scope.

- Non-streaming cache hits now run the resolved output guardrail chain
  before returning the stored body, instead of replaying it unchecked
  (#28). Streaming output guardrails already run end-of-stream.

Part of #448 (findings #3, #18, #21, #28)
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.

2 participants