- Published on
When Traces Become Cost — Retention, Sampling, and Masking in Langfuse
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Opening — The Moment Volume Turns Into Cost
- Four Places Cost Accumulates
- Sampling — The Decision Is Made Per Trace
- What to Exclude From Sampling
- Masking — When Personal Data Ends Up in the Prompt
- Retention — The Default Is Not to Delete
- Lifecycle for the Raw Event Bucket
- Handling Large Payloads
- Reading the Cost Axis From the Data
- Closing — The Order of Reduction
- Try It Yourself
- Series
- References
Opening — The Moment Volume Turns Into Cost
Tracing feels nearly free early on. At a few thousand requests a day, keeping everything costs nothing noticeable. The problem is that the habits from that period harden. When requests hit millions a day and users start pasting whole documents into prompts, storage cost and privacy arrive together. They are two faces of the same problem: you are keeping more than you need.
Component and configuration names here were verified against the official documentation on 2026-08-15. Langfuse's architecture differs by version, so re-check the docs for the version you are running — especially values like plan-specific details that change over time.
Four Places Cost Accumulates
Looking at the storage layer from the third post through a cost lens splits it into four points.
| Point | What accumulates | What reduces it |
|---|---|---|
| ClickHouse | trace, observation, and score rows | Sampling, retention policy |
| Object storage, raw events | Every ingested event | Bucket lifecycle policy |
| Object storage, media | Images and attachments | Size ceiling, retention policy |
| Network and worker | Throughput itself | Sampling, export filtering |
Note that sampling affects the top two rows simultaneously. Never send it from the application and no raw event is created either. A retention policy, by contrast, is a mechanism for deleting what is already stored. In sequence, sampling comes first.
Sampling — The Decision Is Made Per Trace
The sampling documentation defines two configuration paths: the environment variable LANGFUSE_SAMPLE_RATE, and the constructor argument sample_rate in Python or sampleRate in JS/TS. The range is 0 to 1 and the default is 1, meaning collect everything.
from langfuse import Langfuse, get_client
Langfuse(sample_rate=0.5) # collect 50 percent
langfuse = get_client()
The key is the unit of decision. The documentation states that the SDK samples on the trace level: if a trace is sampled, all observations and scores within it are sampled too, and if it is not, none of its observations or scores are sent.
Why that matters is clearest in the opposite case. Sample per observation and you are left with traces full of holes, and decomposing latency with a parent present but children missing produces wrong numbers. A trace-level decision guarantees that what survives is whole and what does not is simply absent.
The JS/TS SDK uses the standard OpenTelemetry sampler.
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
// collect 20 percent
const sampler = new TraceIdRatioBasedSampler(0.2);
What to Exclude From Sampling
Ratio sampling has a well-known trap: rare failures disappear along with everything else. Sample an error that happens ten times a day at 10 percent and one instance survives — and the ten are exactly what you need to investigate. A realistic setup therefore does not stop at one ratio.
- Keep the happy path at a low ratio. A sample is enough to see the distribution of latency and cost.
- Keep failures and anomalous paths in full. Treat runs where the code caught an error separately.
- Handle evaluation datasets independently of sampling.
The other axis is not sending at all. should_export_span from the second post is the place for that. An application already running OpenTelemetry will also send HTTP and database spans that LLM observability does not need, and filtering with predicates such as is_genai_span cuts throughput by exactly that much.
Masking — When Personal Data Ends Up in the Prompt
This is where LLM tracing is most distinctive. An ordinary application trace records identifiers and status codes; an LLM trace records the entire sentence the user typed. And users paste anything.
The masking documentation describes two paths on the Python side, differing in when they apply. The recommended mask_otel_spans works at the export stage, after Langfuse has decided which OpenTelemetry spans this client will export. The older mask runs synchronously as the SDK creates attributes.
from langfuse import Langfuse
def mask_otel_spans(*, params: MaskOtelSpansParams) -> Optional[MaskOtelSpansResult]:
patches = {}
for identifier, span in params.spans.items():
patches[identifier] = OtelSpanPatch(
delete_attributes=("gen_ai.prompt.0.content",),
set_attributes={"masking.applied": True},
)
return MaskOtelSpansResult(span_patches=patches)
langfuse = Langfuse(mask_otel_spans=mask_otel_spans)
JS/TS has a single path: a mask function passed to LangfuseSpanProcessor. The documentation says it is applied to observation input, output, and metadata before sending.
import { LangfuseSpanProcessor } from "@langfuse/otel";
const spanProcessor = new LangfuseSpanProcessor({
mask: ({ data }) => {
return data.replace(/\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, "***MASKED***");
},
});
There is one property you have to internalize. Masking happens inside the application, before the data leaves your infrastructure. It is not a server-side feature. If even one service has masking turned off, that service's prompts are stored verbatim, so this has to be verified per deployable unit.
Regex masking works well on fixed-shape values like card numbers and poorly on names and addresses. So you go two layers deep: erase shaped values with masking, and for fields there was never a reason to keep, turn off collection entirely with capture_input and capture_output from the second post.
Retention — The Default Is Not to Delete
The default behavior the data retention documentation states matters. Without a retention policy, Langfuse does not automatically delete event data, and self-hosted instances store data indefinitely by default. Configure nothing and it accumulates forever.
Configuration is per project. Owners and administrators adjust it in project settings, or you set it through the projects API, and the documentation states a minimum retention window of three days. The deletion targets and the timestamp fields they key off are documented precisely as well.
| Target | Timestamp field used |
|---|---|
| trace | timestamp |
| observation | start_time |
| score | timestamp |
| media assets | created_at |
Deletion runs as a nightly process, and the documentation is blunt that deleted assets cannot be recovered. Before shortening a retention window, the documentation's guidance is to set up Blob Storage Export so data syncs automatically to S3, GCS, or Azure.
Self-hosting carries an extra condition. Enabling retention requires granting the Langfuse IAM role s3:DeleteObject across all buckets, and on versioned S3 buckets the documentation states that delete markers and non-current versions must be removed manually or through lifecycle rules. Miss that and bucket usage will not shrink even with a retention policy turned on.
On Cloud, the default access window varies by plan. As of the date these docs were read: Hobby 30 days, Core 90 days, Pro and Enterprise 3 years. Values like these change, so check the documentation for the plan you are on.
Lifecycle for the Raw Event Bucket
Setting a ClickHouse retention policy alone is only half the job. As the third post covered, the raw ingested events pile up in object storage. The object storage documentation recommends configuring a bucket lifecycle expiration policy that automatically deletes objects after a defined number of days, offering 30 days on Langfuse Cloud as a reference point.
What to weigh when choosing that window is the documented purpose of raw events: retry jobs, replay and disaster recovery, and optional record assembly. How long you need to be able to do those is your retention window.
The documentation adds one more piece of advice. Langfuse tracks uploaded files in the ClickHouse blob_storage_file_log table, and matching that table's TTL to the bucket policy helps optimize processing. Shrink only one side and rows pointing at objects that no longer exist keep piling up.
Handling Large Payloads
Multi-modal inputs and long contexts are their own axis. The relevant values from the configuration documentation are as follows.
# Media limits (documented defaults in the comments)
LANGFUSE_S3_MEDIA_MAX_CONTENT_LENGTH=1000000000 # default 1,000,000,000 bytes
LANGFUSE_S3_MEDIA_DOWNLOAD_URL_EXPIRY_SECONDS=3600 # default 3600 seconds
# Batch export limits
BATCH_EXPORT_PAGE_SIZE=500 # default 500
BATCH_EXPORT_ROW_LIMIT=1500000 # default 1,500,000 rows
# S3 concurrency
LANGFUSE_S3_CONCURRENT_WRITES=50 # default 50
LANGFUSE_S3_CONCURRENT_READS=50 # default 50
A default ceiling of 1 GB is effectively no ceiling at all. In a real service it is safer to set this far lower, so that when a path accidentally attaches a large file to a trace it is blocked at upload rather than in the bucket.
There is also the option of shrinking the payload itself. You rarely need the full text of twenty retrieved documents. Keeping document identifiers, scores, and a summary of the top few preserves what investigation needs while cutting size dramatically. That is not a setting but an instrumentation design question — it means revisiting the boundary you drew in the second post.
Reading the Cost Axis From the Data
So far this has been the cost of the tracing system itself, but the cost of the model calls it observes lives in the same data.
As the first post covered, generations carry usage_details and cost_details. The token and cost tracking documentation explains that cost is calculated at ingestion when usage is ingested or inferred and matches a model definition that includes prices. For self-hosted or in-house models you register a regex pattern and prices under the models entry in project settings, and user-defined models take priority over the models Langfuse maintains.
Which means: register no model definition and those calls show a cost of zero. If your cost dashboard does not line up with the actual bill, this is the first place to look.
Closing — The Order of Reduction
The order goes like this. First decide what not to send — export filters and turning off capture. Then decide how much to send, which is sampling. Then decide what to hide, which is masking. Finally decide how long to keep it, applying a retention policy and a bucket lifecycle together. The earlier steps reduce cost and risk at the same time; the later ones clean up what has already happened.
There are two checks you can run today. Confirm in project settings whether a retention policy exists, and confirm on the storage side whether a bucket lifecycle rule is in place. If both are empty, everything accumulating right now is being kept forever.
Try It Yourself
- LLM API Cost Calculator — calculating model call cost first tells you which side is bigger, that or tracing storage.
- SLO & Error Budget Calculator — when choosing a sampling ratio, work out how large a sample you need to judge the target at all.
Series
- Previous: Self-Hosting Langfuse
- Next: After You Have Collected It — Dashboards, the Metrics API, and Evaluation
References
- Langfuse sampling: https://langfuse.com/docs/observability/features/sampling
- Langfuse masking: https://langfuse.com/docs/observability/features/masking
- Langfuse data retention: https://langfuse.com/docs/administration/data-retention
- Langfuse blob storage: https://langfuse.com/self-hosting/infrastructure/blobstorage
- Langfuse configuration variables: https://langfuse.com/self-hosting/configuration
- Langfuse token and cost tracking: https://langfuse.com/docs/observability/features/token-and-cost-tracking