* feat(observability): optional PostHog integration (errors, LLM traces, replay, flags)
Off by default. Activates when POSTHOG_API_KEY is set in env. Defaults
to PostHog Cloud EU; override host for US Cloud or self-hosted.
Coverage:
- FastAPI 500 handler captures unhandled exceptions
- src/orchestrator.py rebuild + rebuild_source failures
- services/scheduler/ HTTP-job failures
- cli/main.py uncaught CLI errors (Typer.Exit/SystemExit/KeyboardInterrupt
skipped; flushes before re-raise so short-lived CLI invocations don't
drop events)
- connectors/llm/anthropic_provider.py + openai_compat.py emit
$ai_generation events with provider, model, latency, token counts
(prompt/completion bodies stay off unless POSTHOG_LLM_PAYLOADS=1
because LLM prompts here routinely include customer SQL/data)
- Browser snippet injected into every text/html response by
PosthogInjectionMiddleware — registered inside the GZip layer so it
sees uncompressed HTML before compression. Many templates are
standalone (their own DOCTYPE) and never extend base.html, so a
per-template include would miss them.
- Frontend: $pageview, $pageleave, JS error capture via window.error
and unhandledrejection handlers, masked session replay
(maskAllInputs: true plus CSS-selector mask for known data surfaces),
feature flags (browser posthog.isFeatureEnabled + server-side
feature_enabled with fallback for older SDKs).
Identification mode operator-configurable: none / id / email / full.
Default email ships user.id + email but never name. CLI entry point
moves from cli.main:app to cli.main:main (Typer wrapper).
Files:
- src/observability/posthog_client.py — lazy singleton, no network
when disabled, single-process flush on shutdown
- src/observability/llm_tracing.py — trace_generation context manager
- app/middleware/posthog_inject.py — HTML rewrite middleware
- app/web/templates/_posthog.html — browser snippet template
- docs/observability.md — operator guide
- config/.env.template — documented POSTHOG_* knobs
- tests/test_posthog_disabled.py + tests/test_posthog_client.py +
tests/test_llm_tracing.py — 18 tests covering disabled state,
identify-mode payloads, $ai_generation shape, error variant.
CHANGELOG entry under [Unreleased] Added.
* feat(observability): tag every PostHog event with environment + release
Splits PostHog dashboards cleanly between localhost / dev / staging /
production without manual tagging on every capture call.
- POSTHOG_ENVIRONMENT explicit override; auto-resolves to "local" when
LOCAL_DEV_MODE=1, else RELEASE_CHANNEL, else AGNES_DEPLOYMENT_ENV,
else "unknown".
- AGNES_VERSION → RELEASE_CHANNEL fallback feeds the `release` property
for "is this error new in this release?" cohorting.
- Backend gets both via the PostHog SDK's super_properties constructor
arg (every captured event picks them up automatically).
- Browser snippet calls posthog.register({environment, release}) inside
the loaded callback so $pageview, $exception, autocapture, etc. all
carry the same labels.
- request.state.user now populated by auth dependencies so the snippet
can actually call posthog.identify(user_id, {email}) for logged-in
users (previously the user block always resolved to None because
nothing wrote to request.state.user).
4 new tests cover env resolution: explicit > LOCAL_DEV_MODE > channel
> unknown, plus super-properties forwarding into the SDK constructor.
* feat(observability): inline user attrs on every PostHog event + debug throw route
PostHog's UI shows person properties on the Person profile page, not
inline on each event — so a reviewer triaging an exception couldn't tell
which user hit the bug without clicking through. Fix it on both sides.
- Backend capture_exception merges user_id / user_email / user_name into
the event properties (gated by POSTHOG_IDENTIFY_PII: none/id/email/full).
Backed by a new _user_props_for_event helper on PosthogClient.
- Browser snippet registers user_id + user_email + user_name as super-
properties via posthog.register({...}) so every $exception, $pageview,
and custom event coming from posthog.captureException() carries them
inline. Mirrors the backend so cross-referencing client/server events
doesn't require a person-profile lookup.
- /api/debug/throw — debug-only endpoint gated by DEBUG=1 (404 in prod).
Runs Depends(get_current_user) first so request.state.user is set when
the unhandled-exception handler captures the event. Lets operators
exercise the full observability path end-to-end without hand-rolling
a TestClient script. Configurable via ?kind=ValueError&msg=...
7 new tests cover: backend user-attr merge across identify modes,
anonymous request fall-through, browser snippet super-prop emission for
logged-in / anonymous / id-only / full-name cases.
* fix(observability): address minasarustamyan PR #231 review
Two bugs caught in review.
1. PosthogInjectionMiddleware dropped Response.background on every
return path. BaseHTTPMiddleware materialises the body and asks
subclasses to return a fresh Response — three paths in dispatch()
omitted background=, silently cancelling any BackgroundTask /
BackgroundTasks the route attached (audit logging, async webhooks,
email sends) with no log line. Fix: route every return through a
_passthrough() helper that forwards background.
Also adds a _MAX_BUFFER_BYTES (4 MB) cap so a streamed-HTML response
can't balloon RSS during buffering. Bigger bodies short-circuit
through with a warning rather than being injected.
Regression tests in tests/test_posthog_inject_middleware.py exercise
four return paths (snippet present, render-fail, double-injection
guard, non-HTML passthrough) plus the streaming-guard short-circuit.
2. $ai_input / $ai_output_choices were emitted without truncation, so
POSTHOG_LLM_PAYLOADS=1 silently dropped events past PostHog's ~32 KB
per-event ingest limit — exactly the calls (large prompts with
schemas / sample rows / SQL) an operator would want to inspect.
Fix: clip both at POSTHOG_LLM_PAYLOAD_MAX_CHARS (default 30000) with
an explicit "…[truncated N chars]" marker so readers don't mistake
truncated captures for complete ones. Metadata (provider, model,
tokens, latency, error) flows regardless. Three new tests cover
default-cap clipping, env-override, and pass-through under the cap.
37 PostHog tests pass.
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""PostHog client behavior when POSTHOG_API_KEY is set.
|
|
|
|
The underlying ``posthog.Posthog`` class is patched so the suite runs
|
|
without a network. We assert on the calls our wrapper forwards, plus
|
|
shape of the identify-mode payloads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def posthog_env(monkeypatch):
|
|
"""Set up POSTHOG_API_KEY and reset the singleton."""
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_test_key")
|
|
monkeypatch.delenv("POSTHOG_HOST", raising=False)
|
|
monkeypatch.delenv("POSTHOG_IDENTIFY_PII", raising=False)
|
|
monkeypatch.delenv("POSTHOG_REPLAY", raising=False)
|
|
monkeypatch.delenv("POSTHOG_LLM_PAYLOADS", raising=False)
|
|
from src.observability import reset_posthog
|
|
reset_posthog()
|
|
yield
|
|
reset_posthog()
|
|
|
|
|
|
def test_enabled_when_key_set(posthog_env):
|
|
with patch("posthog.Posthog") as posthog_ctor:
|
|
from src.observability import get_posthog
|
|
|
|
pc = get_posthog()
|
|
|
|
assert pc.enabled is True
|
|
assert pc.host == "https://eu.i.posthog.com"
|
|
assert pc.identify_mode == "email"
|
|
assert pc.replay_enabled is True
|
|
assert pc.llm_payloads_enabled is False
|
|
posthog_ctor.assert_called_once()
|
|
kwargs = posthog_ctor.call_args.kwargs
|
|
assert kwargs["project_api_key"] == "phc_test_key"
|
|
assert kwargs["host"] == "https://eu.i.posthog.com"
|
|
|
|
|
|
def test_host_override(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.setenv("POSTHOG_HOST", "https://us.i.posthog.com")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog"):
|
|
assert get_posthog().host == "https://us.i.posthog.com"
|
|
reset_posthog()
|
|
|
|
|
|
def test_capture_exception_forwards_to_sdk(posthog_env):
|
|
sdk = MagicMock()
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
from src.observability import get_posthog
|
|
|
|
pc = get_posthog()
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/dashboard"),
|
|
method="GET",
|
|
state=SimpleNamespace(user={"id": "u-42", "email": "a@example.com", "name": "Ada"}),
|
|
)
|
|
pc.capture_exception(RuntimeError("boom"), request=request, properties={"k": "v"})
|
|
|
|
sdk.capture_exception.assert_called_once()
|
|
args, kwargs = sdk.capture_exception.call_args
|
|
# Exception is positional (PostHog SDK ≥ 3.7).
|
|
assert isinstance(args[0], RuntimeError)
|
|
assert kwargs["distinct_id"] == "u-42"
|
|
props = kwargs["properties"]
|
|
assert props["path"] == "/dashboard"
|
|
assert props["method"] == "GET"
|
|
assert props["k"] == "v"
|
|
# User attributes inlined on the event itself per default identify mode (email).
|
|
assert props["user_id"] == "u-42"
|
|
assert props["user_email"] == "a@example.com"
|
|
# name only at identify mode 'full'.
|
|
assert "user_name" not in props
|
|
|
|
|
|
def test_capture_exception_user_props_full_mode(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "full")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
sdk = MagicMock()
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
pc = get_posthog()
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/x"), method="POST",
|
|
state=SimpleNamespace(user={"id": "u-1", "email": "a@b.test", "name": "Ada"}),
|
|
)
|
|
pc.capture_exception(RuntimeError("e"), request=request)
|
|
props = sdk.capture_exception.call_args.kwargs["properties"]
|
|
assert props["user_id"] == "u-1"
|
|
assert props["user_email"] == "a@b.test"
|
|
assert props["user_name"] == "Ada"
|
|
reset_posthog()
|
|
|
|
|
|
def test_capture_exception_user_props_none_mode_emits_nothing(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "none")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
sdk = MagicMock()
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
pc = get_posthog()
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/x"), method="POST",
|
|
state=SimpleNamespace(user={"id": "u-1", "email": "a@b.test"}),
|
|
)
|
|
pc.capture_exception(RuntimeError("e"), request=request)
|
|
props = sdk.capture_exception.call_args.kwargs["properties"]
|
|
assert "user_id" not in props
|
|
assert "user_email" not in props
|
|
assert "user_name" not in props
|
|
reset_posthog()
|
|
|
|
|
|
def test_capture_exception_anonymous_request_no_user_props(posthog_env):
|
|
sdk = MagicMock()
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
from src.observability import get_posthog
|
|
pc = get_posthog()
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/x"), method="GET",
|
|
state=SimpleNamespace(), # no .user attribute
|
|
)
|
|
pc.capture_exception(RuntimeError("e"), request=request)
|
|
kwargs = sdk.capture_exception.call_args.kwargs
|
|
assert kwargs["distinct_id"] == "anonymous"
|
|
props = kwargs["properties"]
|
|
assert "user_id" not in props
|
|
assert "user_email" not in props
|
|
|
|
|
|
def test_capture_exception_falls_back_when_sdk_lacks_native(posthog_env):
|
|
"""Older posthog SDKs miss capture_exception — wrapper sends $exception."""
|
|
sdk = MagicMock(spec=["capture", "shutdown"])
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
from src.observability import get_posthog
|
|
|
|
pc = get_posthog()
|
|
pc.capture_exception(ValueError("x"), distinct_id="u-1")
|
|
|
|
sdk.capture.assert_called_once()
|
|
kwargs = sdk.capture.call_args.kwargs
|
|
assert kwargs["event"] == "$exception"
|
|
assert kwargs["distinct_id"] == "u-1"
|
|
assert kwargs["properties"]["$exception_type"] == "ValueError"
|
|
assert kwargs["properties"]["$exception_message"] == "x"
|
|
|
|
|
|
def test_is_feature_enabled_returns_default_on_sdk_error(posthog_env):
|
|
sdk = MagicMock()
|
|
# Wrapper prefers the v7 name `feature_enabled`. Patch both so either
|
|
# SDK version routes through the failing path.
|
|
sdk.feature_enabled.side_effect = RuntimeError("network down")
|
|
sdk.is_feature_enabled.side_effect = RuntimeError("network down")
|
|
with patch("posthog.Posthog", return_value=sdk):
|
|
from src.observability import get_posthog
|
|
|
|
assert get_posthog().is_feature_enabled("flag-x", "u-1", default=True) is True
|
|
|
|
|
|
def test_invalid_identify_mode_falls_back_to_email(monkeypatch, caplog):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "completely-bogus")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog"):
|
|
assert get_posthog().identify_mode == "email"
|
|
reset_posthog()
|
|
|
|
|
|
def test_template_user_block_respects_identify_modes(monkeypatch):
|
|
"""The Jinja helper produces id-only / email / full payloads on demand."""
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
from app.web.router import _posthog_user_block
|
|
from src.observability import reset_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog"):
|
|
request = SimpleNamespace(state=SimpleNamespace(
|
|
user={"id": "u-7", "email": "a@b.test", "name": "Ada"},
|
|
))
|
|
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "id")
|
|
reset_posthog()
|
|
block = _posthog_user_block(request)
|
|
assert block == {"distinct_id": "u-7", "props": {}}
|
|
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "email")
|
|
reset_posthog()
|
|
block = _posthog_user_block(request)
|
|
assert block == {"distinct_id": "u-7", "props": {"email": "a@b.test"}}
|
|
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "full")
|
|
reset_posthog()
|
|
block = _posthog_user_block(request)
|
|
assert block == {"distinct_id": "u-7", "props": {"email": "a@b.test", "name": "Ada"}}
|
|
|
|
monkeypatch.setenv("POSTHOG_IDENTIFY_PII", "none")
|
|
reset_posthog()
|
|
assert _posthog_user_block(request) is None
|
|
reset_posthog()
|
|
|
|
|
|
def test_environment_resolution_explicit_wins(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.setenv("POSTHOG_ENVIRONMENT", "qa-7")
|
|
monkeypatch.setenv("LOCAL_DEV_MODE", "1") # would otherwise resolve to "local"
|
|
monkeypatch.setenv("RELEASE_CHANNEL", "stable")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog") as ctor:
|
|
pc = get_posthog()
|
|
assert pc.environment == "qa-7"
|
|
kwargs = ctor.call_args.kwargs
|
|
assert kwargs["super_properties"]["environment"] == "qa-7"
|
|
reset_posthog()
|
|
|
|
|
|
def test_environment_resolution_local_dev_short_circuit(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.delenv("POSTHOG_ENVIRONMENT", raising=False)
|
|
monkeypatch.setenv("LOCAL_DEV_MODE", "1")
|
|
monkeypatch.setenv("RELEASE_CHANNEL", "stable") # ignored when LOCAL_DEV_MODE wins
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog"):
|
|
assert get_posthog().environment == "local"
|
|
reset_posthog()
|
|
|
|
|
|
def test_environment_release_channel_fallback(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
monkeypatch.delenv("POSTHOG_ENVIRONMENT", raising=False)
|
|
monkeypatch.delenv("LOCAL_DEV_MODE", raising=False)
|
|
monkeypatch.setenv("RELEASE_CHANNEL", "stable")
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog") as ctor:
|
|
pc = get_posthog()
|
|
assert pc.environment == "stable"
|
|
# release also surfaces from AGNES_VERSION → RELEASE_CHANNEL fallback
|
|
assert ctor.call_args.kwargs["super_properties"]["release"] == "stable"
|
|
reset_posthog()
|
|
|
|
|
|
def test_environment_unknown_when_nothing_set(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
for var in ("POSTHOG_ENVIRONMENT", "LOCAL_DEV_MODE", "RELEASE_CHANNEL", "AGNES_DEPLOYMENT_ENV", "AGNES_VERSION"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
from src.observability import reset_posthog, get_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog") as ctor:
|
|
pc = get_posthog()
|
|
assert pc.environment == "unknown"
|
|
assert pc.release is None
|
|
assert "release" not in ctor.call_args.kwargs["super_properties"]
|
|
reset_posthog()
|
|
|
|
|
|
def _render_snippet(user_block):
|
|
"""Render `_posthog.html` directly with stub Jinja globals.
|
|
|
|
Avoids spinning up the full TestClient for what is effectively a
|
|
template-output assertion.
|
|
"""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
env = Environment(loader=FileSystemLoader("app/web/templates"), autoescape=False)
|
|
return env.get_template("_posthog.html").render(
|
|
request=None,
|
|
posthog_config={
|
|
"enabled": True,
|
|
"host": "https://eu.i.posthog.com",
|
|
"api_key_public": "phc_test",
|
|
"replay_enabled": True,
|
|
"replay_mask_selector_extra": "",
|
|
"environment": "local",
|
|
"release": "0.99.0",
|
|
},
|
|
posthog_user_block=lambda _r: user_block,
|
|
)
|
|
|
|
|
|
def test_browser_snippet_registers_user_id_and_email_when_logged_in():
|
|
out = _render_snippet({
|
|
"distinct_id": "u-99",
|
|
"props": {"email": "ada@example.com"},
|
|
})
|
|
|
|
# Super-properties: env + release always, plus user_id/email when logged in.
|
|
assert "_superProps.user_id = \"u-99\"" in out
|
|
assert "_superProps.user_email = \"ada@example.com\"" in out
|
|
# identify() still fires alongside register() so person profiles get linked.
|
|
assert "ph.identify(\"u-99\"" in out
|
|
assert "\"email\": \"ada@example.com\"" in out
|
|
# Environment + release land on the same super-prop bag.
|
|
assert "environment: \"local\"" in out
|
|
assert "release: \"0.99.0\"" in out
|
|
|
|
|
|
def test_browser_snippet_includes_user_name_in_full_mode():
|
|
out = _render_snippet({
|
|
"distinct_id": "u-99",
|
|
"props": {"email": "ada@example.com", "name": "Ada Lovelace"},
|
|
})
|
|
|
|
assert "_superProps.user_name = \"Ada Lovelace\"" in out
|
|
|
|
|
|
def test_browser_snippet_omits_user_props_when_anonymous():
|
|
out = _render_snippet(None)
|
|
|
|
assert "_superProps.user_id" not in out
|
|
assert "_superProps.user_email" not in out
|
|
assert "_superProps.user_name" not in out
|
|
assert "ph.identify(" not in out
|
|
# Environment still registers so anonymous events are tagged too.
|
|
assert "environment: \"local\"" in out
|
|
|
|
|
|
def test_browser_snippet_omits_email_when_id_only_mode():
|
|
"""Caller passes a block with only distinct_id → no email/name in output."""
|
|
out = _render_snippet({"distinct_id": "u-1", "props": {}})
|
|
|
|
assert "_superProps.user_id = \"u-1\"" in out
|
|
assert "_superProps.user_email" not in out
|
|
assert "_superProps.user_name" not in out
|
|
|
|
|
|
def test_template_user_block_anonymous_returns_none(monkeypatch):
|
|
monkeypatch.setenv("POSTHOG_API_KEY", "phc_x")
|
|
from app.web.router import _posthog_user_block
|
|
from src.observability import reset_posthog
|
|
reset_posthog()
|
|
with patch("posthog.Posthog"):
|
|
request = SimpleNamespace(state=SimpleNamespace()) # no user attribute
|
|
# `getattr` falls back to None — block should be None.
|
|
assert _posthog_user_block(request) is None
|
|
reset_posthog()
|