Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions docs/concepts/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,26 +610,55 @@ graph.attach_observer(observer)
The `client` is anything matching the `LangfuseClient` Protocol —
the bundled `InMemoryLangfuseClient` (used by the conformance
harness, useful for unit tests), or a real `langfuse.Langfuse()`
instance from the [Langfuse Python SDK](https://github.com/langfuse/langfuse-python).
The Protocol declares only the methods the observer calls, so SDK
versions whose shape matches drop in directly. SDK versions whose
shape diverges (renamed kwargs, return-type quirks) plug in via a
small adapter; see
instance wrapped in `LangfuseSDKAdapter` for production. Install
the optional extras to bring in the Langfuse SDK:

```bash
pip install 'openarmature[langfuse]'
```

Production wire-up:

```python
from langfuse import Langfuse
from openarmature.observability.langfuse import (
LangfuseObserver,
LangfuseSDKAdapter,
)

langfuse_client = Langfuse(
public_key="pk-lf-...",
secret_key="sk-lf-...",
host="https://cloud.langfuse.com",
)
observer = LangfuseObserver(
client=LangfuseSDKAdapter(langfuse_client),
disable_llm_payload=False,
)
```

The adapter bridges `langfuse>=4.6`'s unified `start_observation`
API onto our `LangfuseClient` Protocol; the observer code is the
same in tests and production. See
[`examples/10-langfuse-observability`](../examples/10-langfuse-observability.md)
for the runnable demo plus the adapter shape.
for a runnable demo.

!!! note "Langfuse SDK version compatibility"

No specific `langfuse` SDK version is validated in CI as of this
release. The Protocol mirrors the SDK's documented low-level
`trace` / `span` / `generation` shape, but the SDK has shifted
between major versions (v2 → v3 introduced API changes). A
follow-on release pins a tested `[langfuse]` extras range and
ships a runtime `isinstance` check confirming the SDK satisfies
the Protocol. Until then, treat production wire-up as a "verify
in your own environment" path: bring the langfuse version your
stack already uses, run a smoke trace, and write a thin adapter
if any kwargs don't line up.
Validated against `langfuse>=4.6,<5`. The v4 SDK introduced an
OTel-based architecture with `start_observation` /
`propagate_attributes` replacing the v2/v3 `trace` / `span` /
`generation` low-level API; the bundled `LangfuseSDKAdapter`
handles the bridge so the observer surface is stable across
future v4 patches.

Earlier SDK versions (v2.x, v3.x) are NOT supported. Projects on
those versions either upgrade to v4 or supply their own adapter
matching the `LangfuseClient` Protocol's four methods.

A runtime `isinstance(adapter, LangfuseClient)` check ships in
the unit suite — if a future v4 patch breaks the Protocol's
surface, the test fails loudly.

### What Langfuse sees

Expand Down
48 changes: 29 additions & 19 deletions docs/examples/10-langfuse-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,35 +114,45 @@ Trace id=01234567-89ab-...

## Swapping to a real Langfuse SDK

The observer's `client` parameter is `LangfuseClient`-Protocol-typed,
so any structurally-compatible value works:
Install the optional extras:

```bash
pip install 'openarmature[langfuse]'
```

Wrap the SDK client with `LangfuseSDKAdapter` and pass it to the
observer:

```python
from langfuse import Langfuse
from openarmature.observability.langfuse import (
LangfuseObserver,
LangfuseSDKAdapter,
)

client = Langfuse(
langfuse_client = Langfuse(
public_key="pk-lf-...",
secret_key="sk-lf-...",
host="https://cloud.langfuse.com",
)
observer = LangfuseObserver(client=client, disable_llm_payload=False)
observer = LangfuseObserver(
client=LangfuseSDKAdapter(langfuse_client),
disable_llm_payload=False,
)
```

If the installed SDK version's `trace` / `span` / `generation` method
signatures match the Protocol exactly, this is the whole change. If
they diverge (renamed kwargs, return-type quirks), wrap the SDK in a
small adapter class that implements `LangfuseClient` and delegates to
the SDK call-by-call. The Protocol surface is narrow — four methods —
so the adapter is on the order of 40 lines.

**No specific `langfuse` SDK version is validated in CI as of this
release.** The Protocol matches the SDK's documented low-level shape,
but `langfuse` has shifted between major versions (v2 → v3 introduced
API changes). A follow-on release pins a tested `[langfuse]` extras
range and a runtime `isinstance(client, LangfuseClient)` check; until
then, smoke-trace in your own environment with whichever `langfuse`
version your stack already uses and write a thin adapter if any
kwargs don't line up.
The adapter bridges `langfuse>=4.6,<5`'s unified `start_observation`
API onto OA's four-method `LangfuseClient` Protocol. v4 has no
explicit trace creation (traces are auto-created from observations);
the adapter caches trace info from `.trace()` and applies it via
`propagate_attributes` around EVERY observation under that trace_id.
Propagating on every observation keeps v4's last-attribute-wins
display logic from clobbering the trace's display name when later
observations land without the attribute set.

Validated against `langfuse>=4.6,<5`. v2.x and v3.x are NOT
supported — supply your own adapter against the same four-method
Protocol if you need to stay on an older version.

For prompt linkage: in production, the
`Prompt.observability_entities['langfuse_prompt']` value is the SDK's
Expand Down
23 changes: 17 additions & 6 deletions examples/10-langfuse-observability/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
The example uses the bundled ``InMemoryLangfuseClient`` recorder so the
demo runs without a Langfuse account — at the end we print the captured
Trace + Observation tree. Swapping to a real ``langfuse.Langfuse()``
client is a one-line constructor change (see the comment near the
observer build below).
client is a one-line constructor change via ``LangfuseSDKAdapter`` (see
the comment near the observer build below). The adapter bridges the
``langfuse>=4.6`` Python SDK shape onto OA's ``LangfuseClient``
Protocol. Install with::

pip install 'openarmature[langfuse]'

LLM calls go through ``openarmature.llm.OpenAIProvider``.

Expand Down Expand Up @@ -244,11 +248,18 @@ async def main() -> None:
# fields — without needing a Langfuse account. For production:
#
# from langfuse import Langfuse
# client = Langfuse(public_key=..., secret_key=..., host=...)
# from openarmature.observability.langfuse import LangfuseSDKAdapter
#
# langfuse_client = Langfuse(
# public_key="pk-lf-...",
# secret_key="sk-lf-...",
# host="https://cloud.langfuse.com",
# )
# client = LangfuseSDKAdapter(langfuse_client)
#
# Replace the InMemoryLangfuseClient construction below with that
# client. The observer code doesn't change — the client is
# Protocol-typed, so any structurally-compatible value works.
# Validated against ``langfuse>=4.6,<5``. The adapter bridges
# langfuse v4's unified ``start_observation`` API onto OA's
# ``LangfuseClient`` Protocol; the observer code doesn't change.
client = InMemoryLangfuseClient()

# disable_llm_payload=False opts in to capturing the input messages
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ otel = [
# below 1.0; revisit when 1.0 lands.
"opentelemetry-instrumentation-logging>=0.62.0b1",
]
# Spec observability §8 Langfuse mapping. Optional per charter §3.1
# principle 5; matches the [otel] extras' shape. Validated against
# Langfuse Python SDK 4.6.x; bridge from the SDK's v4 API onto the
# LangfuseClient Protocol lives in ``observability.langfuse.adapter``.
langfuse = [
"langfuse>=4.6,<5",
]

[project.urls]
Repository = "https://github.com/LunarCommand/openarmature-python"
Expand Down Expand Up @@ -105,3 +112,11 @@ select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
# Opt-in markers for tests that hit real external services. CI skips
# these by default — run with `-m integration` to include them after
# setting the relevant env vars (LANGFUSE_PUBLIC_KEY / SECRET_KEY for
# Langfuse Cloud, etc).
markers = [
"integration: tests that exercise real external services (Langfuse Cloud, HyperDX). Skipped by default.",
]
addopts = ["-m", "not integration"]
14 changes: 14 additions & 0 deletions src/openarmature/observability/langfuse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
)
from .observer import LangfuseObserver

# LangfuseSDKAdapter requires the [langfuse] optional dependency.
# Surface it when available, but don't force the import on consumers
# who only use the InMemoryLangfuseClient — the adapter module's own
# guard raises an informative ImportError if anyone tries to use it
# without the extras installed.
try:
from .adapter import LangfuseSDKAdapter as LangfuseSDKAdapter

_adapter_available = True
except ImportError: # pragma: no cover - exercised by extras-not-installed path
_adapter_available = False

__all__ = [
"InMemoryLangfuseClient",
"LangfuseClient",
Expand All @@ -54,3 +66,5 @@
"ObservationLevel",
"ObservationType",
]
if _adapter_available:
__all__.append("LangfuseSDKAdapter")
Loading