deer-flow/backend/tests/test_monocle_tracing.py
Daoyuan Li d2b5f884e3
fix(channels): buffer GitHub follow-ups during busy runs (#4133)
Queue comments received while a run is active, then submit one deduplicated follow-up after it finishes. Failed drains are requeued and watcher tasks stop cleanly with the channel manager.
2026-07-24 22:41:07 +08:00

399 lines
15 KiB
Python

"""Tests for Monocle telemetry setup.
Covers the config gate (``MONOCLE_TRACING`` default off / toggle on), the setup
helper's behavior (off-box exporter warning, exporter validation, idempotency,
Langfuse coexistence), the Gateway-lifespan wiring, and the regression that
importing ``deerflow.agents`` no longer sets up telemetry at import time.
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import sys
import textwrap
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# monocle_apptrace is an optional extra (pinned in the dev group); skip the whole
# module in minimal installs instead of erroring at collection.
pytest.importorskip("monocle_apptrace")
from deerflow.config import is_monocle_tracing_enabled
from deerflow.config.tracing_config import get_tracing_config, reset_tracing_config
from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled
_TRACING_ENV = (
"MONOCLE_TRACING",
"MONOCLE_EXPORTERS",
"OKAHU_API_KEY",
"LANGFUSE_TRACING",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
)
@pytest.fixture(autouse=True)
def clear_monocle_env(monkeypatch):
for name in _TRACING_ENV:
monkeypatch.delenv(name, raising=False)
# The setup-completed flag is process-global; reset it so a test that runs
# (mocked) setup cannot change how later tests observe the embedded hint.
monkeypatch.setattr("deerflow.tracing.monocle._setup_completed", False)
reset_tracing_config()
yield
reset_tracing_config()
def test_disabled_by_default():
assert is_monocle_tracing_enabled() is False
assert get_tracing_config().monocle.enabled is False
def test_setup_noop_when_disabled(monkeypatch):
called = False
def _fail(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", _fail)
assert setup_monocle_tracing_if_enabled() is False
assert called is False
def test_toggles_on_and_sets_up(monkeypatch):
monkeypatch.setenv("MONOCLE_TRACING", "true")
reset_tracing_config()
captured: dict = {}
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: captured.update(kw))
assert is_monocle_tracing_enabled() is True
assert setup_monocle_tracing_if_enabled() is True
assert captured == {"workflow_name": "deer-flow", "monocle_exporters_list": "file"}
def test_custom_exporters(monkeypatch):
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "file,console")
reset_tracing_config()
captured: dict = {}
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: captured.update(kw))
assert setup_monocle_tracing_if_enabled() is True
assert captured["monocle_exporters_list"] == "file,console"
def test_warns_on_non_file_exporter(monkeypatch, caplog):
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "file,s3")
reset_tracing_config()
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
with caplog.at_level(logging.WARNING):
assert setup_monocle_tracing_if_enabled() is True
warnings = [r.message for r in caplog.records if "beyond the local" in r.message]
assert warnings, "expected an off-box exporter warning"
assert "s3" in warnings[0]
assert "Langfuse" not in warnings[0] # only mentioned when Langfuse is co-enabled
def test_off_box_warning_mentions_langfuse_when_co_enabled(monkeypatch, caplog):
"""With Langfuse sharing the global provider, its spans leave the box too — say so."""
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "okahu")
monkeypatch.setenv("OKAHU_API_KEY", "okh_test")
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
reset_tracing_config()
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
with caplog.at_level(logging.WARNING):
assert setup_monocle_tracing_if_enabled() is True
warnings = [r.message for r in caplog.records if "beyond the local" in r.message]
assert warnings, "expected an off-box exporter warning"
assert "Langfuse" in warnings[0]
def test_no_off_box_warning_for_file_exporter(monkeypatch, caplog):
monkeypatch.setenv("MONOCLE_TRACING", "true") # default exporter is file
reset_tracing_config()
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
with caplog.at_level(logging.WARNING):
assert setup_monocle_tracing_if_enabled() is True
assert not any("beyond the local" in r.message for r in caplog.records)
def test_no_off_box_warning_for_console_exporter(monkeypatch, caplog):
"""``console`` writes to local stdout, so it must not trip the off-box warning."""
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "file,console")
reset_tracing_config()
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
with caplog.at_level(logging.WARNING):
assert setup_monocle_tracing_if_enabled() is True
assert not any("beyond the local" in r.message for r in caplog.records)
def test_coexists_with_langfuse():
"""Monocle and Langfuse (v4, OTel-based) share the global provider without span loss.
Verified against the installed langfuse: whichever library initializes second
reuses the existing global ``TracerProvider`` and attaches its own span
processor, so both sides keep exporting. Runs the real setup (no mocks) in a
subprocess so the process-global provider never leaks into the suite.
"""
script = textwrap.dedent(
"""
import os
os.environ["MONOCLE_TRACING"] = "true"
os.environ["MONOCLE_EXPORTERS"] = "console"
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-test"
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-test"
os.environ["LANGFUSE_HOST"] = "http://127.0.0.1:9" # unreachable; offline test
from opentelemetry import trace
from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled
# Gateway order: Monocle at startup, Langfuse per-run afterwards.
assert setup_monocle_tracing_if_enabled() is True
provider = trace.get_tracer_provider()
from langfuse import Langfuse
Langfuse(tracing_enabled=True)
assert trace.get_tracer_provider() is provider # provider not replaced
# NOTE: reaches into OTel SDK internals (no public API lists a
# provider's span processors). The SDK is pinned by uv.lock; if a bump
# renames these attributes, update this introspection — the coexistence
# behavior itself is unaffected.
names = [type(p).__name__ for p in provider._active_span_processor._span_processors]
assert any("Langfuse" in n for n in names), names # Langfuse attached alongside Monocle
print("COEXIST_OK")
"""
)
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert "COEXIST_OK" in result.stdout
def test_rejects_unknown_exporter(monkeypatch):
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "fle")
reset_tracing_config()
with pytest.raises(ValueError, match="unknown exporter"):
setup_monocle_tracing_if_enabled()
def test_okahu_exporter_requires_api_key(monkeypatch):
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "okahu")
reset_tracing_config()
with pytest.raises(ValueError, match="OKAHU_API_KEY"):
setup_monocle_tracing_if_enabled()
def test_okahu_exporter_with_api_key_ok(monkeypatch):
monkeypatch.setenv("MONOCLE_TRACING", "true")
monkeypatch.setenv("MONOCLE_EXPORTERS", "okahu")
monkeypatch.setenv("OKAHU_API_KEY", "okh_test")
reset_tracing_config()
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
assert setup_monocle_tracing_if_enabled() is True
def test_embedded_hint_when_enabled_but_uninitialized(monkeypatch, caplog):
"""``build_tracing_callbacks()`` hints when Monocle is enabled but setup never ran.
The embedded ``DeerFlowClient`` and the TUI never hit the Gateway lifespan,
so this debug line is the only in-process signal explaining why no Monocle
traces appear.
"""
from deerflow.tracing import build_tracing_callbacks
monkeypatch.setenv("MONOCLE_TRACING", "true")
reset_tracing_config()
monkeypatch.setattr("deerflow.tracing.monocle._setup_completed", False)
# Scoped to the factory's logger: earlier tests in the suite may have run
# configure_logging(), which pins an explicit INFO level on the hierarchy
# that a root-level caplog.at_level(DEBUG) would not override.
with caplog.at_level(logging.DEBUG, logger="deerflow.tracing.factory"):
assert build_tracing_callbacks() == []
assert any("not initialized in this process" in r.message for r in caplog.records)
def test_no_embedded_hint_after_setup(monkeypatch, caplog):
from deerflow.tracing import build_tracing_callbacks
monkeypatch.setenv("MONOCLE_TRACING", "true")
reset_tracing_config()
monkeypatch.setattr("deerflow.tracing.monocle._setup_completed", False)
monkeypatch.setattr("monocle_apptrace.setup_monocle_telemetry", lambda **kw: None)
assert setup_monocle_tracing_if_enabled() is True
with caplog.at_level(logging.DEBUG, logger="deerflow.tracing.factory"):
build_tracing_callbacks()
assert not any("not initialized in this process" in r.message for r in caplog.records)
def test_no_import_time_setup():
"""Regression: importing deerflow.agents must not install telemetry.
The setup call used to live at module import in ``deerflow/agents/__init__``.
It now happens only via the gateway lifespan, so a plain import must neither
expose ``setup_monocle_telemetry`` nor install a global OTel
``TracerProvider`` (which is what ``setup_monocle_telemetry`` does). Runs in
a subprocess so the import is genuinely fresh and, unlike deleting
``sys.modules`` entries in-process, cannot corrupt module identity for the
rest of the suite.
"""
script = textwrap.dedent(
"""
from opentelemetry import trace
from opentelemetry.trace import ProxyTracerProvider
import deerflow.agents as agents
assert not hasattr(agents, "setup_monocle_telemetry")
# Still the SDK-less default proxy: no provider was installed on import.
assert isinstance(trace.get_tracer_provider(), ProxyTracerProvider)
print("IMPORT_CLEAN_OK")
"""
)
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert "IMPORT_CLEAN_OK" in result.stdout
def test_double_invoke_is_idempotent():
"""Calling setup twice must not double-instrument.
Exercises upstream ``check_duplicate_setup`` with the real tracer (no mock).
Run in a subprocess so the process-global OTel provider it installs never
leaks into the rest of the suite.
"""
script = textwrap.dedent(
"""
import os
os.environ["MONOCLE_TRACING"] = "true"
os.environ["MONOCLE_EXPORTERS"] = "console" # avoid writing .monocle/ files
from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled
from monocle_apptrace.instrumentation.common.instrumentor import get_monocle_instrumentor
assert setup_monocle_tracing_if_enabled() is True
first = get_monocle_instrumentor()
assert first is not None
assert setup_monocle_tracing_if_enabled() is True
assert get_monocle_instrumentor() is first # no second provider installed
print("IDEMPOTENT_OK")
"""
)
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert "IDEMPOTENT_OK" in result.stdout
def test_gateway_lifespan_initializes_monocle():
"""The Gateway lifespan is the sole Monocle call site; pin that wiring.
Mirrors the patching in ``test_gateway_lifespan_shutdown.py`` so the lifespan
can be driven directly, and asserts the setup helper runs during startup.
"""
from fastapi import FastAPI
from app.gateway.app import lifespan
@asynccontextmanager
async def _noop_langgraph_runtime(_app, _startup_config):
yield
startup_config = SimpleNamespace(log_level="INFO", memory=SimpleNamespace(token_counting="char"))
fake_service = MagicMock()
fake_service.get_status = MagicMock(return_value={})
async def fake_start(_startup_config, **_kwargs):
return fake_service
setup_spy = MagicMock(return_value=False)
with (
patch("app.gateway.app.get_app_config", return_value=startup_config),
patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)),
patch("app.gateway.app.langgraph_runtime", _noop_langgraph_runtime),
patch("app.gateway.app.setup_monocle_tracing_if_enabled", setup_spy),
patch("app.gateway.app.auth.close_oidc_service", AsyncMock()),
patch("app.channels.service.start_channel_service", side_effect=fake_start),
patch("app.channels.service.stop_channel_service", AsyncMock()),
):
async def drive() -> None:
async with lifespan(FastAPI()):
pass
asyncio.run(drive())
setup_spy.assert_called_once_with()
def test_gateway_lifespan_survives_monocle_setup_failure(caplog):
"""A raising Monocle setup (e.g. bad MONOCLE_EXPORTERS) must not break startup.
Pins the lifespan's fail-open contract: the error is logged and the Gateway
keeps serving without tracing.
"""
from fastapi import FastAPI
from app.gateway.app import lifespan
@asynccontextmanager
async def _noop_langgraph_runtime(_app, _startup_config):
yield
startup_config = SimpleNamespace(log_level="INFO", memory=SimpleNamespace(token_counting="char"))
fake_service = MagicMock()
fake_service.get_status = MagicMock(return_value={})
async def fake_start(_startup_config, **_kwargs):
return fake_service
setup_spy = MagicMock(side_effect=ValueError("MONOCLE_EXPORTERS has unknown exporter(s): fle."))
with (
patch("app.gateway.app.get_app_config", return_value=startup_config),
patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)),
patch("app.gateway.app.langgraph_runtime", _noop_langgraph_runtime),
patch("app.gateway.app.setup_monocle_tracing_if_enabled", setup_spy),
patch("app.gateway.app.auth.close_oidc_service", AsyncMock()),
patch("app.channels.service.start_channel_service", side_effect=fake_start),
patch("app.channels.service.stop_channel_service", AsyncMock()),
):
async def drive() -> None:
async with lifespan(FastAPI()):
pass
with caplog.at_level(logging.ERROR, logger="app.gateway.app"):
asyncio.run(drive()) # completes despite the raising setup
setup_spy.assert_called_once_with()
assert any("Monocle tracing setup failed" in r.message for r in caplog.records)