Skip to content

fix(observe): preserve current observation type on span update#1705

Merged
hassiebp merged 3 commits into
mainfrom
codex/fix-current-span-type-preservation
Jun 15, 2026
Merged

fix(observe): preserve current observation type on span update#1705
hassiebp merged 3 commits into
mainfrom
codex/fix-current-span-type-preservation

Conversation

@hassiebp

@hassiebp hassiebp commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What changed

  • Preserve the active span's existing Langfuse observation type when update_current_span(...) is called.
  • Add a focused unit regression for @observe(as_type="guardrail", capture_output=False) when the function body sets output via update_current_span(...).

Why

update_current_span(...) wrapped the active OTEL span as a generic LangfuseSpan, which rewrote langfuse.observation.type to span. With capture_output=False, the decorator skips its final output update, so typed observations such as guardrail stayed downgraded to span.

Fixes #1676.

Verification

  • uv run --frozen ruff check .
  • uv run --frozen mypy langfuse --no-error-summary
  • uv run --frozen pytest tests/unit/test_observe.py tests/unit/test_otel.py::TestBasicSpans::test_start_as_current_observation_types -q

Greptile Summary

This PR fixes a bug where calling update_current_span(...) on a typed observation (e.g., as_type="guardrail") would silently downgrade the span's langfuse.observation.type attribute to "span" because update_current_span unconditionally wrapped the active OTEL span in a plain LangfuseSpan. The issue was most visible with capture_output=False since the decorator skips its final output write, leaving the clobbered type in place.

  • langfuse/_client/client.py: update_current_span now reads the existing OBSERVATION_TYPE attribute from the active span before wrapping it, then delegates to the correct typed class via _get_span_class. The _get_span_class signature is loosened from ObservationTypeLiteral to str to accommodate runtime attribute values.
  • tests/unit/test_observe.py: Adds a focused async regression test for @observe(as_type="guardrail", capture_output=False) combined with an in-body update_current_span(output=...) call, asserting both type and output are preserved.

Confidence Score: 5/5

The change is safe to merge — it makes a minimal, targeted fix to update_current_span with a direct regression test, and the fallback path for unknown types matches prior behavior exactly.

The fix reads one attribute from the active span and routes to the already-existing _get_span_class helper — no new logic is introduced. The typed subclasses force their own as_type in init, so the round-trip write back to the span attribute is always consistent. The added test directly exercises the previously broken path and confirms both the type and output attributes are preserved.

No files require special attention.

Sequence Diagram

sequenceDiagram
    participant D as @observe decorator
    participant F as guardrail_check()
    participant C as Langfuse.update_current_span()
    participant S as OTEL Span

    D->>S: "start span, set OBSERVATION_TYPE="guardrail""
    D->>F: invoke function body
    F->>C: "update_current_span(output={"verdict": "manually set"})"
    C->>S: read OBSERVATION_TYPE → "guardrail"
    C->>C: _get_span_class("guardrail") → LangfuseGuardrail
    C->>S: "LangfuseGuardrail.__init__ sets OBSERVATION_TYPE="guardrail" (preserved)"
    C->>S: "span.update(output=...) writes output attribute"
    F-->>D: return True
    Note over D: capture_output=False → skip final output write
    D->>S: end span
    Note over S: OBSERVATION_TYPE="guardrail" ✓, output preserved ✓
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into codex/fix-curre..." | Re-trigger Greptile

@hassiebp hassiebp marked this pull request as ready for review June 12, 2026 13:01
@github-actions

Copy link
Copy Markdown

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 langfuse/_client/client.py:1377-1391 — The sister method update_current_generation (lines 1304-1309, just above the diff hunk) was not migrated and still unconditionally constructs LangfuseGeneration(otel_span=..., langfuse_client=self), which forces langfuse.observation.type back to "generation". Because ObservationTypeGenerationLike = Literal["generation", "embedding"], an @observe(as_type="embedding", capture_output=False) function that calls langfuse.update_current_generation(output=...) silently downgrades the exported type from "embedding" to "generation" — the exact #1676 symptom on the sibling method. Fix by applying the same dispatch you just added: read LangfuseOtelSpanAttributes.OBSERVATION_TYPE off the OTEL span and route through self._get_span_class(...) (the signature widening to str already supports this).

    Extended reasoning...

    What is wrong

    This PR establishes a contract — "update_current_* must preserve the active observation type" — and implements it for update_current_span at langfuse/_client/client.py:1377-1393. But the sister method update_current_generation directly above it (client.py:1304-1309) was not migrated and still runs:

    generation = LangfuseGeneration(
        otel_span=current_otel_span, langfuse_client=self
    )

    LangfuseGeneration.__init__ chains to LangfuseObservationWrapper.__init__ with as_type="generation", and that base __init__ unconditionally rewrites the attribute (langfuse/_client/span.py:120-123):

    self._otel_span = otel_span
    self._otel_span.set_attribute(
        LangfuseOtelSpanAttributes.OBSERVATION_TYPE, as_type
    )

    So every call to update_current_generation re-stamps the attribute to "generation", regardless of what it was before.

    Why existing code does not prevent it

    ObservationTypeGenerationLike in constants.py:15-18 is Literal["generation", "embedding"]embedding is a first-class generation-like type the SDK exposes via @observe(as_type="embedding", ...). With capture_output=False, the decorator skips its own final output write, so the only output write is whatever the user does inside the function body. If the user reaches for update_current_generation (the natural method name for a generation-like observation), the type gets clobbered with no fallback to fix it up.

    The new update_current_span path guards this by reading the existing OBSERVATION_TYPE attribute off the OTEL span and dispatching through the widened _get_span_class (which now accepts str, client.py:1071). That same dispatch infrastructure already supports the embedding/generation case — the call site at line 1307 just was not migrated.

    Impact

    After this PR, the type-preservation contract is half-applied: update_current_span preserves all seven span-like types plus the two generation-like ones, but update_current_generation still downgrades embeddinggeneration. The PR description says "Fixes #1676", but #1676's root cause (wrapper construction overwriting OBSERVATION_TYPE during an update) is left open on the sibling method.

    Step-by-step proof

    1. User code:
      @observe(as_type="embedding", capture_output=False)
      def embed_text(text):
          vec = compute_embedding(text)
          langfuse.update_current_generation(output=vec)
          return vec
    2. The decorator opens the span with as_type="embedding". LangfuseObservationWrapper.__init__ calls set_attribute(OBSERVATION_TYPE, "embedding"). ✓
    3. Inside the body, update_current_generation(output=vec) runs. At client.py:1307-1309 it constructs LangfuseGeneration(otel_span=current_otel_span, langfuse_client=self).
    4. LangfuseGeneration.__init__ (span.py:1347-1348) calls super().__init__(as_type="generation", ...).
    5. LangfuseObservationWrapper.__init__ at span.py:121-123 unconditionally calls self._otel_span.set_attribute(LangfuseOtelSpanAttributes.OBSERVATION_TYPE, "generation") — overwriting the "embedding" from step 2.
    6. capture_output=False means the decorator's exit path does not re-write OBSERVATION_TYPE either.
    7. Exported span attribute langfuse.observation.type = "generation" instead of "embedding".

    How to fix

    Apply the same pattern from update_current_span at the update_current_generation call site:

    existing_observation_type = (
        current_otel_span.attributes.get(
            LangfuseOtelSpanAttributes.OBSERVATION_TYPE, "generation"
        )
        if current_otel_span.is_recording()
        else "generation"
    )
    span_class = self._get_span_class(existing_observation_type)
    generation = span_class(otel_span=current_otel_span, langfuse_client=self)

    A regression test mirroring the new test_capture_output_false_preserves_type_when_current_span_is_updated but with as_type="embedding" and update_current_generation would lock this in.

Comment thread langfuse/_client/client.py
@hassiebp hassiebp disabled auto-merge June 15, 2026 11:24
@hassiebp hassiebp merged commit 5f6e4c1 into main Jun 15, 2026
18 of 19 checks passed
@hassiebp hassiebp deleted the codex/fix-current-span-type-preservation branch June 15, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant