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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,15 @@ AGENTS_SECRET_KEY=
# at startup and routes per-call telemetry. Per-call gating is governed by
# the workspace's analytics_consent (off / errors_only / full). Leave blank
# to disable tracing entirely.
#
# LANGFUSE_HOST is the canonical name (matches the LiteLLM / Langfuse SDK
# convention). LANGFUSE_BASE_URL is accepted as an alias so a self-hosted
# setup that uses the alternative naming still wires up tracing. If both
# are set, LANGFUSE_HOST wins.
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=
# LANGFUSE_BASE_URL= # alias for LANGFUSE_HOST

# Agent invocation rate limits — operator-level (not per-workspace). Defaults
# below are 10× the original spec. Override only if you need to throttle
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ BACKEND_CORS_ORIGINS=http://localhost:5173
# Optional — Langfuse tracing for agent calls (per-workspace consent gates each call).
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=
LANGFUSE_HOST= # canonical, matches LiteLLM/Langfuse SDK
# LANGFUSE_BASE_URL= # accepted as alias for LANGFUSE_HOST
```

### ⚠️ Required for AI agents: `AGENTS_SECRET_KEY`
Expand Down
10 changes: 8 additions & 2 deletions backend/app/agents/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def acompletion(
tools: list[dict] | None = None,
tool_choice: str | dict | None = None,
response_format: dict | None = None,
metadata: LLMCallMetadata,
metadata: LLMCallMetadata | None,
model_override: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
Expand Down Expand Up @@ -465,7 +465,7 @@ def _safe_completion_cost(resp: Any) -> Decimal | None:
return None

def _build_langfuse_metadata(
self, call_meta: LLMCallMetadata
self, call_meta: LLMCallMetadata | None
) -> dict | None:
"""Build per-call metadata for the LiteLLM Langfuse callback.

Expand All @@ -474,6 +474,12 @@ def _build_langfuse_metadata(
env vars at app startup by ``app/agents/tracing.py`` (task 013); this
method only constructs the trace identifying info.
"""
# Some callers (e.g. the auto-title endpoint) intentionally pass no
# metadata — they don't belong to an agent invocation and must never
# show up in Langfuse. Treat that as "tracing off" to avoid an
# AttributeError on call_meta.analytics_consent.
if call_meta is None:
return None
if call_meta.analytics_consent == "off":
return None
if not os.environ.get(_LANGFUSE_PUBLIC_KEY_ENV):
Expand Down
11 changes: 9 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import SecretStr
from pydantic import AliasChoices, Field, SecretStr
from pydantic_settings import BaseSettings


Expand Down Expand Up @@ -48,7 +48,14 @@ class Settings(BaseSettings):
# convention and the langfuse/skills setup pattern.
langfuse_public_key: SecretStr | None = None
langfuse_secret_key: SecretStr | None = None
langfuse_host: str | None = None
# Accept either LANGFUSE_HOST (LiteLLM/SDK convention) or
# LANGFUSE_BASE_URL (a common typo / alternative naming) so a misnamed
# env var doesn't silently disable tracing. Both are documented in
# .env.example.
langfuse_host: str | None = Field(
default=None,
validation_alias=AliasChoices("LANGFUSE_HOST", "LANGFUSE_BASE_URL"),
)

# Agent invocation rate limits — operator-level, not per-workspace.
# Defaults are 10× the original spec defaults (which were 600/h, 6000/d,
Expand Down
21 changes: 18 additions & 3 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
from collections.abc import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
Expand All @@ -19,7 +20,21 @@ async def get_db() -> AsyncGenerator[AsyncSession]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
except BaseException:
# SSE endpoints get cancelled when the frontend aborts (tab close,
# navigation, network blip). uvicorn raises asyncio.CancelledError
# which is a BaseException — not Exception — so a plain
# `except Exception` would miss it and the noisy "Exception
# terminating connection" + "Exception in ASGI application"
# tracebacks would land in prod logs on every disconnect.
#
# Catch BaseException, attempt a best-effort rollback, then
# re-raise so the framework still treats the request as cancelled.
# The rollback itself can also raise CancelledError if the scope
# is already torn down — suppress that too, SQLAlchemy still
# returns the connection to the pool on its own.
with contextlib.suppress(BaseException):
await session.rollback()
raise
else:
await session.commit()
7 changes: 7 additions & 0 deletions backend/tests/agents/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,13 @@ def test_langfuse_metadata_off_returns_none(client: LLMClient):
assert client._build_langfuse_metadata(meta) is None


def test_langfuse_metadata_none_returns_none(client: LLMClient):
# Auto-title and other ad-hoc LLM calls pass metadata=None because they
# don't belong to an agent invocation. Must not blow up with
# AttributeError on call_meta.analytics_consent.
assert client._build_langfuse_metadata(None) is None


def test_langfuse_metadata_full_with_env_returns_dict(
client: LLMClient, monkeypatch: pytest.MonkeyPatch
):
Expand Down
28 changes: 28 additions & 0 deletions backend/tests/agents/test_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,34 @@ def test_is_langfuse_configured_false_when_all_missing(
assert tracing.is_langfuse_configured() is False


# ---------------------------------------------------------------------------
# Env-var alias: LANGFUSE_HOST is canonical, LANGFUSE_BASE_URL is accepted
# as a fallback so a misnamed env var doesn't silently disable tracing.
# ---------------------------------------------------------------------------


def test_settings_picks_up_langfuse_base_url_alias(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("LANGFUSE_HOST", raising=False)
monkeypatch.setenv("LANGFUSE_BASE_URL", "https://lf.example.test")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-x")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-x")
fresh = config_module.Settings()
assert fresh.langfuse_host == "https://lf.example.test"


def test_settings_prefers_langfuse_host_over_base_url(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("LANGFUSE_HOST", "https://canonical.example.test")
monkeypatch.setenv("LANGFUSE_BASE_URL", "https://alias.example.test")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-x")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-x")
fresh = config_module.Settings()
assert fresh.langfuse_host == "https://canonical.example.test"


# ---------------------------------------------------------------------------
# setup_litellm_callbacks
# ---------------------------------------------------------------------------
Expand Down
Loading