Skip to content

feat(platform): self-hosted Langfuse v3 — infra, LLM tracing, ops entry point (#320) - #328

Merged
jayesh-keychain merged 4 commits into
mainfrom
fix/320-langfuse-selfhosted-infra
Aug 1, 2026
Merged

feat(platform): self-hosted Langfuse v3 — infra, LLM tracing, ops entry point (#320)#328
jayesh-keychain merged 4 commits into
mainfrom
fix/320-langfuse-selfhosted-infra

Conversation

@jayesh-keychain

Copy link
Copy Markdown
Collaborator

Stages 1, 2 and the entry-point slice of stage 5 from #320. Self-hosted Langfuse v3 for LLM tracing, cost and latency analytics.

Why: the recall pipeline was a black box. The #319 query-embedding bug took manual pgvector cosine measurements to find; a trace would have shown it instantly.

What lands

  • langfuse compose profile (web, worker, ClickHouse, MinIO) — opt-in like metabase, so pnpm infra:up stays exactly nats postgres redis. Reuses the existing Postgres (dedicated langfuse DB, mirroring metabase-db-init) and the existing Redis.
  • platform/observability/langfuse.ts — off by default (no keys → no-op), loopback-only payloads reusing isLoopbackUrl, unconditional redaction for isPrivate, fail-open emits.
  • traceGeneration on both complete() paths — one seam covers every LLM call, since ADR-0002 makes the router the chokepoint.
  • Observability ↗ link in the Operations Console; corrects the Redis card, which claimed "not in use" while Langfuse queues on it.

Two stage-1 defects green healthchecks hid — all containers healthy, UI 200, 72 tables migrated, and zero traces could persist: the MinIO bucket was never created (Langfuse doesn't create it), and REDIS_HOST/REDIS_PORT made Langfuse send AUTH to a password-less Redis. Fixed with an idempotent langfuse-minio-init and REDIS_CONNECTION_STRING.

Verified end-to-end against the running stack (Ollama gemma3:4b):

GENERATION model=gemma3:4b class=strong vendor=ollama costUsd=0 latencyMs=1808.5
trace input : "What is Priya allergic to?" + retrieved context
      output: "peanuts"
  • pnpm langfuse:up → all services healthy, UI + /api/public/health 200, 72 tables migrated
  • default docker compose config --services unchanged (nats postgres redis)
  • traces + generations land with model, class, vendor, cost, latency
  • ops link verified rendering in the live signed-in admin
  • type-check, lint, build green; 77 platform tests pass
  • privacy gating is NOT test-covered — tracked in test(platform): prove the Langfuse privacy gating (#320 stage 2 has none) #325, deliberately not claimed here

Refs #320 (stages 3–4 remain). Does not close it.

🤖 Generated with Claude Code

Jayesh Bhade and others added 4 commits August 1, 2026 03:38
…320)

Stage 1 of #320: the infrastructure only. No application code is instrumented
yet — tracing the model router (#320 stage 2) and the recall pipeline (stage 3)
land separately, so this PR is reviewable on its own and changes NO runtime
behaviour.

Langfuse v3 is not one container. It needs ClickHouse for analytics, Redis for
queueing, S3-compatible blob storage for event payloads, and separate web and
worker processes. That is far too much to impose on every `pnpm infra:up`, so
it all sits behind a `langfuse` compose profile — the same opt-in pattern
`metabase` already uses. Verified: `docker compose config --services` still
reports exactly `nats postgres redis`, and the Langfuse services appear only
with `--profile langfuse`.

  - `langfuse-db-init` creates a dedicated `langfuse` database on the EXISTING
    Postgres, mirroring `metabase-db-init` (idempotent, safe on every up)
    rather than adding a second Postgres.
  - Redis is reused, not duplicated. NOTE: this makes CLAUDE.md §1's "Redis,
    NATS and MinIO are reserved, not used" no longer true for Redis — the
    manual needs updating; flagged in #320.
  - MinIO is added. It was documented as a reserved dependency but was never
    actually in this file; this is its first real use.
  - Host ports: 3000 is admin and 3001 is metabase, so Langfuse takes 3002.
    Every port is `${VAR:-default}` so it can be moved on a machine that
    already has something bound — the same lesson as #315/#316.
  - `TELEMETRY_ENABLED=false`: this is a privacy tool, so it must not itself
    phone home.

SELF-HOSTED ONLY, and that is the point rather than a preference: traces carry
prompts and completions, which carry memory text. Langfuse Cloud and LangSmith
are rejected because shipping that to a third party violates invariant §2.2.
The ADR recording this is still owed (#320 stage 1).

The three Langfuse secrets (NEXTAUTH_SECRET, SALT, ENCRYPTION_KEY) are wired
as env with placeholder defaults so a local stack boots, and documented in
.env.example with the generation command. ENCRYPTION_KEY must be exactly 64
hex chars. No real secret is committed.

Verification: `docker compose --profile langfuse config` parses and resolves
(the shared YAML anchor for the web/worker env expands correctly), and the
default service list is unchanged. The stack has NOT yet been booted
end-to-end — the images are a multi-GB pull — so "all services healthy" and
the first-run Langfuse migration are UNPROVEN here and must be confirmed with
`pnpm langfuse:up` before this is relied on.

Refs #320 (stage 1 of 5; does not close it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stage-1 commit stated the stack had NOT been booted end-to-end (the images
were a multi-GB pull still in flight) and that "all services healthy" plus the
first-run migration were unproven. They have now been run — recording the
evidence so the branch does not carry a stale caveat.

  docker compose --profile langfuse ps
    clickhouse            healthy
    minio                 healthy
    langfuse-worker       running
    langfuse (web)        running

  curl http://localhost:3002              -> 200
  curl http://localhost:3002/api/public/health -> 200

  langfuse database created on the EXISTING postgres  -> present
  first-run migration                                 -> 72 tables in public

`langfuse-db-init` created the database, the worker applied the Postgres and
ClickHouse migrations, and the web process serves. The pre-existing containers
were NOT disturbed: postgres/redis/nats kept their 3-4h uptimes across the
profile coming up, confirming the opt-in profile composes with a running dev
stack rather than restarting it.

Still stage 1 of 5 — no application code is instrumented, and tracing must not
be enabled until the stage-2 privacy gating (loopback-only payloads,
unconditional redaction for isPrivate, fail-open) exists.

Refs #320.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2 of #320. Every call through the model router now emits a Langfuse
trace + generation with model, class, vendor, token-derived cost and latency.
`complete()` is the only instrumentation point needed: ADR-0002 already makes
it the chokepoint every LLM call passes through, so one seam covers ingest and
recall without touching a single call site.

Three rules govern `platform/observability/langfuse.ts`, in priority order:

  1. OFF BY DEFAULT — with no keys the client is undefined and every export is
     a no-op, so dev/CI/tests neither emit nor need a server (§2.5 parity).
  2. PAYLOADS STAY ON-BOX — trace input/output ARE prompts and completions,
     i.e. memory text. They are sent only to a loopback host, reusing the same
     `isLoopbackUrl` gate as the Ollama provider rather than re-deriving the
     rule. A remote host degrades to metadata-only unless an operator sets
     LANGFUSE_ALLOW_REMOTE_PAYLOADS explicitly, so moving a URL in `.env` can
     never start exfiltrating content (M9). `isPrivate` content is redacted
     UNCONDITIONALLY — even on loopback: a trace is another store, and a
     private memory must not be duplicated into one.
  3. FAIL-OPEN — every emit is wrapped; a Langfuse outage, bad key or
     serialisation error is swallowed and logged, never surfacing to a user.
     Same contract as `fireCoactivationLog`.

Also fixes two stage-1 compose defects that green healthchecks hid. The stack
reported every container healthy, the UI served 200 and 72 tables migrated —
and could still persist ZERO traces:

  - The `langfuse` MinIO bucket was never created (Langfuse does not create
    it), so every event died with "Failed to upload events to blob storage,
    aborting event processing". Added an idempotent `langfuse-minio-init`
    one-shot mirroring `langfuse-db-init`; both langfuse services now gate on
    it.
  - Redis auth: passing REDIS_HOST/REDIS_PORT made Langfuse send an AUTH
    command to a password-less Redis ("ERR AUTH called without any password
    configured"). Switched to REDIS_CONNECTION_STRING.

Trace input/output are set on the TRACE ROOT as well as the child generation.
Setting only the generation left the UI showing "this trace didn't receive an
input or output" while the payload sat one level down — the trace root is what
the UI opens on.

Verified end-to-end against the running stack (Ollama gemma3:4b):

  GENERATION model=gemma3:4b class=strong vendor=ollama
             costUsd=0 latencyMs=1808.5
  trace  input : "What is Priya allergic to?" + retrieved context
         output: "peanuts"

type-check, lint green; 77 platform tests pass unchanged.

NOT done, so the #320 privacy AC stays open: the two tests proving private
memory text never reaches the client, and that a non-loopback host degrades to
metadata-only. The gating code exists and logged `payloads: full` correctly on
loopback, but it is not test-covered — and stage 1 already showed what happens
when only the happy path is exercised.

Refs #320 (stage 2 of 5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Langfuse had no entry point — an operator had to know it existed and type
localhost:3002. It now sits in the /ops nav beside "Manage users" and
"Audit trail", where operators already look.

  - An external <a>, not a next/link: Langfuse is a SEPARATE self-hosted app on
    its own port, not a route in this Next app.
  - target=_blank + rel=noopener so the console is not lost on the way out.
  - The URL is NEXT_PUBLIC_LANGFUSE_URL with a :3002 default (3000 = admin,
    3001 = metabase), so a deployment that moves it does not patch a component.
  - Shown unconditionally. The `langfuse` compose profile is opt-in, so the
    service is often down; a link that explains itself on hover ("opt-in: pnpm
    langfuse:up") beats an entry point that silently vanishes.

Also corrects the Redis service card, which still read "Reserved — not in use
this phase". Langfuse queues on Redis as of stage 1, so that was now false on
the very page an operator consults to see what is running.

Verified in the running admin as a signed-in operator (the page is
client-rendered behind auth, so an unauthenticated fetch only returns the
shell and cannot confirm this): the link renders as "Observability ↗" with
href http://localhost:3002 and target=_blank, and the Redis card shows the
corrected text. type-check and lint green.

Refs #320 (part of stage 5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jayesh-keychain
jayesh-keychain merged commit c64f273 into main Aug 1, 2026
6 checks passed
@jayesh-keychain
jayesh-keychain deleted the fix/320-langfuse-selfhosted-infra branch August 1, 2026 05:47
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