Your Langfuse Trace Has No Customer. That Is Why You Cannot Query It.
A readable Langfuse tree is still unqueryable until you propagate user, session, tags, metadata, and environment onto every observation.

Langfuse sessions, user IDs, tags, and environments do not appear because you named the spans well. They appear because you propagated them onto every child.
You can have a perfect, named, nested tree and still cannot answer what this customer saw or whether staging just got expensive. Langfuse sessions, user IDs, tags, and environments do not appear because you named the spans well.
In this article: You will learn why a clean Langfuse tree still cannot answer production questions until you attach users, sessions, tags, metadata, and environments. We cover
propagate_attributesin Python andpropagateAttributesin TypeScript, how Harbor Desk maps one trace per ticket message and one session per thread, and the gotcha that leaves siblings unlabeled. By the end you will label every child from the active context instead of hoping one field copies itself.
The tree looks right. Harbor Desk, a support-ticket assistant that classifies a ticket, retrieves a policy snippet, and drafts a reply, now records a named, nested request. You can read handle-ticket, classify-intent, the policy lookup, and draft-reply. You still cannot answer the two questions that show up on a Monday morning: what did customer maya@harbor see, and did staging just get expensive?
Those facts are not in the observation names. They are labels you hang on the tree after the structure is already useful.
The mechanism is the same in both SDKs. Python uses propagate_attributes. JS and TS use propagateAttributes. You set user_id, session_id, tags, metadata, and sometimes environment on the active context. Every observation created inside that block inherits them. Setting a field on one child and hoping the siblings pick it up is the bug this article exists to prevent.

Langfuse sessions: one id per ticket thread
Many LLM interactions span multiple traces. A session groups those traces so you can replay the whole interaction. You propagate a sessionId. The id can be any US-ASCII string under 200 characters. Longer ids are dropped. Every observation with that id, and the traces that enclose those observations, appear together.
For Harbor Desk the mapping is mechanical.
- One trace per ticket message.
- One session per ticket thread. Use the shop's ticket id:
ticket-4412. - You do not know when the customer will stop replying, so you never wait to close the session.
Maya writes about the soaked life jacket. That is trace A, session ticket-4412. She replies "store credit, not a refund." That is trace B, same session. The session view is the replay. Each trace stays small enough to score.

A session is not a mega-trace you hold open until the customer goes quiet. You do not know when the thread ends. You stamp the same id on each new turn and let the session view collect them.

This listing stamps the Harbor Desk ticket handler so every child observation inherits the customer, the ticket session, and the slice labels. The root span opens first; propagate_attributes sits inside it, around the work that will create children.
from langfuse import get_client, propagate_attributes
from langfuse.openai import openai
langfuse = get_client()
TICKET_ID = "ticket-4412"
CUSTOMER_ID = "maya@harbor"
TICKET = (
"Order 4412. The life jacket arrived soaked and smells like bilge. "
"I want a refund, not a replacement."
)
with langfuse.start_as_current_observation( # ①
as_type="span",
name="handle-ticket",
input={"ticket": TICKET},
) as root:
with propagate_attributes( # ②
user_id=CUSTOMER_ID, # ③
session_id=TICKET_ID,
tags=["refund-flow"], # ④
metadata={"order_id": "4412"},
version="harbor-desk-0.1.0",
):
# classify / retrieve / draft from Part 2 go here.
# Every child observation inherits user, session, tags, metadata.
pass
root.update(output={"status": "ok"})
langfuse.flush() # ⑤
① start_as_current_observation opens the root handle-ticket span and records the ticket text as input.
② propagate_attributes wraps the children from inside that span, so inheritance applies to observations created in the block.
③ user_id and session_id are the customer and the ticket thread; Maya's follow-up reuses ticket-4412.
④ tags, metadata, and version are the slice labels: the flow, the order id, and this Harbor Desk build.
⑤ flush() sends the queued events before a short-lived script exits.
Note: The full extracted listing at code/langfuse/part-4-users-sessions-attributes/listings/01-propagate-attributes.py is the complete runnable program.
JS uses camelCase keys (userId, sessionId) and an async callback:
import { startActiveObservation, propagateAttributes } from "@langfuse/tracing";
await startActiveObservation( // ①
"handle-ticket",
async (root) => {
root.update({ input: { ticket } }); // ②
await propagateAttributes( // ③
{
userId: "maya@harbor", // ④
sessionId: "ticket-4412",
tags: ["refund-flow"],
metadata: { orderId: "4412" },
version: "harbor-desk-0.1.0",
},
async () => {
// classify / retrieve / draft
},
);
},
{ asType: "span" },
);
① startActiveObservation opens the root span with an async callback instead of a with block.
② root.update sets the ticket input on that span; the Python listing passed input= at construction.
③ propagateAttributes takes the attribute bag first, then the async work that creates children.
④ Keys are camelCase (userId, sessionId, orderId); the values are the same customer, ticket, and slice labels.
Note: The full extracted listing at code/langfuse/part-4-users-sessions-attributes/listings/02-propagate-attributes.ts is the complete runnable program.
Gotcha: Put propagate_attributes inside the root span, around the children. Do not wrap an empty function after the work has already run. Inheritance is for observations created in the block, not for ones that you already closed.
Users: who triggered this, and what did they cost?
The Users view is an overview of everyone you have labeled, plus a drill-in per person. Propagate userId. It can be a username, an email, or any unique identifier. It is optional. Without it, you cannot aggregate cost or volume by customer, and you cannot open "Maya's last ten tickets."
Harbor Desk uses the shop's customer id (maya@harbor). Do not put that id in the observation name. Names stay stable. The customer lives on the attribute.
Environments: keep staging out of production dashboards
Environments organize traces, observations, scores, and sessions from different contexts in the same project: production, staging, and development. You reuse datasets and prompts across them. You do not want local experiments in the production cost chart.
The recommended approach is to set LANGFUSE_TRACING_ENVIRONMENT. You can also pass environment when you construct the client. The init parameter wins if both are set. The default, if you set nothing, is default.
The value must match ^(?!langfuse)[a-z0-9-_]+$ and is at most 40 characters. Allowed characters are lowercase letters, numbers, hyphens, and underscores. The value cannot start with langfuse.
LANGFUSE_TRACING_ENVIRONMENT=development
Sometimes the environment belongs to the incoming request rather than the process, as when one proxy serves all four stages. Python can set it per scope with propagate_attributes(environment="staging"). Use as_baggage=True if the value has to cross a service boundary.
In production: Set LANGFUSE_TRACING_ENVIRONMENT=production in the deploy, not in application code that you might copy into a notebook.

Tags, metadata, releases
Tags are business-level labels such as refund-flow and policy-v2. They are immutable and must be set at creation time. They are for things that you know up front: which flow and which surface. A judgment that you learn later, for example that the draft was unhelpful, is a score, not a tag.
Metadata is the flexible key-value store. Use it for request ids, the order number, retrieval stats, raw payloads, and anything an evaluator might need that does not belong in I/O. Propagated metadata values are strings with a maximum of 200 characters. Keys are alphanumeric. Oversized values are dropped.
Release is the application version, usually a semver or a git sha. The SDKs look for it on SDK init, then in an environment variable, then in automatic identifiers on popular platforms. Version on an observation, which is what the snippet above sets, is the component-level marker. It identifies this Harbor Desk build, or later this prompt version. Releases answer "what happened after we shipped v2.1.24?" Versions answer "which component inside that release?"
You already have names you will not change. These attributes are how you slice the same names by customer, thread, environment, and deploy.

Do this today
Do not rewrite classify, retrieve, or draft. Label the tree you already have.
- Wrap the body of
handle-ticketinpropagate_attributes. Pass the ticket id, the customer id, andmetadata={"order_id": "4412"}. - Pass
tags=["refund-flow"]when classify said refund, or set the tag that you know at the start of the request. - Run two messages on
ticket-4412and open the session view. You want both turns, the same customer, and the same environment. - If the second turn is missing the session id, you set it on the first child and not via propagate. Fix the call site, not the UI.
The tree is the structure. Attributes are the questions.
The tree is how Harbor Desk happened. Attributes are how you ask questions of it. propagate_attributes / propagateAttributes is the whole trick: set user_id, session_id, tags, and metadata on the context so every child inherits them. Use one trace per turn, one session per Harbor Desk ticket, and the environment from the deploy.
Send Maya's follow-up as a second trace on ticket-4412 and open the session replay. The labels travel with the work. The next job is wrapping the same ticket handler in an agent loop without giving those labels up.
A pretty tree that nobody can query is still just a tree.