deer-flow/backend/tests/test_monocle_tracing.py
Xinmin Zeng f2e832330e
fix(sandbox): enforce disabled skills in filesystem views (#4178)
* fix(sandbox): project enabled skills into sandbox views

* fix(skills): keep projection mutations consistent

* fix(skills): fail closed on projection errors

* fix(skills): isolate per-scope failures during boot projection rebuild

rebuild_all_skill_projections() propagated any exception from the public
rebuild or from a single user's rebuild straight out of the gateway
lifespan startup, uncaught. A single broken user directory (bad
permissions, corrupted _skill_states.json, unreadable content) would
therefore abort gateway boot for every user, not just that one -
_rebuild_*_locked already fails closed internally (clears the view and
re-raises), so the boot loop only needed to stop treating that re-raise
as fatal.

Each scope's rebuild now fails closed independently and boot continues;
a scope left empty by a boot failure self-heals on the next sandbox
acquire via ensure_skill_projections().

Also patches deerflow.skills.projection.rebuild_all_skill_projections in
the memory-flush lifespan test fixture, matching the two sibling
fixtures in the same file — this call is now on the lifespan startup
path and the fixture's minimal SimpleNamespace config predates it.

* test(skills): update authz test for the projection-aware public toggle

_persist_shared_skill_state (introduced earlier in this branch) reads
the shared extensions_config.json fresh from disk under the projection
lock instead of through the cached get_extensions_config() singleton -
that's the whole point of the fix (stale worker caches must not clobber
another worker's concurrent update). The name no longer exists on the
skills router module, so the test's monkeypatch of it started raising
AttributeError instead of exercising the endpoint.

The mock storage in this test isn't a real LocalSkillStorage instance,
so _persist_shared_skill_state's projection-mutation branch is already
skipped (nullcontext) and it falls back to a fresh ExtensionsConfig()
for the nonexistent tmp config_path - no replacement monkeypatch needed.

* fix(sandbox): make skill projection ensure best-effort in acquire

acquire() called _ensure_skills_projection() directly, outside any
try/except, in both LocalSandboxProvider and AioSandboxProvider. Every
other skill-mount setup path in these providers has always caught
exceptions and logged a warning rather than failing sandbox acquire
outright (e.g. when config.yaml can't be resolved) - these two new call
sites broke that contract, so any projection failure (including simply
not having a config.yaml, as in CI's test environment) now failed
acquire() itself instead of just leaving skill mounts off.

_ensure_skills_projection now catches its own exceptions and returns
None; both providers' callers already tolerate that (a None projection
skips the skill-specific mounts, matching the existing degrade path)
after making _append_public_skill_mapping and the custom/legacy mount
block in LocalSandboxProvider explicitly None-safe.

Caught by running the full suite with config.yaml removed, matching
CI's environment - not caught locally because a real config.yaml was
present, masking the failure.

* fix(sandbox): make E2B skill projection mounts best-effort

_skill_projection_mounts called ensure_skill_projections with no guard,
unlike Local/AIO's _ensure_skills_projection. A raise propagated out of
_apply_mounts before the configured-mounts loop ran, so a skills
projection failure dropped the operator's own configured mounts too -
only caught by create()'s outer warning, with nothing applied at all.

Swallow here and return an empty mount list on failure, matching the
Local/AIO pattern: still fail-closed for skills, but no longer widens
the blast radius to unrelated configured mounts.

Review feedback from PR #4178.

* docs(skills): document projection trade-offs flagged in review

- _update_tree_digest: note the metadata-only (not content) hashing
  trade-off and why runtime writes through this codebase are still
  covered regardless (rebuild-under-lock + rename always changes inode).
- LocalSandboxProvider.acquire: note the acquire-time self-heal cost
  (cheap on a fresh manifest, ~400ms rebuild under lock on stale/drift).
- skill_projection_mutation: drop the no-op except-Exception-then-raise;
  a raise from the mutation already propagates past the yield with the
  view left cleared, no explicit re-raise needed.
- provisioner README: spell out that hostPath skills volumes require
  the gateway and K8s node to share DEER_FLOW_HOST_BASE_DIR (single-node
  or shared storage), and that the custom/legacy volumes' hostPath type
  Directory (not DirectoryOrCreate) makes a violation of that assumption
  a visible Pod-creation failure instead of a silent empty mount.

Review feedback from PR #4178.

* fix(skills): lazily repair user projections

* fix(skills): close projection review gaps

* fix(skills): refresh user projection enable state

* fix(skills): close projection review follow-ups

* fix(skills): preserve state across projection writes

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-31 17:55:24 +08:00

401 lines
16 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("deerflow.skills.projection.ensure_public_skill_projection"),
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("deerflow.skills.projection.ensure_public_skill_projection"),
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)