Summary
@observe accepts both as_type=… and capture_output=False. Either flag
works correctly in isolation. Combining them silently records the
observation as type=span instead of the requested type — no warning,
no error, no exception.
In our codebase we needed as_type="guardrail" (for Langfuse's typed UI)
and capture_output=False (because the body sets a richer output via
update_current_span(output=…) and the function's bool return value
would otherwise clobber it). The two flags address orthogonal concerns
and intuitively compose — but using them together silently downgrades
the observation kind, breaking the typed-observation contract.
Result: traces the application author intended to surface in Langfuse's
typed UI (guardrail badges, evaluator dashboards, tool-call filters)
fall back to opaque span-typed rows. The intent is lost without any
diagnostic.
Reproduction (langfuse 4.7.0)
import asyncio
from langfuse import Langfuse, get_client, observe
Langfuse() # init singleton, keys via env
lf = get_client()
@observe(name="probe.with_capture_output_false", as_type="guardrail", capture_output=False)
async def broken():
# capture_output=False is needed when the function body sets a richer
# output via update_current_span(output=...) and the return value would
# otherwise clobber it.
lf.update_current_span(output={"verdict": "manually set"})
@observe(name="probe.without_capture_output_false", as_type="guardrail")
async def works():
return {"verdict": "auto captured"}
async def main():
with lf.start_as_current_observation(name="probe.root", as_type="span"):
await broken()
await works()
lf.flush()
asyncio.run(main())
Observation types after ingestion (verified via /api/public/observations):
| Function |
Expected |
Actual |
probe.with_capture_output_false |
GUARDRAIL |
SPAN ❌ |
probe.without_capture_output_false |
GUARDRAIL |
GUARDRAIL ✓ |
Re-verified on 4.7.0 (released 2026-05-27). Also reproduces on 4.6.1 and 4.0.6.
Why this is a problem
- The two flags are orthogonal in intent.
as_type selects the
UI/analytics treatment (typed icon, filter chip, dashboard
aggregation). capture_output=False opts out of auto-output-capture
so an explicit update_current_span(output=…) from inside the
function body isn't clobbered. They naturally compose for any
guardrail / evaluator / tool wrapper that wants a typed observation
AND a custom output shape.
- Typed observations gate a meaningful chunk of Langfuse-native UX —
guardrail status badges, evaluator score aggregations in the
Datasets/Evals view, per-type filter chips in the trace list,
tool-call rendering. A silent downgrade means none of those views
pick up the observation.
- The downgrade is silent. No warning, no log line, no exception.
Trivially missed in code review and in deployed code; you only
notice when the observation doesn't appear under the expected
filter in the Langfuse UI.
- The workaround (drop
@observe, use explicit
start_as_current_observation) is more verbose and loses the
decorator's signature-preservation and async-vs-sync auto-handling.
Doable, but the decorator should support the same composition.
- The combination isn't documented as unsupported in the SDK
reference. It's a runtime contradiction, not a documented
constraint.
Suggested fix
Either of:
- Honour
as_type regardless of capture_output (preferred).
The OTel observation kind and the output-capture mechanism aren't
coupled in the underlying span model — setting one shouldn't affect
the other. Likely a small change in the decorator path where the
typed-as kind is dropped when capture_output=False causes the
span builder to take a different branch.
- Raise at decoration time if the combination is genuinely
unsupported, with a message pointing to the explicit-observation
workaround. Beats silent fallback.
A silent type downgrade is the worst of the three options.
Version info
- langfuse-python: 4.7.0 (also reproduces on 4.6.1 and 4.0.6)
- Python: 3.11.12
- macOS 14,
cloud.langfuse.com backend
Current workaround we use
Replace the @observe decorator with explicit
start_as_current_observation, and call obs.update(output=…) inside
the with-block:
async def run_output_guardrails(...):
lf = get_client()
with lf.start_as_current_observation(
name="output_guardrails",
as_type="guardrail",
input={...},
) as obs:
result = await guardrails_service.check_message(...)
obs.update(output={
"blocked": not result.safe,
"safe": result.safe,
"violations": result.violations,
"checkers": [...],
})
return not result.safe
This works — both as_type and the explicit output stick. It's
strictly more boilerplate than the @observe form would be, and you
give up the decorator's signature preservation.
Happy to send a PR — the cleanest path is probably preserving the
typed-as kind on the OTel observation regardless of capture_output,
since the two are not coupled in the underlying span model.
Summary
@observeaccepts bothas_type=…andcapture_output=False. Either flagworks correctly in isolation. Combining them silently records the
observation as
type=spaninstead of the requested type — no warning,no error, no exception.
In our codebase we needed
as_type="guardrail"(for Langfuse's typed UI)and
capture_output=False(because the body sets a richer output viaupdate_current_span(output=…)and the function's bool return valuewould otherwise clobber it). The two flags address orthogonal concerns
and intuitively compose — but using them together silently downgrades
the observation kind, breaking the typed-observation contract.
Result: traces the application author intended to surface in Langfuse's
typed UI (guardrail badges, evaluator dashboards, tool-call filters)
fall back to opaque
span-typed rows. The intent is lost without anydiagnostic.
Reproduction (langfuse 4.7.0)
Observation types after ingestion (verified via
/api/public/observations):probe.with_capture_output_falseGUARDRAILSPAN❌probe.without_capture_output_falseGUARDRAILGUARDRAIL✓Re-verified on 4.7.0 (released 2026-05-27). Also reproduces on 4.6.1 and 4.0.6.
Why this is a problem
as_typeselects theUI/analytics treatment (typed icon, filter chip, dashboard
aggregation).
capture_output=Falseopts out of auto-output-captureso an explicit
update_current_span(output=…)from inside thefunction body isn't clobbered. They naturally compose for any
guardrail / evaluator / tool wrapper that wants a typed observation
AND a custom output shape.
guardrail status badges, evaluator score aggregations in the
Datasets/Evals view, per-type filter chips in the trace list,
tool-call rendering. A silent downgrade means none of those views
pick up the observation.
Trivially missed in code review and in deployed code; you only
notice when the observation doesn't appear under the expected
filter in the Langfuse UI.
@observe, use explicitstart_as_current_observation) is more verbose and loses thedecorator's signature-preservation and async-vs-sync auto-handling.
Doable, but the decorator should support the same composition.
reference. It's a runtime contradiction, not a documented
constraint.
Suggested fix
Either of:
as_typeregardless ofcapture_output(preferred).The OTel observation kind and the output-capture mechanism aren't
coupled in the underlying span model — setting one shouldn't affect
the other. Likely a small change in the decorator path where the
typed-as kind is dropped when
capture_output=Falsecauses thespan builder to take a different branch.
unsupported, with a message pointing to the explicit-observation
workaround. Beats silent fallback.
A silent type downgrade is the worst of the three options.
Version info
cloud.langfuse.combackendCurrent workaround we use
Replace the
@observedecorator with explicitstart_as_current_observation, and callobs.update(output=…)insidethe with-block:
This works — both
as_typeand the explicit output stick. It'sstrictly more boilerplate than the
@observeform would be, and yougive up the decorator's signature preservation.
Happy to send a PR — the cleanest path is probably preserving the
typed-as kind on the OTel observation regardless of
capture_output,since the two are not coupled in the underlying span model.