Skip to content

Session-based sampling

When sample_rate < 1.0 is set in setup_tracing(), the SDK decides whether each trace is exported to Langfuse. This document explains how that decision is made, how session_id makes it conversation-consistent, and what the always_send_errors safety valve does.

Why session consistency matters

A statistical sampling decision made independently per trace produces a coherent sample of individual traces. But for conversational applications, individual traces are turns in a conversation. If turn 1 is sampled in but turn 2 is sampled out, you see a fragment of a conversation that provides no diagnostic value. Session-based sampling guarantees that all turns of the same conversation are always kept or always dropped together.

How the sampling decision is made

The sampling key is the session.id span attribute when it is present, falling back to the trace ID when it is not:

key = span.attributes.get("session.id") or hex(span.context.trace_id)

The decision is deterministic: given the same key and the same rate, the decision is always the same, across all processes, pod replicas, and restarts. No shared state is required — the algorithm is a pure hash:

int(md5(key.encode()).hexdigest(), 16) % 100 < rate * 100

Traces without session.id get an independent keep/drop decision per trace. Traces with the same session.id always get the same decision regardless of their trace ID.

Setting session_id

Use propagate(session_id=...) at the top of each request. The session_id is stamped onto all spans in that scope by SessionIdStampingProcessor during on_start(), before the spans enter the batch queue:

with propagate(
    session_id=request.session_id,
    user_id=request.user_id,
):
    result = await handle(request)

For conversational applications, session_id should be the conversation or thread identifier — a value that is stable across turns. The LiteLLM proxy passes chat_id as session_id automatically (Wave 1 in the integration plan), so RAMgpt traces are already session-grouped in Langfuse without any extra code in the workflow.

When session_id is omitted, each trace is sampled independently. This is correct for batch evaluation workflows and one-shot API calls that are not part of a multi-turn conversation.

Error preservation

always_send_errors=True overrides the sampling decision for any trace that contains at least one ERROR-status span:

setup_tracing(
    service_name="my_workflow",
    sample_rate=0.1,
    always_send_errors=True,
)

At a 10% rate, 90% of successful conversations are dropped. But every failure is exported, regardless of whether that conversation would have been included in the 10% sample. Use this whenever you want to reduce routine observability volume without going blind on failures.

When always_send_errors=True overrides the sample decision, the entire trace is kept — not just the ERROR-status span. All sibling and parent spans of the error span survive.

Partial-trace prevention

The sampling decision is made at the trace level, not the span level. All spans that share a trace ID receive the same keep/drop decision. Langfuse never receives a partial trace where some spans are present and others are absent.

The FilteringExporter groups spans by trace before applying the decision:

  1. Collect all spans in the batch by trace ID
  2. For each trace, look up the session.id from any span in that trace
  3. Compute _should_sample(session_id_or_trace_id, rate)
  4. If the trace is dropped AND always_send_errors=False: discard all spans for that trace
  5. If the trace contains any ERROR span AND always_send_errors=True: keep all spans for that trace

Combining with explicit suppression

propagate(langfuse_export=False) is a hard, per-call exclusion that happens before the sampling stage (step E in the filter pipeline). Suppressed spans are always dropped regardless of sample_rate or always_send_errors. The two mechanisms are independent:

Mechanism Controls
sample_rate Statistical fraction of all traces exported
always_send_errors Override to keep error traces that the rate would have dropped
propagate(langfuse_export=False) Hard exclusion for a specific call; bypasses both

Export a fraction of traces; ensure all errors are always visible regardless of sampling rate.

setup_tracing(
    service_name="my_workflow",
    sample_rate=0.2,
    always_send_errors=True,
)

Export everything — no sampling noise during debugging.

setup_tracing(
    service_name="my_workflow",
    sample_rate=1.0,
)

Suppress Langfuse entirely; OTel spans still reach App Insights.

with propagate(langfuse_export=False):
    for item in eval_batch:
        await run_evaluation(item)

Sample production conversations; control eval traffic independently.

# In the pod's tracing setup
setup_tracing(service_name="my_workflow", sample_rate=0.3)

# For eval traffic — tag and route separately
with propagate(session_id=eval_run_id, tags=["eval"]):
    result = await run_eval_item(item)

# For prod traffic — sample at 30%
with propagate(session_id=request.session_id):
    result = await handle(request)

Cost tracking

Sampling affects what cost data is visible in Langfuse, but cost tracking as a whole is split across two tools that answer different questions.

What Azure Portal tracks

Azure bills every model call regardless of whether the corresponding trace reaches Langfuse. The Azure Portal gives exact, auditable spend at the subscription and resource-group level. This is the authoritative source for "what did this pod cost this month?"

What Langfuse tracks

Langfuse records token counts and estimated cost per trace. Because only sampled traces reach Langfuse, the cost figures there represent the sampled fraction only — not the full spend. At a sample_rate of 0.3, roughly 30% of conversations are traced; the remaining 70% produce no Langfuse record and no Langfuse cost entry. The cost of a traced generation is only as right as the project's model definition for its model; see 14-tracing-model-prices.md.

What Langfuse provides that Azure does not is per-trace cost granularity: which conversation used the most tokens, which model, how the cost breaks down across spans. That per-conversation view does not exist in Azure billing.

Approximating total cost from sampled data

At any sample_rate < 1.0, the total cost can be approximated by dividing the Langfuse aggregate by the sampling factor:

estimated_total_cost ≈ langfuse_observed_cost / sample_rate

At 50% sampling, the Langfuse figure is roughly half the actual spend; multiply by 2 to estimate the total. At 20% sampling, multiply by 5.

This approximation is statistically reliable when traffic volume is high. A pod handling thousands of conversations per day gives a large enough sample that the distribution of token counts and model choices in the sampled traces closely mirrors the full population. For low-traffic pods or during initial rollout (few traces in Langfuse), the approximation has wider error bars.

Summary

Question Tool Accuracy
Total cost for this pod Azure Portal Exact
Cost per conversation / trace Langfuse Approximate (÷ sample_rate)

Use Azure for billing reconciliation and budget alerts. Use Langfuse for relative comparisons: which conversation types are expensive, which models are used most, how cost scales with conversation length.