-
Notifications
You must be signed in to change notification settings - Fork 11.2k
feat(trace): add agent observability with Monocle #4024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ba5caeb
60559dd
39b4d49
a97ff2c
745409c
803be48
c2910f2
c1f8bff
232fd03
93f968d
cb165f8
e0122c9
ec30867
6c36548
96fd6ec
3ac4310
2920b60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ | |
| from app.gateway.trace_middleware import TraceMiddleware, resolve_trace_enabled | ||
| from deerflow.config import app_config as deerflow_app_config | ||
| from deerflow.logging_config import DEFAULT_LOG_DATE_FORMAT, DEFAULT_LOG_FORMAT, configure_logging | ||
| from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled | ||
| from deerflow.uploads.manager import cleanup_stale_upload_staging_files | ||
|
|
||
| AppConfig = deerflow_app_config.AppConfig | ||
|
|
@@ -189,6 +190,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: | |
| config = get_gateway_config() | ||
| logger.info(f"Starting API Gateway on {config.host}:{config.port}") | ||
|
|
||
| # Agent observability (Monocle). Off by default; enabled with | ||
| # MONOCLE_TRACING. Initialized here at startup — not at import time — so a | ||
| # plain `import deerflow.agents` never installs a process-global tracer. | ||
| # Unlike LangSmith/Langfuse, whose validation failures abort the agent run, | ||
| # a bad Monocle config only logs: the Gateway keeps serving without tracing. | ||
| try: | ||
| setup_monocle_tracing_if_enabled() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Embedded / TUI / CLI path gets no Monocle tracing. LangSmith and Langfuse are attached at the graph invocation root for both That's a real asymmetry vs the other two providers — at minimum call it out in AGENTS.md (embedded/TUI users must call Also: this lifespan wiring — the one line that makes the feature work — has no test asserting it's invoked during startup. A focused test monkeypatching the helper and asserting it's called from the lifespan would close that gap. |
||
| except Exception: # observability must never break startup | ||
| logger.exception("Monocle tracing setup failed; continuing without it") | ||
|
Comment on lines
+198
to
+201
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Graceful-degrade vs. fail-fast asymmetry — worth documenting. This The behavior is internally consistent with "cannot break startup", but the asymmetry isn't called out in |
||
|
|
||
| # Pre-warm tiktoken encoding cache so the first memory-injection request | ||
| # never blocks on the BPE data download (which hits an OpenAI/Azure URL | ||
| # that may be unreachable in restricted networks — see issue #3402). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,11 +47,47 @@ def validate(self) -> None: | |
| raise ValueError(f"Langfuse tracing is enabled but required settings are missing: {', '.join(missing)}") | ||
|
|
||
|
|
||
| # Manual mirror of monocle_apptrace's supported exporters, kept local so a typo | ||
| # fails at startup with a clear message instead of an opaque upstream error. | ||
| # Update this tuple when a monocle_apptrace bump adds or renames an exporter. | ||
| _MONOCLE_EXPORTERS = ("file", "console", "okahu", "s3", "blob", "gcs") | ||
|
WillemJiang marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class MonocleTracingConfig(BaseModel): | ||
| """Configuration for Monocle telemetry.""" | ||
|
|
||
| enabled: bool = Field(...) | ||
| exporters: str = Field(...) | ||
| okahu_api_key: str | None = Field(...) | ||
|
|
||
| @property | ||
| def is_enabled(self) -> bool: | ||
| # Unlike the siblings' is_configured, no credential check here: that is | ||
| # exporter-dependent and lives in validate(), run at Gateway startup. | ||
| return self.enabled | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
LangSmith/Langfuse fold credential presence into Defensible as a composite-validation choice, but a one-line comment explaining why Monocle's |
||
|
|
||
| @property | ||
| def exporter_list(self) -> list[str]: | ||
| """The configured exporters, parsed once so validation and setup agree.""" | ||
| return [e.strip() for e in self.exporters.split(",") if e.strip()] | ||
|
|
||
| def validate(self) -> None: | ||
| if not self.enabled: | ||
| return | ||
| selected = self.exporter_list | ||
| unknown = [e for e in selected if e not in _MONOCLE_EXPORTERS] | ||
| if unknown: | ||
| raise ValueError(f"MONOCLE_EXPORTERS has unknown exporter(s): {', '.join(unknown)}. Allowed: {', '.join(_MONOCLE_EXPORTERS)}.") | ||
| if "okahu" in selected and not self.okahu_api_key: | ||
| raise ValueError("Monocle 'okahu' exporter is selected but OKAHU_API_KEY is not set.") | ||
|
|
||
|
|
||
| class TracingConfig(BaseModel): | ||
| """Tracing configuration for supported providers.""" | ||
|
|
||
| langsmith: LangSmithTracingConfig = Field(...) | ||
| langfuse: LangfuseTracingConfig = Field(...) | ||
| monocle: MonocleTracingConfig = Field(...) | ||
|
|
||
| @property | ||
| def is_configured(self) -> bool: | ||
|
|
@@ -125,6 +161,11 @@ def get_tracing_config() -> TracingConfig: | |
| secret_key=_first_env_value("LANGFUSE_SECRET_KEY"), | ||
| host=_first_env_value("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com", | ||
| ), | ||
| monocle=MonocleTracingConfig( | ||
| enabled=_env_flag_preferred("MONOCLE_TRACING"), | ||
| exporters=_first_env_value("MONOCLE_EXPORTERS") or "file", | ||
| okahu_api_key=_first_env_value("OKAHU_API_KEY"), | ||
| ), | ||
| ) | ||
| return _tracing_config | ||
|
|
||
|
|
@@ -149,6 +190,16 @@ def is_tracing_enabled() -> bool: | |
| return get_tracing_config().is_configured | ||
|
|
||
|
|
||
| def is_monocle_tracing_enabled() -> bool: | ||
| """Whether Monocle OTel observability is enabled (via ``MONOCLE_TRACING``). | ||
|
|
||
| Kept separate from :func:`get_enabled_tracing_providers` because Monocle is a | ||
| process-global instrumentor activated at startup, not a per-run LangChain | ||
| callback. | ||
| """ | ||
| return get_tracing_config().monocle.is_enabled | ||
|
|
||
|
|
||
| def reset_tracing_config() -> None: | ||
| """Discard the cached :class:`TracingConfig` so the next call rebuilds it. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| from .factory import build_tracing_callbacks | ||
| from .metadata import build_langfuse_trace_metadata, inject_langfuse_metadata | ||
| from .monocle import setup_monocle_tracing_if_enabled | ||
|
|
||
| __all__ = [ | ||
| "build_langfuse_trace_metadata", | ||
| "build_tracing_callbacks", | ||
| "inject_langfuse_metadata", | ||
| "setup_monocle_tracing_if_enabled", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Monocle telemetry: initialized once from the Gateway lifespan when ``MONOCLE_TRACING`` is set.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from deerflow.config import ( | ||
| get_enabled_tracing_providers, | ||
| get_tracing_config, | ||
| is_monocle_tracing_enabled, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Read by build_tracing_callbacks() to hint embedded/TUI processes that | ||
| # enabled MONOCLE_TRACING but never ran the Gateway-lifespan setup. | ||
| _setup_completed = False | ||
|
|
||
|
|
||
| def is_monocle_setup_completed() -> bool: | ||
| """Whether :func:`setup_monocle_tracing_if_enabled` ran in this process.""" | ||
| return _setup_completed | ||
|
|
||
|
|
||
| def setup_monocle_tracing_if_enabled() -> bool: | ||
| """Initialize Monocle telemetry when ``MONOCLE_TRACING`` is enabled; a no-op otherwise. | ||
|
|
||
| ``monocle_apptrace.setup_monocle_telemetry()`` is idempotent, so this stays a thin, | ||
| config-gated wrapper. Returns ``True`` when enabled. | ||
| """ | ||
| if not is_monocle_tracing_enabled(): | ||
| return False | ||
|
|
||
| monocle = get_tracing_config().monocle | ||
| # Fail fast on an unknown MONOCLE_EXPORTERS value or a missing OKAHU_API_KEY, | ||
| # with a clear message, before instrumenting. Validated here (not in the | ||
| # per-run callback path) so a config typo never breaks agent runs. | ||
| monocle.validate() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Embedded/TUI path gets no signal that Monocle is a no-op. Because This matches the docs (embedded users call |
||
|
|
||
| # Coexistence with Langfuse (v4, also OTel-based) is verified: whichever | ||
| # library initializes second reuses the existing global TracerProvider and | ||
| # attaches its own span processor, so neither side loses spans (see | ||
| # test_coexists_with_langfuse). Both processors see all spans, so Monocle's | ||
| # exporters also capture Langfuse's spans when both are enabled. | ||
| exporters = monocle.exporters | ||
|
|
||
| # `console` stays on local stdout, so only the remote exporters are flagged. | ||
| off_box = [e for e in monocle.exporter_list if e not in ("file", "console")] | ||
| if off_box: | ||
| # Monocle's exporters see every span on the shared global provider, so a | ||
| # co-enabled OTel provider's spans leave the box too. | ||
| langfuse_note = " Langfuse is also enabled and shares the global provider, so its spans are exported there as well." if "langfuse" in get_enabled_tracing_providers() else "" | ||
| logger.warning( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The README notes that "Monocle's exporters also see Langfuse's spans when both are enabled," but this runtime warning only mentions Monocle's own trace data. A user running Langfuse + |
||
| "Monocle is exporting trace data (prompts, tool inputs/outputs, completions) beyond the local .monocle/ file via: %s. Make sure that destination is trusted.%s", | ||
| ", ".join(off_box), | ||
| langfuse_note, | ||
| ) | ||
|
|
||
| try: | ||
| from monocle_apptrace import setup_monocle_telemetry | ||
| except ImportError as exc: | ||
| raise RuntimeError("MONOCLE_TRACING is enabled but monocle_apptrace is not installed. Install the 'monocle' extra: `uv sync --extra monocle` in backend/, or `pip install 'deerflow-harness[monocle]'`.") from exc | ||
|
|
||
| # monocle_exporters_list takes the comma-separated string as-is (monocle_apptrace's API). | ||
| setup_monocle_telemetry(workflow_name="deer-flow", monocle_exporters_list=exporters) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security: spans capture prompts + tool I/O verbatim, with no DeerFlow-controlled scrubbing layer. Auto-instrumentation records span inputs/outputs — prompts, tool arguments ( Two suggestions: (1) log a loud startup warning whenever a non-
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This call installs a process-global OTel The mitigation (off by default, optional extra) is sound - just flagging this as the one coupling to weigh against the |
||
| global _setup_completed | ||
| _setup_completed = True | ||
| logger.info("Monocle telemetry enabled (exporters=%s)", exporters) | ||
| return True | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,9 @@ postgres = [ | |
| redis = ["redis>=5.0.0"] | ||
| pymupdf = ["pymupdf4llm>=0.0.17"] | ||
| boxlite = ["boxlite>=0.9.7"] | ||
| # Agent observability (Monocle). Optional so a default install stays free of the | ||
| # OpenTelemetry stack; only pulled in when MONOCLE_TRACING is used. | ||
| monocle = ["monocle_apptrace>=0.8.8"] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| [build-system] | ||
| requires = ["hatchling"] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.