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:
- Collect all spans in the batch by trace ID
- For each trace, look up the
session.idfrom any span in that trace - Compute
_should_sample(session_id_or_trace_id, rate) - If the trace is dropped AND
always_send_errors=False: discard all spans for that trace - 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 |
Recommended configurations
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.