Unit consumption control
Langfuse's free tier grants a fixed number of observation units per month. Each span exported to Langfuse counts as one unit regardless of whether it has any diagnostic content. This document explains how the SDK's processor pipeline lets you reduce unit consumption without losing diagnostic coverage.
Background: what costs a Langfuse unit
Every span that exits the FilteringExporter and is delivered to the Langfuse
OTLP endpoint costs one unit. The SDK controls unit consumption at two levels:
- Span filtering: dropping individual spans before they are delivered, all
of it inside
FilteringExporter - Trace sampling: keeping or dropping entire traces based on a rate
Filtering removes noise. Sampling scales volume.
The processor pipeline
OTel SDK
│
├─ SessionIdStampingProcessor.on_start() stamps session.id while span is mutable
├─ SuppressionProcessor.on_start() stamps langfuse.suppress_export
├─ ReparentingProcessor.on_start() stamps where the span should attach
│ once its dropped ancestors are gone
│
└─ BatchSpanProcessor
└─ FilteringExporter.export(spans)
│
├─ G, span names: drop by name or prefix, reattach the survivors
├─ E, suppress: drop spans with langfuse.suppress_export == True
├─ U, usage strip: remove gen_ai.usage.* from wrapper spans
├─ B, no content: drop spans with no input AND no output
├─ A, sampling: keep or drop by trace (or session) and rate
├─ D, log units: emit "Langfuse flush: N spans" to the
│ semantic_tracing.units logger
│
└─ inner BatchSpanProcessor.span_exporter -> Langfuse OTLP
The on_start() processors run synchronously in the request coroutine while the
span is still mutable, before it enters the batch queue. FilteringExporter runs
later, at batch flush time, on the already-immutable ReadableSpan copies.
Every keep or drop decision therefore happens at export time, including the
name-based ones. Refusing a span at the sampler instead would be cheaper, but a
span that is never created still has children parented onto it, and those
children would arrive at Langfuse pointing at an observation that never does.
ReparentingProcessor exists to record the answer while the parent chain is
still visible, so stage G can reattach each survivor on the way out.
Configuring filters
Drop named/prefixed spans globally
The most reliable way to avoid paying units for framework internals is to name
them, so FilteringExporter discards them before the network call. Two lists
merge pod-supplied names with a built-in set of known-noisy patterns:
setup_tracing(
service_name="my_workflow",
drop_span_names=["edge_group.process SingleEdgeGroup"],
drop_span_name_prefixes=["send_async"],
)
Built-in defaults cover Kiota dispatch internals and HTTP spans from pods that
do not use the dual-provider architecture. Set clear_default_drop_prefixes=True
to suppress the built-in list and rely entirely on the pod-supplied set.
Naming a span that has children is safe. Its descendants are reattached to the
nearest ancestor that survives, so a framework's mid-tree bookkeeping span can be
removed without detaching the work nested inside it. This matters for Microsoft
Agent Framework in particular, whose workflow.run and executor.process <name>
spans sit directly between an application's own spans.
Drop spans with no diagnostic content
Many framework-internal spans carry no input and no output — they are lifecycle
bookkeeping. Setting drop_empty_observations=True drops these at the exporter
layer:
setup_tracing(
service_name="my_workflow",
drop_empty_observations=True,
)
Unlike a named drop, this does not reattach descendants, and it cannot: whether
a span is empty is only known once it has ended, by which point its children may
already have been exported. Use it for leaf noise, and name a span in
drop_span_names when it has children.
A span is considered empty when all of the following are true:
langfuse.inputis absent or emptylangfuse.outputis absent or empty- no
gen_ai.*attribute is present
The gen_ai.* exception preserves spans from Microsoft Agent Framework and other
OTel gen_ai instrumentation (e.g. invoke_agent, chat, execute_tool) that
carry token counts and operation metadata via the OTel semantic conventions
instead of Langfuse-specific attributes. Without this exception,
drop_empty_observations=True would silently drop those spans when
capture_sensitive_data=False.
Statistical sampling
sample_rate controls what fraction of traces reach Langfuse. Set it below
1.0 to cap unit consumption on high-traffic services:
setup_tracing(
service_name="my_workflow",
sample_rate=0.3,
)
See 10-tracing-session-sampling.md for a full
explanation of how sampling interacts with session_id, always_send_errors,
and partial-trace prevention.
Suppress export for a specific scope
Use propagate(langfuse_export=False) to hard-exclude an entire request from
Langfuse while keeping the OTel trace intact:
with propagate(langfuse_export=False):
await run_smoke_test(request)
This is independent of sample_rate. It is a deterministic, per-call exclusion.
See 02-tracing-observations.md for the full
propagate() parameter reference.
Monitoring unit consumption
The SDK emits a structured log line each time FilteringExporter flushes a
batch to Langfuse. Listen to the semantic_tracing.units logger to record or
alert on this:
import logging
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
logging.getLogger("semantic_tracing.units").addHandler(handler)
Log line format:
Langfuse flush: N spans (M traces, ~$X.XX at list price)
The cost estimate is computed at $8.00 per 100,000 units (Langfuse list price
at time of writing). Use it as a rough directional indicator, not a billing
forecast — actual billing depends on your Langfuse plan tier.
Combining options
A typical production configuration that controls costs while preserving error visibility:
setup_tracing(
service_name="my_workflow",
sample_rate=0.2,
always_send_errors=True,
drop_empty_observations=True,
drop_span_name_prefixes=["kiota.internal"],
)
Filter execution order
Filter order inside FilteringExporter.export():
- G, span names: drop by exact name or prefix, then reattach each survivor to the nearest ancestor that also survived
- E, suppression:
langfuse.suppress_export == Truealways drops - U, usage stripping: remove
gen_ai.usage.*from spans named insuppress_usage_on_spans - B, null content: empty input and output drops if
drop_empty_observations=True - A, sampling: trace-level keep or drop at
sample_rate;always_send_errors=Truebypasses it for error traces - D, log: emit the unit count for surviving spans
Spans that survive every stage are forwarded to the inner Langfuse exporter.