Skip to content
Back to student guides
LangfuseLLM observability3 levels90 sections

Langfuse Learning Path

Start at your current experience level. Beginners build and debug a first trace, intermediate learners make tracing reliable in production, and advanced learners design governance, evaluation, privacy, and platform operations.

14sections
13examples

Part one of three: Build and read your first Langfuse trace

Start here if Langfuse is new to you. You need basic Python and one application that calls an LLM. By the end, you will capture a request, read its trace, compare a prompt change, and protect sensitive data.

OBSERVEcapture work
TRACEgroup a request
SCOREmeasure quality
DECIDEchange safely

1. Understand a trace before writing code

Langfuse is an observability and evaluation system for applications that call language models. A trace represents one user request or workflow. Inside it, observations describe work: a span for retrieval, a generation for a model call, a tool for an external action, or an event for a small point-in-time fact.

Do not treat every model call as a separate story. A support request might retrieve policy documents, assemble a prompt, call a model, and format an answer. The trace keeps those observations together so you can ask, “why was this answer slow or wrong?”

Object Meaning Example
Trace End-to-end request support-answer
Span A unit of application work retrieve-policy
Generation A model or completion call answer-model
Event A point-in-time occurrence cache-hit
Score A quality or outcome measurement correctness = 1
The ruleStart with the user request as the root. Children should explain the work needed to produce the answer, not mirror every internal helper function.
Try it
  1. Draw one request as a root box.
  2. Add retrieval, prompt assembly, and model call as children.
  3. Mark the one value you would need to debug a bad answer.
If the tree cannot explain the answer, rename or regroup the observations.

A useful trace tells a causal story. The root names the user-visible operation. Children name the work that contributed to it, and their nesting explains dependency. If retrieval finishes before generation starts, the tree should show the retrieval span and generation as siblings under the same request, with timestamps that reveal the sequence.

trace tree for one support request
support-answer                         TRACE: one user-visible request
|-- validate-input                     SPAN: application work
|-- retrieve-policy                    SPAN: search and ranking
|   |-- vector-search                  SPAN: database call
|   `-- rerank                         SPAN: ranking work
|-- compose-answer                     GENERATION: model input/output/usage
`-- format-response                    SPAN: final application work

Total request time is the root duration.
Model time is the generation duration, not the sum of every child.

The tree answers several questions without reading every payload. A long vector-search child points to retrieval latency. A fast generation with the wrong prompt version points to release configuration. A failed format-response after a successful generation explains why the user saw an error even though the model call completed.

Choose boundaries that match ownership. If the search team owns retrieval, give retrieval a stable span. If one helper function only converts a list to a tuple, tracing it adds noise. Instrument work that can fail, consume material time or cost, cross a service boundary, or answer a recurring debugging question.

2. Capture your first request with Python

Install the SDK and configure credentials outside source control. The v4 API uses one observation API instead of separate span and generation constructors. The @observe decorator is the quickest safe starting point; it records timing, input, output, and errors around a function.

.env
pip install langfuse
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"
app.py
from langfuse import observe, get_client

langfuse = get_client()

@observe(name="support-answer")
def answer_question(question: str) -> str:
    documents = retrieve_policy(question)
    return call_model(question, documents)

@observe(name="retrieve-policy")
def retrieve_policy(question: str) -> list[str]:
    return ["Refunds are available within 30 days."]

@observe(name="call-chat-model", as_type="generation")
def call_model(question: str, documents: list[str]) -> str:
    return "The refund window is 30 days."

The decorator creates a root observation when the function is first entered. Nested decorated functions become children through the active OpenTelemetry context. Use start_as_current_observation when the boundary is clearer in a block.

block.py
from langfuse import get_client

langfuse = get_client()

with langfuse.start_as_current_observation(
    as_type="span", name="support-answer", input={"question": question}
) as root:
    with langfuse.start_as_current_observation(
        as_type="generation", name="answer-model", model="gpt-4.1"
    ) as generation:
        response = run_model(question)
        generation.update(output=response)
    root.update(output={"answer": response})
Try it
  1. Decorate the HTTP handler.
  2. Decorate retrieval and the model call with stable names.
  3. Open a trace and verify the parent-child tree.
The first useful milestone is a readable tree, not a dashboard.

3. Choose the right observation type

Use span for application work and generation for model calls. Use tool for an external action when the integration supports it. Set a useful status message and error level when a child fails; do not hide failure inside a successful root output.

Field Meaning
as_type Semantic kind of observation
level Severity such as DEBUG, DEFAULT, WARNING, or ERROR
status_message Human-readable failure or state
Try it
  1. Mark retrieval as a span.
  2. Mark the provider call as a generation.
  3. Give a failed child a useful status message.
Types and levels help a reader scan a trace quickly.

4. Find traces by user, session, and release

A trace is useful only when you can locate the user, session, release, and environment behind it. Use propagate_attributes near the request boundary so every child observation receives the same correlating fields. Keep metadata small, stable, and non-sensitive.

context.py
from langfuse import observe, propagate_attributes

@observe(name="support-answer")
def handle_request(user_id: str, session_id: str, question: str):
    # 2. Shared fields make the trace searchable without changing node names.
    with propagate_attributes(
        user_id=user_id,
        session_id=session_id,
        metadata={"channel": "web"},
        tags=["support"],
        version="2026.09",
        environment="production",
        trace_name="support-answer",
    ):
        return answer_question(question)
Field Put there Avoid
user_id Stable application user key Email address when not needed
session_id Conversation or workflow key A new random value per child
version Application release A mutable branch name
environment production, staging, or dev User-provided arbitrary text
metadata Small labels for filtering Prompts, tokens, or secrets
Privacy boundaryContext fields are searchable. Use internal identifiers and redact user content before capture. A public key identifies a project; it is not permission to expose customer data.
Try it
  1. Choose one stable user identifier and one session identifier.
  2. Add a release value to a local trace.
  3. Search the UI by session and confirm children share the context.
Context should help you find a request without becoming the request payload.

Correlation fields form an index over your traces. user_id groups a person's requests, session_id reconstructs a conversation, release separates deployments, and environment keeps test traffic out of production analysis. These values should describe the request, not duplicate the full request body.

how shared attributes propagate
HTTP request
  |  user_id=u_42, session_id=s_9, release=2026.09.12
  v
support-answer trace
  |-- retrieve-policy      inherits the same safe attributes
  |-- compose-answer       inherits the same safe attributes
  `-- format-response      inherits the same safe attributes

Search "release=2026.09.12 AND environment=production"
then group failures by observation name or user-safe segment.

Use opaque internal identifiers rather than email addresses or customer names. A searchable field spreads across indexes, exports, dashboards, and retained telemetry. If an operator needs to resolve an opaque ID to a person, keep that mapping in the application system with its existing access controls.

5. Capture failures you can debug

Let exceptions remain visible to the observation boundary. Add a short status message for expected failures, but do not replace the exception with a vague “failed”. Include a stable error category in metadata, never a secret or full customer payload.

Do not swallowA caught exception with no observation update produces a green-looking request and a missing explanation. Preserve the error path and test it.
Try it
  1. Force the model stub to raise.
  2. Open the resulting trace.
  3. Confirm the failing child and root outcome are understandable.
Failure visibility is more valuable than a perfect happy-path demo.

6. Track the prompt and model behind an answer

Hard-coded prompts make changes difficult to compare. Prompt Management stores a named template with versions and labels. Fetch the intentional production label, compile variables, and link the prompt version to the generation that used it.

prompts.py
from langfuse import get_client

langfuse = get_client()
prompt = langfuse.get_prompt("support-answer", label="production")
compiled = prompt.compile(
    company_policy="Refunds are available within 30 days.",
    question="Can I return this item?",
)

with langfuse.start_as_current_observation(
    as_type="generation", name="answer-model", model="gpt-4.1",
    input={"prompt_name": "support-answer", "prompt_version": prompt.version},
) as generation:
    output = run_model(compiled)
    generation.update(output=output)
Practice Why it matters
Name prompts by domain Refactors do not erase meaning
Use production deliberately A random latest version is not a release policy
Record prompt version Quality changes need an explanation
Keep variables explicit Reviewers can reproduce the request

Prompt caching keeps retrieval off the critical path after the first fetch. Still design a fallback for a cold instance and decide which last-known version is safe.

Try it
  1. Create two versions of a short support prompt.
  2. Send the same question through both versions.
  3. Compare prompt version, latency, model, and output in the trace.
A prompt change is a deployment input even when application code did not change.

7. Add a score that measures one outcome

A trace tells you what happened; a score tells you whether the result was useful. Use numeric scores for a measurable range, categorical scores for a named class, and boolean scores for a yes/no outcome. Keep the score name and definition stable.

score.py
from langfuse import get_client

langfuse = get_client()

langfuse.create_score(
    trace_id=trace_id,
    name="correctness",
    value=1.0,
    data_type="NUMERIC",
    comment="Matches the policy answer",
)

langfuse.create_score(
    trace_id=trace_id,
    name="route",
    value="refunds",
    data_type="CATEGORICAL",
)

Start with one score that answers one decision. “Quality” is too broad; “policy-correct” has an observable meaning. Human reviewers can add scores through annotation workflows, while automated evaluators can apply the same named score to many traces.

Useful score

  • Has a definition
  • Has a stable name
  • Explains disagreements

Weak score

  • Combines five ideas
  • Changes meaning weekly
  • Has no owner
Try it
  1. Write a binary correctness rule for one answer type.
  2. Apply it to five traces.
  3. Read every disagreement before changing the prompt.
Measurement improves only after its definition is reviewable.

Scores need a subject and a definition. A trace-level score can describe the final task outcome. An observation-level score can target retrieval or one generation. Put the score at the narrowest level that still matches the claim; otherwise an engineer opens a poor trace and cannot tell which stage earned the result.

TRACEone support request
GENERATIONmodel answer
SCOREgroundedness = 0.82
DATASET ITEMexpected policy behavior

Keep the scoring rule beside its name. quality=0.8 means little without a scale, rubric version, evaluator, and target. Prefer a name such as policy_groundedness_v2, document that 1.0 means every factual claim has policy support, and attach evaluator metadata. Human labels and automated scores may share a concept, but they should retain their source so you can compare agreement.

8. Compare a change on a small test dataset

A dataset is a named collection of test cases. Each item should include an input and, when possible, an expected output or evaluation criterion. Run the same task against fixed cases so prompt and model changes are comparable.

experiment.py
from langfuse import get_client, Evaluation

langfuse = get_client()

def task(*, item, **kwargs):
    return answer_question(item.input)

def exact_match(*, input, output, expected_output, **kwargs):
    value = float(output.strip().lower() == expected_output.strip().lower())
    return Evaluation(name="exact-match", value=value)

result = langfuse.run_experiment(
    name="support-baseline",
    data=[
        {"input": "Refund window?", "expected_output": "30 days"},
    ],
    task=task,
    evaluators=[exact_match],
    max_concurrency=2,
)
print(result.format())

Keep evaluation data redacted and versioned. A production trace can become a regression case after an incident, but only after an owner removes unnecessary identifiers and writes a clear expected behavior.

Try it
  1. Create three redacted cases for one support intent.
  2. Run the current prompt against them.
  3. Save the result as a baseline before editing.
Your first experiment is a comparison instrument, not a claim that one score defines quality.

9. Inspect latency, tokens, and estimated cost

Generations can record model and usage details. A token count without the model, route, and release is difficult to price or explain. Capture provider usage when available, then reconcile estimates against the provider invoice.

usage.py
with langfuse.start_as_current_observation(
    as_type="generation", name="answer-model", model="gpt-4.1",
) as generation:
    response = run_model(question)
    generation.update(
        output=response.text,
        usage_details={
            "input_tokens": response.usage.input_tokens,
            "output_tokens": response.usage.output_tokens,
        },
    )

Use the UI first: filter by release, model, environment, and score. Add a custom report only when the built-in view cannot answer a recurring operational question. A useful first dashboard has request volume, p95 latency, error rate, cost estimate, and quality score by release.

Try it
  1. Compare one model across two releases.
  2. Find the slowest representative trace.
  3. Check whether its prompt or retrieval child is responsible.
Aggregate metrics find the area; a trace explains the cause.

10. Protect keys, customer data, and trace delivery

Use separate projects or environments for development and production. Store the secret key in the deployment secret manager. Public and secret keys must never appear in browser code or committed examples. Langfuse queues data in the background, so short-lived jobs must flush before exit.

safe_client.py
from langfuse import Langfuse

def mask(value):
    if isinstance(value, str):
        return value.replace("alice@example.com", "[EMAIL]")
    return value

langfuse = Langfuse(mask=mask)

try:
    run_job()
finally:
    langfuse.flush()

For larger systems, add retention, access review, sampling, alert ownership, and a documented self-hosting decision. Do not sample away every error trace. A dropped trace is an operational tradeoff, not a free optimization.

Control Beginner decision
Secrets Deployment environment only
PII Mask before ingestion
Delivery flush() short-lived workers
Ownership Name the dashboard and incident owner
Retention Match policy, not convenience
Try it
  1. Put keys in local environment variables.
  2. Send a deliberately sensitive test value through the mask callback.
  3. Run a short script and verify it flushes before exit.
Telemetry is production data. Give it the same care as application data.

11. Separate development from production

Use a development project for experiments and a production project for customer traffic. Keep environment names explicit and rotate credentials through the secret manager. Never solve a local setup problem by copying production keys into a notebook.

Environment Data Key policy
Development Synthetic or redacted Individual development credential
Staging Test fixtures Separate project or environment
Production Minimized customer telemetry Managed secret and reviewed access
Try it
  1. Create a synthetic local request.
  2. Verify its environment field.
  3. Remove the key from shell history after testing.
Environment separation prevents a teaching example from becoming a data incident.

Delivery is asynchronous in many SDK paths. Your request code records observations in memory, the client batches them, and a background worker sends them to Langfuse. This keeps telemetry latency away from the user path, but a short-lived process can exit before the batch leaves.

telemetry delivery path
application thread -> SDK buffer -> background batch -> Langfuse ingest -> UI/index
       |                 |               |                    |
   never block       bounded memory   retry policy       searchable trace
       |
       +-> on shutdown: flush with a bounded timeout

Treat telemetry as a secondary path. An ingestion outage should not make the support API unavailable. Log or measure dropped batches, expose queue health, and flush during worker or CLI shutdown. For a long-running web process, let batching work normally; flushing after every request removes the performance benefit and can amplify an outage.

12. Debug a trace in a repeatable order

Start at the root input and output, then inspect the slowest or failed child, then compare a known-good trace. This order keeps debugging focused. A dashboard tells you where to look; the trace tells you what changed.

Outside in

  • Request outcome
  • Child timing
  • Model input/output
  • Score and version

Guess first

  • Read random logs
  • Change the prompt
  • Rerun one example
  • Declare fixed
Try it
  1. Choose one failed and one successful trace.
  2. Compare their tree shape.
  3. Write the smallest changed field.
Comparison beats intuition.

Beginner checkpoint: can you do these tasks?

Before moving on, you should be able to instrument one request, identify its model generation, find it by session, compare a prompt version, attach a defined score, run a fixed case, inspect usage, and flush a short process.

Try it
  1. Run the complete example from this pane.
  2. Open its trace and locate every field in the checklist.
  3. Explain one safe next change.
Mid-level work begins when the basic path is repeatable.

13. Complete annotated example: trace one support answer

The following combines the beginner path: one root, propagated context, a child generation, explicit usage, and a final flush.

complete.py
from langfuse import get_client, observe, propagate_attributes

langfuse = get_client()

# 1. One root represents the user-visible request.
@observe(name="support-answer")
def answer(*, user_id: str, session_id: str, question: str) -> str:
    # 2. Shared fields make the trace searchable without changing node names.
    with propagate_attributes(
        user_id=user_id,
        session_id=session_id,
        version="2026.09",
        environment="staging",
        metadata={"channel": "web"},
    ):
        # 3. Record the model call as a generation with model and input context.
        with langfuse.start_as_current_observation(
            as_type="generation", name="answer-model", model="gpt-4.1",
            input={"question": question},
        ) as generation:
            output = "Refunds are available within 30 days."
            # 4. Usage and output explain cost and behavior for this call.
            generation.update(
                output=output,
                usage_details={"input_tokens": 18, "output_tokens": 9},
            )
        return output

try:
    answer(user_id="user-42", session_id="session-7", question="Refund window?")
# 5. Short-lived processes must drain buffered telemetry before exit.
finally:
    langfuse.flush()

Next: continue to Part two: reliable production tracing for contracts, experiment gates, cost reconciliation, privacy controls, and rollout ownership.