A minimal, from-scratch re-implementation of Langfuse built to understand its internals. Backend: FastAPI + SQLAlchemy + SQLite/PostgreSQL. Frontend: React + Vite + TypeScript + Tailwind. SDK: Python + httpx.
π See mini-langfuse-plan.md for the full design doc, data model, and 5-milestone roadmap. Extension directions (M6+) live in mini-langfuse-plan-v2.md.
π§ Active product roadmap: mini-langfuse-plan-v3.md.
π Aliyun ECS demo deploy (liuyidi.me) + pitfalls: docs/aliyun-ecs-demo-deploy.md Β· templates in deploy/demo/.
- β Milestone 1 β End-to-end minimum loop: ingestion API, trace/observation model, tree view UI, working Python SDK, demo script.
- β
Milestone 2 β Generation cost calculation from built-in pricing table (OpenAI, Anthropic, Gemini);
@observedecorator;mini_langfuse.openaidrop-in wrapper; cost breakdown in UI. - β Milestone 3 β Session aggregation view; background flusher thread (non-blocking, atexit-safe); UI sessions list + conversation timeline.
- β
Milestone 4 β Score API + inline scoring UI (numeric / boolean / categorical); versioned Prompts with mutable
productionlabel pointer; SDKcreate_prompt,get_prompt(name, label=...),PromptClient.compile(vars); Prompt diff viewer in UI; Generation β PromptVersion link. - β
Milestone 5 β
docker-compose updeployment (multi-stage Node build β Nginx serving the SPA and proxying/api/*to the FastAPI container); pytest suite covering ingestion idempotency, cost math, tree building, prompt label movement, score validation, SDK contextvar isolation, flusher fault tolerance, and prompt compile. - β M7 β Waterfall chart on the trace detail page (three-column layout: Tree | Waterfall | Detail) with hover/select linked across all three panes.
- β
M8 β Playground page: edit chat messages, auto-detect
{{variables}}, run against mock / OpenAI / Anthropic providers, response with latency+tokens+cost, save-as-new-version dialog; every run is auto-persisted as aplayground:*trace so it also shows up in Traces.
ββββββββββββββββ HTTP ββββββββββββββββ ββββββββββββββ
β Your Python β ββ Basic βββΆ β FastAPI β ββSQLβββΆβ SQLite β
β app + SDK β ingestion β server β β (or PG) β
β β β β ββββββββββββββ
β β’ trace() β β β’ auth β
β β’ @observe β β β’ ingest β
β β’ .score() β β β’ cost calc β
β β’ .get_promptβ β β’ tree build β
ββββββββββββββββ ββββββββ¬ββββββββ
β² β
β HTTP β REST
β βΌ
β ββββββββββββββββ
ββββ prompts ββββββββββ React + Vite β
β Tailwind SPA β
β /traces /sessions /prompts
ββββββββββββββββ
Queue-based tracing is now available as an optional data-plane path:
SDK / app -> FastAPI ingestion -> Redis Stream -> Python worker -> ClickHouse
β PostgreSQL keeps control-plane data
Data model highlights:
- Observations are one flat table joined by
parent_observation_idβ allows partial updates and cheap tree reconstruction. - Sessions are an aggregation view, not a table β no start/end ambiguity.
- Prompt
labels(e.g.production) are mutable pointers;versionis immutable. Promoting v2 to production auto-removes the label from v1. - Ingestion is idempotent per event id and processed per-event under SQLite savepoints β one bad event doesn't fail the batch.
mini-langfuse/
βββ server/ # FastAPI backend
β βββ app/ # models, schemas, api routes, services
β βββ tests/ # pytest suite
β βββ Dockerfile
βββ sdk-python/ # Python client SDK
β βββ mini_langfuse/ # Client, decorators, flusher, openai wrapper, prompts
β βββ tests/
βββ web/ # React + Vite frontend
β βββ src/ # pages, components, api client
β βββ Dockerfile
β βββ nginx.conf
βββ docker-compose.yml # one-command deploy
βββ demo.py # generates sample traces + prompts + scores
βββ mini-langfuse-plan.md # full design doc
git clone https://github.com/liuyidi/mini-langfuse.git
cd mini-langfuse
docker compose up --build -d- UI: http://localhost:8080
- API: http://localhost:8000 (health probe: http://localhost:8000/health)
- PostgreSQL, Redis, ClickHouse, and the worker are all started by
docker compose. - PostgreSQL data is persisted in the
mlf_pgvolume. - ClickHouse data is persisted in the
mlf_clickhousevolume. - Ingestion defaults to the queue-first path when
MLF_INGESTION_QUEUE_URLis set. - Playground uses the built-in
mockprovider by default. To use real providers, pass keys through the compose env:OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose up --build -d
Then generate some traces (in a Python 3.10+ env):
cd sdk-python
pip install -e .
python ../demo.pyReload the UI and you'll see 5 traces, a 3-turn session, 2 prompt versions with a production label, and 3 scores.
Demo credentials (hardcoded β override via MLF_DEMO_PUBLIC_KEY / MLF_DEMO_SECRET_KEY):
public_key = pk-lf-demosecret_key = sk-lf-demo
If you want to run the queue-first ingestion path locally without the full UI stack:
docker compose up --build db redis clickhouse server workerUseful local connection URLs:
- Redis:
redis://localhost:6379/0 - ClickHouse HTTP:
http://localhost:8123 - ClickHouse Native:
localhost:9000
The worker reads from Redis Stream mlf:ingestion and writes to ClickHouse tables default.traces and default.observations.
cd server
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
uvicorn app.main:app --reload # http://localhost:8000The first run creates a SQLite DB at server/mini_langfuse.db and seeds a demo project.
cd web
npm install
npm run dev # http://localhost:5173Vite proxies /api/* to the backend on :8000.
cd sdk-python
pip install -e .
python ../demo.pyOpen http://localhost:5173.
from mini_langfuse import Client
client = Client("pk-lf-demo", "sk-lf-demo", host="http://localhost:8000")
with client.trace(name="chat", user_id="alice", input={"q": "hi"}) as t:
with t.span(name="retrieve") as s:
docs = ["..."]
s.update(output={"docs": docs})
with t.generation(name="llm", model="gpt-4o", input={"prompt": "..."}) as g:
g.update(
output={"content": "..."},
usage={"prompt_tokens": 10, "completion_tokens": 5},
)
client.close()Nested calls in the same context automatically become children of the enclosing span (via contextvars).
Exceptions inside a span mark it as ERROR before propagating.
from mini_langfuse import Client, observe
Client("pk-lf-demo", "sk-lf-demo") # becomes the default client
@observe()
def retrieve(q: str) -> list[str]:
return search(q)
@observe(as_type="generation", model="gpt-4o-mini")
def summarize(docs: list[str]) -> str:
return openai_call(docs) # args auto-captured, return auto-capturedIf no trace is active, @observe auto-opens one named after the function. Nested @observe-decorated calls become children automatically.
from mini_langfuse import Client
from mini_langfuse.openai import OpenAI # instead of `from openai import OpenAI`
Client("pk-lf-demo", "sk-lf-demo")
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hi"}],
)
# GENERATION observation is auto-recorded with model, messages, output, and cost.# Create v1
v1 = client.create_prompt(
name="support",
type="chat",
content=[
{"role": "system", "content": "Be polite."},
{"role": "user", "content": "{{question}}"},
],
)
# Ship v2 as production (label auto-moves from any older version)
v2 = client.create_prompt(
name="support",
type="chat",
content=[...], # updated content
labels=["production"],
commit_message="Firmer tone",
)
# In production code, always fetch by label β no redeploy needed to promote.
prompt = client.get_prompt("support", label="production")
messages = prompt.compile(question="Where's my order?")
with client.trace(name="support-answer") as t:
with t.generation(name="reply", model="gpt-4o-mini",
input={"messages": messages},
prompt_version_id=prompt.id) as g: # β links to exact version
...client.score(
trace_id=trace_id,
name="helpfulness",
data_type="NUMERIC",
value=0.9,
source="EVAL",
comment="Concise and accurate",
)Scores are also editable inline on the Trace detail page (numeric / boolean / categorical).
Open any prompt (/prompts/:name) and click "Open in Playground βΆ". Edit chat messages, fill in {{variables}} that are auto-detected across all messages, pick a provider (mock / OpenAI / Anthropic) + model, and Run β response, latency, tokens, and cost appear in the right pane. Click "Save as new versionβ¦" to commit the edited prompt as v+1 (optionally with the production label).
Every playground run is persisted as a playground:<prompt> trace and shows up in the Traces list β so you can compare "manual runs vs. real user traffic" side by side.
Provider config:
mock(default) β no API key, deterministic echo response for demos.openaiβ setOPENAI_API_KEYon the server container / process.anthropicβ setANTHROPIC_API_KEYon the server container / process.
Cost is computed server-side from a built-in pricing table (server/app/services/cost.py) whenever a GENERATION includes model and usage. Supported: OpenAI (gpt-4o/mini/turbo/o1), Anthropic Claude 3/3.5/4, Google Gemini 1.5/2.0. Update the table when prices change.
All under HTTP Basic auth using the demo keys.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/public/ingestion |
Batch event upsert (trace/span/generation/event Γ create/update) |
| GET | /api/public/traces |
List traces with aggregate metrics |
| GET | /api/public/traces/:id |
Trace detail with tree of observations |
| GET | /api/public/sessions |
List sessions (aggregated by session_id) |
| GET | /api/public/sessions/:id |
Session detail with all traces in time order |
| POST | /api/public/scores |
Create a score on a trace or observation |
| GET | /api/public/scores?traceId= |
List scores |
| POST | /api/public/prompts |
Create prompt or a new version (auto-incrementing) |
| GET | /api/public/prompts |
List prompts with latest version metadata |
| GET | /api/public/prompts/:name |
Prompt detail with all versions |
| GET | /api/public/prompts/:name/resolve?version=&label= |
Resolve a single version |
| PATCH | /api/public/prompt-versions/:id/labels |
Move labels (each label points to one version) |
| POST | /api/public/playground/run |
Proxy an LLM call and record it as a playground:* trace |
| GET | /health |
Liveness probe |
Since M3 the SDK no longer blocks on network. Client._enqueue() drops events into a bounded queue.Queue; a daemon thread batches them by size (default 50) or interval (1s) and POSTs to /ingestion. An atexit hook drains what's left when the interpreter exits, so short-lived scripts don't lose events. Ingestion failures are logged and swallowed β user code never sees them.
You can tune it:
Client(pk, sk, batch_size=100, flush_interval=0.5) # more aggressive
client.flush(timeout=5) # block until queue drained (for tests / notebooks)# Server (fastapi + sqlalchemy, uses in-memory-ish SQLite):
cd server && pip install -e '.[dev]' && pytest
# SDK (no server required β uses a stubbed HTTP layer):
cd sdk-python && pip install -e . pytest && pytestThe server suite covers ingestion idempotency, tree building, cost math, prompt label movement, and score validation. The SDK suite covers contextvar isolation across nested spans, background flusher batching + fault tolerance, prompt variable substitution, and the async @observe path.
- Read the full plan:
mini-langfuse-plan.md - Real Langfuse: https://github.com/langfuse/langfuse