Skip to content

@observe(as_type=…, capture_output=False) silently downgrades observation to type=span #1676

@xRME-18

Description

@xRME-18

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. The combination isn't documented as unsupported in the SDK
    reference. It's a runtime contradiction, not a documented
    constraint.

Suggested fix

Either of:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions