How do you create nested Langfuse observations without the @observe decorator?
answer
- two creation methods, one difference
- context manager opens and closes
- the other one you must end
- update() fills it in afterwards
- trace-level input has its own call
basics
~20 sGet the client with get_client(), then use start_as_current_observation(as_type=...) as a context manager: it opens an observation, makes it current so nested work attaches beneath it, and ends it on exit. start_observation() does not make it current, so you call end() yourself.
solid answer
~40 sThe v4 client exposes two creation methods. `langfuse.start_as_current_observation(as_type="span", name=...)` is a context manager: it opens the observation, makes it the active one so anything created inside becomes a child, and ends it when the block exits. `langfuse.start_observation(...)` creates one *without* making it active and without auto-ending it — you hold the object, update it, and call `.end()` when the work finishes, which is what you need for work that spans callbacks or outlives the current block. The returned object has `update(...)`, `create_event(...)`, `score(...)` and its own `start_observation` / `start_as_current_observation` for explicit children. From inside a block you can also reach the active one with `langfuse.update_current_span(...)` or `update_current_generation(...)`. For trace-level input and output, call `span.set_trace_io(input=..., output=...)` — note that the v3 spellings `span.update_trace(...)` and `langfuse.update_current_trace(...)` do not exist in the v4 SDK.
code
python · 19 linesfrom langfuse import get_client
langfuse = get_client()
with langfuse.start_as_current_observation(as_type="span", name="rag-pipeline") as root:
with langfuse.start_as_current_observation(as_type="retriever", name="search") as retriever:
retriever.update(input={"query": "langfuse"}, output=["doc-1", "doc-2"])
generation = langfuse.start_observation(
as_type="generation",
name="answer",
model="gpt-4o-mini",
)
generation.update(output="a grounded answer")
generation.end()
root.set_trace_io(input="langfuse", output="a grounded answer")
langfuse.flush()go deeper
Know that you can open an observation yourself with the client's context manager instead of decorating a function, and that everything created inside the block nests under it.
State the difference between the two creation calls — one becomes current and ends itself, the other does neither — and know update(), create_event() and how to set trace-level input and output.
Handle the awkward cases: observations whose lifetime crosses a callback, explicit parenting under concurrency, and migrating code that still calls removed v3 trace-update methods.
Define the instrumentation style teams follow — decorators for request shape, manual spans only where lifetimes demand it — so traces stay comparable across services instead of each team inventing its own depth.
## Why you need this at all The `@observe` decorator's unit is a function. Plenty of things you want to time are not functions: a block in the middle of a handler, a loop iteration, a piece of work that starts on one code path and finishes on another. The manual API covers those, and it composes freely with decorated functions — a decorated function can open context-managed children, and a manual span can contain decorated calls. ## The two creation methods **`start_as_current_observation(as_type=..., name=...)`** — a context manager. Entering it opens the observation *and* makes it the currently active one; leaving the block ends it, recording the duration, and restores the previous active observation. Because it is active for the duration of the block, anything created inside — another manual observation, a decorated function call, an instrumented model call — nests beneath it automatically. This is the default choice. **`start_observation(as_type=..., name=...)`** — creates the observation and returns it *without* making it active and *without* ending it. You are responsible for calling `.end()`. Use it when: - the work finishes somewhere else — a callback, a completion handler, a later event; - you want a child that must not capture unrelated concurrent work happening in the same block; - you are fanning out concurrent tasks and each needs its own object rather than a shared implicit context. The cost of the manual form is the obvious one: forget `.end()` and you ship an observation with no end time. ## Filling the observation in The object returned by either method carries the mutation surface: - `update(...)` — set input, output, metadata, level, and type-specific fields such as model or usage details on a generation. Call it as many times as you like; the last write wins. - `create_event(name=...)` — record a point-in-time marker beneath this observation, for things with no duration. - `score(...)` — attach a score to this observation, and `score_trace(...)` to score the whole trace. - `start_observation(...)` / `start_as_current_observation(...)` — create explicit children of *this* observation, regardless of what is currently active. This is the escape hatch for concurrency, where implicit nesting is unreliable. - `end()` — close it, when you created it with the non-context form. From inside a `start_as_current_observation` block you can also skip holding the object and use the client-level helpers: `langfuse.update_current_span(...)` and `langfuse.update_current_generation(...)` mutate whatever is active. Handy in a helper function that should annotate its caller's observation without knowing which one it is. ## Trace-level fields from inside a span A common need: you are three levels deep and want to set the *trace's* input and output, not the span's. In v4 that is `span.set_trace_io(input=..., output=...)`, with `span.set_trace_as_public(...)` alongside it for sharing. This is a place where stale knowledge bites. The v3 SDK had `langfuse.update_current_trace(...)` and `span.update_trace(...)`; **neither exists in the v4 SDK**, and code carried over from a v3 tutorial fails with an attribute error. For trace-level *attributes* like user id, session id and tags, v4 uses a separate module-level context manager rather than a client method. ## Choosing a shape In practice most codebases end up with a hybrid: decorators on the coarse functions that define the shape of a request, and manual context-managed observations inside the two or three functions where the interesting sub-steps live. Reach for `start_observation` only where the lifetime genuinely does not match a block — it is the sharpest tool here and the easiest to leave dangling. For async and concurrent code, prefer explicit parenting: create children off the parent object you hold rather than relying on the active context, because whether context follows a task depends on how it was scheduled. Orphaned traces that should have been children almost always trace back to implicit nesting across a concurrency boundary. ## Interview framing Name both methods and state the difference in one sentence — one makes itself current and self-ends, the other does neither. Then say what you would use each for, mention `update()` and the trace-level `set_trace_io`, and flag that the v3 trace-update method names are gone in v4. That last detail is a cheap way to show you are working from the current SDK rather than from memory.
- When would you deliberately choose start_observation over the context-manager form?When the observation's lifetime does not match a block: work completed in a callback, a request whose response arrives later, or concurrent tasks that each need their own object rather than sharing an implicit active context. You keep the returned object, update it as results arrive, and call end() at the real completion point — accepting that forgetting end() leaves an observation with no duration.
- Code copied from an older tutorial calls langfuse.update_current_trace(...) and fails. What is the v4 equivalent?That method was removed. For trace-level input and output, call set_trace_io(input=..., output=...) on a span object. For trace attributes such as user id, session id and tags, v4 uses the module-level propagate_attributes context manager instead of a client method. The same applies to span.update_trace(), which is also a v3 name with no v4 counterpart.
- You fan out three concurrent sub-tasks inside a span. How do you keep their observations nested correctly?Create each child explicitly off the parent object you already hold, using its own start_observation, rather than relying on the currently-active context inside each task. Whether context follows a task depends on how it was scheduled, so explicit parenting removes the guesswork — and it also stops three concurrent blocks from fighting over which one is "current".
saying these in an interview costs you the question
- Forgets to call end() on a manually started observation
- Assumes every creation method makes the observation current
- Uses v3 update_current_trace() on the v4 SDK
- Relies on implicit nesting across concurrent tasks
- Thinks manual spans and @observe cannot be mixed