Skip to content

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:

  1. Span filtering: dropping individual spans before they are delivered, all of it inside FilteringExporter
  2. 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.input is absent or empty
  • langfuse.output is 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():

  1. G, span names: drop by exact name or prefix, then reattach each survivor to the nearest ancestor that also survived
  2. E, suppression: langfuse.suppress_export == True always drops
  3. U, usage stripping: remove gen_ai.usage.* from spans named in suppress_usage_on_spans
  4. B, null content: empty input and output drops if drop_empty_observations=True
  5. A, sampling: trace-level keep or drop at sample_rate; always_send_errors=True bypasses it for error traces
  6. D, log: emit the unit count for surviving spans

Spans that survive every stage are forwarded to the inner Langfuse exporter.