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
26 changes: 26 additions & 0 deletions dana/__init__/init_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,31 @@ def decorator(func):
sys.modules["langfuse"] = shim


def _install_langsmith_shim() -> None:
"""Install a no-op `langsmith` shim when the package is absent.

Mirrors `_install_langfuse_shim()`: registers a passthrough `traceable`
decorator so `from langsmith import traceable` never crashes and
`dana.common.observable` stays importable without the extra installed.
"""
try:
import langsmith # noqa: F401
except ModuleNotFoundError:
shim = types.ModuleType("langsmith")

def traceable(*args: object, **kwargs: object):
def decorator(func):
return func

if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0]
return decorator

shim.traceable = traceable

sys.modules["langsmith"] = shim


def init_environment(verbose: bool = False):
"""Load environment variables from .env file.

Expand All @@ -92,6 +117,7 @@ def init_environment(verbose: bool = False):
"""
_install_structlog_shim()
_install_langfuse_shim()
_install_langsmith_shim()
dotenv_path = find_dotenv()
if verbose:
print(f"Loading environment variables from {dotenv_path}")
Expand Down
230 changes: 118 additions & 112 deletions dana/common/observable.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
"""
Observable decorator for tracking function calls with Langfuse.
Observable decorator for tracking function calls with a tracing backend.

This module provides an `observable` decorator that automatically tracks
function inputs and outputs using Langfuse for observability and monitoring.
Dispatches to ONE tracing backend per process, selected at decoration time:
1. LangSmith — when `langsmith` is importable AND
(`LANGSMITH_TRACING=true` OR `DANA_LANGSMITH_ENABLED` truthy)
2. Langfuse — when `langfuse` is importable AND `LANGFUSE_ENABLED` truthy
3. no-op — default (decorator returns the function unchanged)

Langfuse tracking can be disabled by setting the LANGFUSE_ENABLED environment
variable to 'false' (default). Set to 'true', '1', or 'yes' to enable tracking.
Backends are EXCLUSIVE: LangSmith takes precedence over Langfuse. Toggle the
active backend via environment variables, not by editing call sites.

LangSmith relies on its SDK's background-thread batching (no per-call flush).
Langfuse preserves its existing flush-after-each-call behavior.
"""

from collections.abc import Callable
import functools
import inspect
import os
from typing import cast


# --- Langfuse (existing backend) ---
try:
from langfuse import Langfuse
from langfuse import observe as langfuse_observe
Expand All @@ -29,128 +37,126 @@ def decorator(func):
return decorator


# Check if Langfuse should be enabled
LANGFUSE_ENABLED = Langfuse is not None and os.getenv("LANGFUSE_ENABLED", "false").lower() in ("true", "1", "yes")
# --- LangSmith (alternative backend) ---
try:
from langsmith import traceable as langsmith_traceable
except ModuleNotFoundError:
langsmith_traceable = None


_TRUTHY = ("true", "1", "yes")
# LangSmith run_type vocabulary. langfuse `as_type` has no 1:1 mapping.
_VALID_RUN_TYPES = {"chain", "llm", "tool", "prompt", "retriever"}

# Enablement is read ONCE at module load. Toggling requires a process restart
# (or `importlib.reload(dana.common.observable)` in tests).
LANGFUSE_ENABLED = Langfuse is not None and os.getenv("LANGFUSE_ENABLED", "false").lower() in _TRUTHY
LANGSMITH_ENABLED = langsmith_traceable is not None and (
os.getenv("LANGSMITH_TRACING", "false").lower() == "true" or os.getenv("DANA_LANGSMITH_ENABLED", "false").lower() in _TRUTHY
)

# Langfuse client singleton (used only on the langfuse branch for per-call flush).
if LANGFUSE_ENABLED:
assert Langfuse is not None # LANGFUSE_ENABLED requires the import to have succeeded
OBSERVER = Langfuse()
else:
OBSERVER = None


def observable(*args, **kwargs) -> Callable:
def _langsmith_kwargs(kwargs: dict) -> dict:
"""Translate langfuse-style @observe kwargs to langsmith @traceable kwargs.

name -> name
as_type -> run_type (default "chain"; "generation" and unknowns -> "chain")
tags -> tags
session_id, user_id -> folded into metadata (langsmith has no direct equivalent)
metadata -> metadata (merged)
"""
Decorator that tracks function calls with Langfuse and flushes after execution.
ls: dict = {}
if "name" in kwargs:
ls["name"] = kwargs["name"]
run_type = kwargs.get("as_type", "chain")
if run_type == "generation" or run_type not in _VALID_RUN_TYPES:
run_type = "chain"
ls["run_type"] = run_type
if "tags" in kwargs:
ls["tags"] = kwargs["tags"]
meta = dict(kwargs.get("metadata") or {})
if "session_id" in kwargs:
meta["session_id"] = kwargs["session_id"]
if "user_id" in kwargs:
meta["user_id"] = kwargs["user_id"]
if meta:
ls["metadata"] = meta
return ls


def _langfuse_wrap(execute_function: Callable, func: Callable) -> Callable:
"""Wrap a langfuse-observed callable with post-invocation flush (sync + async)."""
if inspect.iscoroutinefunction(func):

@functools.wraps(func)
async def async_wrapper(*wrapper_args, **wrapper_kwargs):
result = await execute_function(*wrapper_args, **wrapper_kwargs)
if OBSERVER:
OBSERVER.flush()
return result

return async_wrapper

@functools.wraps(func)
def wrapper(*wrapper_args, **wrapper_kwargs):
result = execute_function(*wrapper_args, **wrapper_kwargs)
if OBSERVER:
OBSERVER.flush()
return result

return wrapper


def observable(*args, **kwargs) -> Callable:
"""Decorator that tracks function calls with the active tracing backend.

This decorator applies the @langfuse_observe() decorator with all provided parameters
and automatically calls OBSERVER.flush() after the function executes. This ensures
that all observations are sent to Langfuse immediately after function completion.
Backend selection (exclusive, evaluated at decoration time):
LangSmith > Langfuse > no-op. See module docstring for env-var triggers.

Note: Langfuse tracking is disabled by default. Set LANGFUSE_ENABLED=true to enable.
Supports both `@observable` and `@observable(...)` syntax, and sync + async.

Args:
*args: Positional arguments passed to langfuse_observe
**kwargs: Keyword arguments passed to langfuse_observe (e.g., name, tags, as_type)

Examples:
Basic usage:
@observable()
def my_function(self, *args, **kwargs):
return "result"

With custom span name:
@observable(name="custom_span_name")
def my_function():
return "result"

With tags and type:
@observable(name="api_call", tags=["production", "api"], as_type="generation")
def api_function():
return "api_response"

With all langfuse_observe parameters:
@observable(
name="complex_operation",
tags=["ml", "inference"],
as_type="generation",
session_id="session_123"
)
def ml_function():
return "prediction"
*args: Positional args forwarded to the backend decorator.
**kwargs: Keyword args forwarded to the backend decorator
(langfuse-style: name, as_type, tags, session_id, user_id, metadata).

Returns:
Decorated function that automatically tracks inputs and outputs using Langfuse,
with automatic flushing after execution.
Decorated callable that tracks inputs/outputs via the active backend,
or the original callable unchanged when no backend is enabled.
"""

def decorator(func: Callable) -> Callable:
# Apply the langfuse_observe decorator with all parameters
def _apply(func: Callable, dec_args: tuple, dec_kwargs: dict) -> Callable:
# Branch 1: LangSmith (precedence). traceable is sync+async native; no flush.
# Positional decorator params are dropped (none used by any call site);
# only translated kwargs are forwarded. LANGSMITH_ENABLED guarantees the import succeeded.
if LANGSMITH_ENABLED:
traceable = cast("Callable[..., Callable]", langsmith_traceable)
return traceable(**_langsmith_kwargs(dec_kwargs))(func)

# Branch 2: Langfuse. Preserve existing flush-after-each-call behavior.
if LANGFUSE_ENABLED:
execute_function = langfuse_observe(*args, **kwargs)(func)
else:
execute_function = func

# Handle async functions
if inspect.iscoroutinefunction(func):

@functools.wraps(func)
async def async_wrapper(*wrapper_args, **wrapper_kwargs):
# Execute the observed function
result = await execute_function(*wrapper_args, **wrapper_kwargs)
# Flush after execution
if OBSERVER:
OBSERVER.flush()
return result

return async_wrapper
else:

@functools.wraps(func)
def wrapper(*wrapper_args, **wrapper_kwargs):
# Execute the observed function
result = execute_function(*wrapper_args, **wrapper_kwargs)
# Flush after execution
if OBSERVER:
OBSERVER.flush()
return result

return wrapper

# Handle both @observable and @observable() syntax
if dec_args or dec_kwargs:
execute_function = langfuse_observe(*dec_args, **dec_kwargs)(func)
else:
execute_function = langfuse_observe(func)
return _langfuse_wrap(cast("Callable[..., object]", execute_function), func)

# Branch 3: no-op
return func

# Bare form: @observable (function passed positionally, no decorator params)
if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
# Called as @observable (without parentheses) - no parameters passed
func = args[0]
# Apply langfuse_observe with no parameters
if LANGFUSE_ENABLED:
execute_function = langfuse_observe(func)
else:
execute_function = func

# Handle async functions
if inspect.iscoroutinefunction(func):

@functools.wraps(func)
async def async_wrapper(*wrapper_args, **wrapper_kwargs):
# Execute the observed function
result = await execute_function(*wrapper_args, **wrapper_kwargs)
# Flush after execution
if OBSERVER:
OBSERVER.flush()
return result

return async_wrapper
else:

@functools.wraps(func)
def wrapper(*wrapper_args, **wrapper_kwargs):
# Execute the observed function
result = execute_function(*wrapper_args, **wrapper_kwargs)
# Flush after execution
if OBSERVER:
OBSERVER.flush()
return result

return wrapper
else:
# Called as @observable() or @observable(...) (with parentheses/parameters)
return decorator
return _apply(args[0], (), {})

# Parameterized form: @observable(...) -> returns a decorator
def decorator(func: Callable) -> Callable:
return _apply(func, args, kwargs)

return decorator
18 changes: 17 additions & 1 deletion docs/codebase-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,23 @@ dana/
- data: pandas, matplotlib
- memory: lancedb, sentence-transformers
- knowledge: rdflib, rank-bm25
- observability: langfuse
- observability: langfuse, langsmith (alternative tracing backend, exclusive with langfuse)

### Tracing Backends (exclusive)

`@observable` (`dana/common/observable.py`) dispatches to ONE tracing backend per process, selected at decoration time. Toggle via env, not code.

| Precedence | Trigger | Backend |
|------------|---------|---------|
| 1 (highest) | `LANGSMITH_TRACING=true` OR `DANA_LANGSMITH_ENABLED=true|1|yes` (+ `langsmith` installed) | LangSmith `@traceable` (background-thread batching, no per-call flush) |
| 2 | `LANGFUSE_ENABLED=true|1|yes` (+ `langfuse` installed) | Langfuse `@observe` + per-call flush |
| 3 (default) | neither set | no-op passthrough |

**LangSmith env vars:** `LANGSMITH_TRACING` (enable, case-sensitive `true`), `DANA_LANGSMITH_ENABLED` (dana-namespace alias), `LANGSMITH_API_KEY` (required for emission), `LANGSMITH_PROJECT` / `LANGSMITH_ENDPOINT` (optional).

If both `LANGSMITH_TRACING` and `LANGFUSE_ENABLED` are set, LangSmith wins. Install via `pip install dana[observability]`.

**Silent no-op caveat:** if `LANGSMITH_TRACING=true` (or `DANA_LANGSMITH_ENABLED`) is set but the `langsmith` package is not installed, an import shim makes `@observable` a silent no-op (no error, no traces). The same applies to `LANGFUSE_ENABLED=true` without `langfuse`. Always install the backend package when enabling its env flag to avoid silent misconfiguration.

## Key Files by Importance

Expand Down
2 changes: 2 additions & 0 deletions docs/project-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Added
- LangSmith as an alternative tracing backend. `@observable` (`dana/common/observable.py`) dispatches to `langsmith.traceable` when `LANGSMITH_TRACING=true` or `DANA_LANGSMITH_ENABLED` truthy; exclusive with Langfuse (LangSmith takes precedence). No call-site changes — all 30+ `@observable` sites traced automatically. Add via `pip install dana[observability]`. LangSmith API key: `LANGSMITH_API_KEY`.
- Single-knob env trigger `DANA_COMPACT_TRIGGER_TOKENS` (default 150000, clamp `[8k, 2M]`) for compression threshold (P3).
- Optional `system_tokens_fn` / `tools_tokens_fn` callbacks on `CompressedTimeline` — fold system-prompt and tools-schema size into `needs_compression()` estimate without coupling to any provider.
- Client-side tool-result stubbing (`cheap_shrink_tool_results()`, P6) with predictive savings gate; opt-in via `enable_cheap_shrink_tool_results`.
Expand All @@ -14,6 +15,7 @@
- AST-based unit test `test_log_field_allowlist.py` — fails CI when log `extra={...}` keys drift outside the allowlist.

### Changed
- `@observable` (`dana/common/observable.py`): when no tracing backend is enabled, the decorator now returns the target function unchanged (identity) instead of wrapping it in a passthrough flush layer. No introspection-sensitive call sites affected; `inspect.signature()` on decorated functions now returns the real signature. Langfuse-path tracing behavior is unchanged.
- `CompressedTimeline.__init__` default `max_tokens_until_compression` now defers to env trigger (150000) when unset. Explicit value continues to win.
- `CompressedTimelineConfig` gains `enable_cheap_shrink_tool_results`, `cheap_shrink_keep_recent`, `enable_reactive_compact` fields.
- `star_agent._maybe_compress_timeline` (sync + async) re-raises `PromptTooLongError` from summary path instead of swallowing — lets caller-layer retry kick in.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ knowledge = [
# Observability and tracing - Install with: pip install dana[observability]
observability = [
"langfuse>=3.5.1",
"langsmith>=0.8,<0.9", # Alternative tracing backend (exclusive with langfuse)
]

# Full installation with all optional features - Install with: pip install dana[full]
Expand Down
Loading
Loading