agnes-the-ai-analyst/tests/test_posthog_client.py
Vojtech 107195730d
feat(observability): optional PostHog integration (#231)
* 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.
2026-05-08 17:57:10 +04:00

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()