Why We Brought This Tool Into Our Lab
We brought Langfuse into our evaluation because agent observability gets expensive in a way that request-based forecasts hide. One application request can generate a root span, several model calls, retrieval operations, tool executions, and retries. Multiply requests by observations per trace before buying infrastructure.
Published starting memory allocationsLangfuse Web 4 GiBLangfuse Worker 4 GiBPostgreSQL 4 GiBRedis/Valkey 1.5 GiBClickHouse 8 GiB
These are published starting allocations, not measured memory use or proof of ingestion capacity.
Our question was narrower than “Is Langfuse a good observability product?” We wanted to establish where ingestion accumulates, what competes with dashboard queries, and how to calculate cost per million spans without confusing accepted HTTP requests with queryable data.
Our local execution stopped before a benchmark: the required python:3.12-slim image was unavailable. We did not complete the Compose installation or measure ingestion throughput, query latency, disk growth, or Redis queue behavior. This review therefore combines an architecture assessment with a runnable test procedure and a worked cost model—not a claimed capacity certification.
We grounded the assessment in the scaling guide, the agent-scale ClickHouse engineering walkthrough, and the self-hosting architecture.
Under the hood, we are sizing a pipeline:
Application / OTLP exporter | v Langfuse Web -> raw events in S3-compatible storage | v Redis queue references | v Langfuse Worker -> buffered inserts -> ClickHouse | v UI and APIs We treat PostgreSQL as transactional infrastructure, Redis as queue/cache infrastructure, object storage as raw-event persistence, and ClickHouse as analytical storage. Calling this “a ClickHouse deployment” leaves several bills—and several failure modes—out of the design.
The architectural advantage is useful: ingestion bursts can accumulate as queued work rather than immediately forcing synchronous database processing. We would not translate that into unlimited burst tolerance. Queue growth consumes capacity, delays visibility, and eventually requires recovery throughput above the normal arrival rate.
Planned Langfuse Setup and Synthetic OTLP Test
We designed the test around completed OTLP spans, multiple turns per trace, controlled payload sizes, and separate measurements for acceptance and visibility.
Our first reproducibility rule is to pin the Langfuse release and record image digests. We do not publish a guessed release number or a minimum ClickHouse version without checking the chosen release’s requirements.
This is the intended setup sequence, not a transcript of a successful installation:
git clone https://github.com/langfuse/langfuse.git cd langfuse # Set this to the release selected for your evaluation. : "${LANGFUSE_REF:?Set LANGFUSE_REF to a reviewed release tag}" git checkout "$LANGFUSE_REF" # Review the release's Compose instructions and replace default secrets. docker compose config > compose.resolved.yaml docker compose pull docker compose up -d docker compose ps docker compose images We would keep this stack private, replace example credentials, and avoid exposing ClickHouse, Redis, or PostgreSQL publicly. Compose is our reproduction environment, not a high-availability deployment plan.
For capacity planning, we use these published starting allocations:
| Component | CPU | Memory |
|---|---|---|
| Langfuse Web | 2 | 4 GiB |
| Langfuse Worker | 2 | 4 GiB |
| PostgreSQL | 2 | 4 GiB |
| Redis/Valkey | 1 | 1.5 GiB |
| ClickHouse | 2 | 8 GiB |
| Local MinIO/SeaweedFS, if used | 2 | 4 GiB |
With local object storage, that totals 11 CPUs and 25.5 GiB across services before host overhead. We treat these as starting allocations, not measured resident memory or proof of any span rate. For larger deployments, we use at least 16 GiB for ClickHouse as a planning baseline, not as a validated capacity target.
After initialization, we would create a project and API keys. Our harness targets OTLP/HTTP at /api/public/otel/v1/traces, using Basic authentication with the project public and secret keys.
The following generator needs no model-provider credentials. It creates multi-turn trace trees and emits valid OTLP JSON. DRY_RUN=1 makes it runnable without Langfuse; that mode validates payload construction only.
# Save as synthetic_otlp.py. Uses only the Python standard library. import base64 import json import os import secrets import time import urllib.error import urllib.request base = os.getenv("LANGFUSE_BASE_URL", "http://localhost:3000") dry_run = os.getenv("DRY_RUN", "1") == "1" public = os.getenv("LANGFUSE_PUBLIC_KEY", "") secret = os.getenv("LANGFUSE_SECRET_KEY", "") traces = int(os.getenv("TRACES", "20")) turns = int(os.getenv("TURNS", "3")) batch_size = int(os.getenv("BATCH_SPANS", "64")) payload_bytes = int(os.getenv("PAYLOAD_BYTES", "1024")) if not dry_run and not (public and secret): raise SystemExit("Set both Langfuse project keys before sending") def attribute(key, value): return {"key": key, "value": {"stringValue": value}} spans = [] for _ in range(traces): trace_id = secrets.token_hex(16) root_id = secrets.token_hex(8) start = time.time_ns() end = start + 1_000_000 def span(name, span_id, parent=None): result = { "traceId": trace_id, "spanId": span_id, "name": name, "kind": 1, "startTimeUnixNano": str(start), "endTimeUnixNano": str(end), "attributes": [ attribute("langfuse.observation.input", "x" * payload_bytes), attribute("benchmark.run", "synthetic-multiturn"), ], } if parent: result["parentSpanId"] = parent return result spans.append(span("agent-session", root_id)) for turn in range(turns): turn_id = secrets.token_hex(8) spans.append(span(f"turn-{turn}", turn_id, root_id)) spans.append(span("retrieval", secrets.token_hex(8), turn_id)) spans.append(span("model-call", secrets.token_hex(8), turn_id)) auth = base64.b64encode(f"{public}:{secret}".encode()).decode() sent = 0 for offset in range(0, len(spans), batch_size): batch = spans[offset:offset + batch_size] body = json.dumps({ "resourceSpans": [{ "resource": {"attributes": [ attribute("service.name", "effloow-sizing") ]}, "scopeSpans": [{ "scope": {"name": "effloow.synthetic"}, "spans": batch, }], }] }).encode() if dry_run: print(json.dumps({"mode": "dry-run", "spans": len(batch), "json_bytes": len(body)})) continue request = urllib.request.Request( base + "/api/public/otel/v1/traces", data=body, headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=30) as response: text = response.read().decode() print(json.dumps({"http_status": response.status, "spans": len(batch), "response": text})) sent += len(batch) except urllib.error.HTTPError as error: raise SystemExit(f"HTTP {error.code}: {error.read().decode()}") print(json.dumps({"generated_spans": len(spans), "http_success_spans": sent, "dry_run": dry_run})) Run the construction check with:
DRY_RUN=1 python3 synthetic_otlp.py Illustrative output, not captured lab output:
{"generated_spans": 200, "http_success_spans": 0, "dry_run": true} We deliberately named the success counter http_success_spans, not stored_spans. A successful response still needs inspection for OTLP partial-success details and reconciliation against queryable observations.
For the actual load test, we would vary client batch size across 1, 64, and 256 spans, then introduce a paced arrival rate. These are proposed test points, not discovered defaults. The simple script above is a smoke-test generator, not a calibrated throughput benchmark.
Before measuring storage, we would inspect the installed schema:
SELECT name, engine, partition_key, sorting_key FROM system.tables WHERE database = currentDatabase() ORDER BY name; SELECT database, table, count() AS active_parts, sum(rows) AS physical_rows, sum(bytes_on_disk) AS bytes_on_disk FROM system.parts WHERE active GROUP BY database, table ORDER BY bytes_on_disk DESC; We would save SHOW CREATE TABLE for each relevant observation and derived table. We would not assume the legacy and newer observations-first layouts have identical names or deduplication behavior.
Blocked Local Test and Deployment Risks
The concrete failure we hit was environmental: the Python container image was unavailable. That prevented the planned local reproduction. We have not established whether the cause was registry access, caching, or another provisioning restriction.
Our next step would be to restore that dependency or run the standard-library harness on an existing Python installation. We would still rerun the complete stack validation rather than treating a successful dry run as an ingestion result.
The architecture review also identified several risks we would assess before deployment.
First, queue jobs are not spans. We use langfuse.queue.ingestion.depth with type:waiting as the primary waiting-depth signal, but we would not divide that number by spans per second. One queued reference can represent batched work.
For older releases, the metric changes: before 3.169.0, we need the legacy langfuse.queue.ingestion.length. Version 3.169.0 introduces tagged depth in StatsD, while distinct flattened CloudWatch series require 3.170.0 or later. A dashboard copied across versions can silently monitor the wrong signal.
We would record waiting depth, oldest pending-work age where available, completed work rate, failures, worker CPU, Redis memory/evictions, and end-to-end visibility delay. We would use load above 50% on a two-CPU worker as an initial scaling trigger, then validate it against queue-drain measurements.
Second, immutable OTLP ingestion and legacy updates need different read assumptions. We would test LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true only with an OTel-only project. Its Redis marker has a 24-hour TTL refreshed by OTel ingestion.
We would not enable the global LANGFUSE_API_CLICKHOUSE_DISABLE_OBSERVATIONS_FINAL=true on a mixed legacy deployment. Removing read-time deduplication without validating update semantics risks stale or duplicate observations. We would confirm the emitted SQL in system.query_log, rather than infer success from an environment variable.
Third, raw-event retention affects recovery and late updates. The optional LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE optimization depends on project history and retained blob events. We would not combine aggressive blob deletion with a read-skipping optimization without testing late updates.
Fourth, disk growth is not just tracing rows. We would measure Langfuse tables, derived representations, ClickHouse system logs, and object storage separately. System logs can dominate growth; our first response would be bounded retention or selective disabling of unused logs, while retaining useful debugging logs.
Finally, we would distinguish client batching from worker buffering and ClickHouse asynchronous insert batching. The reviewed material does not establish their effective thresholds for our chosen release. We cannot turn a client batch-size experiment into a claim about server-side insert sizes.
Scale, Latency & Cost vs. Alternatives
We have no defensible “this node handles X spans/second” result. Our sizing decision instead starts with three separate rates:
- Offered: spans the client attempts to send.
- Accepted: spans acknowledged without reported rejection.
- Queryable: distinct expected observations visible through the intended read path.
We would require a stable queue during steady load, then test burst recovery while dashboard queries continue. For queries, we would record p50, p95, and p99 separately for project/time-filtered listings, trace retrieval, and aggregations.
In our merge-pressure assessment, we consider partitions near 1,000 parts versus a typical 150–200, and maximum granule-size tuning from 10 MiB to 64 MiB. We use these as diagnostic reference points—not local benchmark findings, universal alert thresholds, or settings to copy onto a small Compose node.
Our practical comparison looks like this:
| Option | What we would operate | Why we would choose it | Main economic uncertainty |
|---|---|---|---|
| Langfuse with self-managed ClickHouse | Full application and storage stack | Infrastructure control and tunable retention | Merge, recovery, backup, and staffing costs |
| Self-hosted Langfuse with managed ClickHouse | Application stack plus remaining stores | Less database operational work | Managed database bill and remaining operational burden |
| Langfuse Cloud | Instrumentation and usage governance | Less infrastructure ownership | Current plan, retention, and usage terms |
| Existing collector plus custom ClickHouse tables | Collector, schema, queries, UI, lifecycle | A narrow workload already supported internally | Engineering and maintenance cost |
We would not equate a custom trace table with Langfuse’s product capabilities. Cheap inserts alone do not replace trace navigation, evaluations, access controls, or lifecycle management.
For an auditable cost model, define:
N = ingested spans per month F = fixed monthly infrastructure cost O = monthly operations cost V = variable ingest/request/transfer cost per million spans b = retained bytes per span across all billed storage D = retention days P = blended storage price per GiB-month Retained GiB = (N / 30) × D × b / 2^30 Monthly cost = F + O + (N / 1,000,000) × V + Retained GiB × P Cost per million spans = Monthly cost / (N / 1,000,000) This steady-state approximation excludes replication, backups, and temporary merge space unless we explicitly include them. In a real forecast, we would price object storage and database storage separately.
Here is an illustrative budget, not a measured result or vendor quote:
| Assumption | Example value |
|---|---|
| Monthly spans | 100 million |
| Retention | 30 days |
| Combined retained bytes per span | 2 KiB |
| Fixed infrastructure | $500/month |
| Operations allocation | $600/month |
| Variable processing/request cost | $0.50/million |
| Blended storage price | $0.10/GiB-month |
That produces about 190.7 GiB retained, $19.07 in storage, and a total of $1,169.07/month, or $11.69 per million spans.
At an assumed 20 KiB per span, the same arithmetic becomes approximately $13.41 per million. We would not assume compute remains unchanged with larger payloads; this sensitivity changes storage only.
For break-even, if a comparable managed quote were hypothetically $20 per million all-in, these assumptions yield:
Variable self-hosted cost per million: $0.50 + $0.1907 = $0.6907 Break-even monthly volume: $1,100 / ($20 - $0.6907) ≈ 57 million spans That crossing point is useful only if the infrastructure sustains the volume and the alternatives offer comparable retention, availability, and features. Removing the operations allocation makes self-hosting look cheaper; it does not remove the work.
Our Final Verdict: When to Deploy, When to Skip
We consider Langfuse’s architecture credible, but we have not validated its capacity locally. We would shortlist Langfuse for agent observability, but we would not approve a throughput target or storage budget from this evaluation alone.
We would deploy it when:
- We need Langfuse’s application features, not merely a span sink.
- We can operate the complete stack or deliberately buy managed components.
- We measure queryable throughput and burst drain time.
- We define retention across raw events, analytical data, logs, and backups.
- We validate legacy-update behavior before removing deduplication work.
- Our staffing-inclusive cost model beats the relevant managed alternative.
We would hold off when:
- We need a guaranteed capacity number without running the representative workload.
- We expect Compose to provide availability and recovery automatically.
- We cannot monitor queue age or reconcile accepted spans with visible observations.
- We budget only ClickHouse disk while ignoring raw-event storage and operations.
- We require immediate visibility but cannot tolerate asynchronous backlog.
Our next lab run is straightforward: restore the missing dependency, pin the stack, inspect the schema, validate one OTLP trace, then increase offered load while recording queue behavior, query latency, and storage deltas. Until then, the cost spreadsheet is a planning instrument—not a benchmark.
For adjacent infrastructure decisions, browse our tools collection. If you need a workload-specific capacity and recovery plan, talk to us about an architecture review. We would rather expose an unmeasured assumption before deployment than discover it during an evaluation backfill.
