From 20b0d1ec7b70f51ae57f2940d8850ae5eca98fd9 Mon Sep 17 00:00:00 2001 From: Mohammed Ansari Date: Wed, 15 Jul 2026 15:58:36 -0700 Subject: [PATCH] Add optional Monocle observability (opt-in) --- .env.example | 5 ++ README.md | 18 +++++ deep_researcher/main.py | 66 +++++++++++------ deep_researcher/utils/telemetry.py | 110 +++++++++++++++++++++++++++++ setup.py | 6 ++ 5 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 deep_researcher/utils/telemetry.py diff --git a/.env.example b/.env.example index cfd8228..fa1c19f 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,8 @@ MAIN_MODEL_PROVIDER=openai MAIN_MODEL=gpt-4o FAST_MODEL_PROVIDER=openai FAST_MODEL=gpt-4o-mini + +# Optional Monocle telemetry (requires: pip install deep-researcher[monocle]) +# MONOCLE_TRACING=true +# MONOCLE_EXPORTERS=file # file, console, okahu, s3, blob, gcs (default: file) +# OKAHU_API_KEY=okh_xxxxxxxx # required only for the `okahu` exporter diff --git a/README.md b/README.md index f4f49b1..2a63eff 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,24 @@ LLMs are configured and managed in the `deep_researcher/llm_config.py` file. The Deep Research assistant integrates with OpenAI's trace monitoring system. Each research session generates a trace ID that can be used to monitor the execution flow and agent interactions in real-time through the OpenAI platform. +### Monocle Tracing + +This project also supports [Monocle](https://github.com/monocle2ai/monocle), an OpenTelemetry-based tracer for agentic applications. It records each run end-to-end: LLM calls, agent steps, and tool invocations, with inputs, outputs, timings, and token counts. + +Monocle is an optional extra. Install it and add the following to your `.env` file: + +```bash +pip install "deep-researcher[monocle]" +``` + +```bash +MONOCLE_TRACING=true +MONOCLE_EXPORTERS=file # file, console, okahu, s3, blob, gcs (default: file) +OKAHU_API_KEY=okh_xxxxxxxx # required only for the `okahu` exporter +``` + +Each run writes one trace file to `.monocle/`; open it in the [Monocle VS Code extension](https://marketplace.visualstudio.com/items?itemName=OkahuAI.monocle-apptrace). Connect to [Okahu](https://www.okahu.ai) to analyze traces across runs (via the `okahu` exporter). + ## Observations and Limitations ### Rate Limits diff --git a/deep_researcher/main.py b/deep_researcher/main.py index 8c90a85..acbf2a4 100644 --- a/deep_researcher/main.py +++ b/deep_researcher/main.py @@ -1,12 +1,18 @@ import asyncio import argparse +from contextlib import AsyncExitStack from .iterative_research import IterativeResearcher from .deep_research import DeepResearcher from typing import Literal from dotenv import load_dotenv +from .utils.telemetry import setup_telemetry, trace_span, trace_scope, current_span load_dotenv(override=True) +# Optional Monocle telemetry: a no-op unless MONOCLE_TRACING is truthy AND the +# optional monocle_apptrace package is installed (pip install deep-researcher[monocle]). +setup_telemetry(workflow_name="openai-agents-deep-research") + async def main() -> None: parser = argparse.ArgumentParser(description="Deep Research Assistant") @@ -34,26 +40,46 @@ async def main() -> None: print(f"Starting deep research on: {query}") print(f"Max iterations: {args.max_iterations}, Max time: {args.max_time} minutes") - if args.model == "deep": - manager = DeepResearcher( - max_iterations=args.max_iterations, - max_time_minutes=args.max_time, - verbose=args.verbose, - tracing=args.tracing - ) - report = await manager.run(query) - else: - manager = IterativeResearcher( - max_iterations=args.max_iterations, - max_time_minutes=args.max_time, - verbose=args.verbose, - tracing=args.tracing - ) - report = await manager.run( - query, - output_length=args.output_length, - output_instructions=args.output_instructions - ) + # Under Monocle we shape the whole run as ONE trace, per the recipe: + # workflow -> agentic.turn (this user request) -> agentic.invocation (each agent). + # The orchestrator fires many independent Runner.run calls; without an enclosing + # span each starts its own trace. A single workflow root + one agentic.turn here + # collapses each Runner.run into that turn as an invocation. All helpers are + # no-ops when telemetry is disabled/absent, so the run is unchanged. + report = "" + async with AsyncExitStack() as stack: + await stack.enter_async_context(trace_scope("agentic.session")) + await stack.enter_async_context(trace_span(span_name="openai-agents-deep-research")) + await stack.enter_async_context(trace_scope("agentic.turn")) + await stack.enter_async_context(trace_span( + span_name="research_turn", + attributes={"span.type": "agentic.turn", "entity.1.name": query[:128]}, + events=[{"name": "data.input", "attributes": {"input": query}}], + )) + if args.model == "deep": + manager = DeepResearcher( + max_iterations=args.max_iterations, + max_time_minutes=args.max_time, + verbose=args.verbose, + tracing=args.tracing + ) + report = await manager.run(query) + else: + manager = IterativeResearcher( + max_iterations=args.max_iterations, + max_time_minutes=args.max_time, + verbose=args.verbose, + tracing=args.tracing + ) + report = await manager.run( + query, + output_length=args.output_length, + output_instructions=args.output_instructions + ) + # record the turn's final output on the agentic.turn span before it closes + turn_span = current_span() + if turn_span is not None: + turn_span.add_event("data.output", {"response": report}) print("\n=== Final Report ===") print(report) diff --git a/deep_researcher/utils/telemetry.py b/deep_researcher/utils/telemetry.py new file mode 100644 index 0000000..6e03969 --- /dev/null +++ b/deep_researcher/utils/telemetry.py @@ -0,0 +1,110 @@ +"""Optional Monocle telemetry integration. + +Monocle (``monocle_apptrace``) is an optional observability dependency, activated +only when ``MONOCLE_TRACING`` is truthy AND the package is installed +(``pip install deep-researcher[monocle]``). The env interface is deliberately +unprefixed (``MONOCLE_TRACING`` / ``MONOCLE_EXPORTERS`` / ``OKAHU_API_KEY``) so it +matches the surface used across the other Monocle-enabled apps. + +When telemetry is disabled or absent, every helper here is a clean no-op: no +import errors, no extra spans, no behaviour change. +""" + +import contextlib +import os +from typing import Any, Optional + +_TRUTHY = {"1", "true", "yes", "on"} + +# Mirror of monocle_apptrace's supported exporters, kept local so a typo fails +# fast with a clear message instead of an opaque upstream error. +_MONOCLE_EXPORTERS = ("file", "console", "okahu", "s3", "blob", "gcs") + +# Set True by setup_telemetry only when Monocle is both enabled and installed. +_ACTIVE = False + + +def monocle_enabled() -> bool: + """Return True if Monocle telemetry is enabled via MONOCLE_TRACING.""" + value = os.environ.get("MONOCLE_TRACING", "") + return value.strip().lower() in _TRUTHY + + +def _exporters() -> str: + """The configured, comma-separated exporter string (default ``file``).""" + value = os.environ.get("MONOCLE_EXPORTERS", "") + return value.strip() if value and value.strip() else "file" + + +def setup_telemetry(workflow_name: str) -> bool: + """Initialise Monocle telemetry when enabled; a clean no-op otherwise. + + Reads MONOCLE_EXPORTERS, validates it, then forwards the comma-separated + string to setup_monocle_telemetry. Returns True when telemetry was activated. + """ + global _ACTIVE + if not monocle_enabled(): + return False + + exporters = _exporters() + selected = [e.strip() for e in exporters.split(",") if e.strip()] + + # Fail fast on an unknown exporter or okahu without a key, before instrumenting. + 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)}. " + f"Allowed: {', '.join(_MONOCLE_EXPORTERS)}." + ) + if "okahu" in selected and not os.environ.get("OKAHU_API_KEY"): + raise ValueError("Monocle 'okahu' exporter is selected but OKAHU_API_KEY is not set.") + + 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: pip install \"deep-researcher[monocle]\"." + ) from exc + + # monocle_exporters_list takes the comma-separated string as-is (monocle's API). + setup_monocle_telemetry(workflow_name=workflow_name, monocle_exporters_list=exporters) + _ACTIVE = True + return True + + +@contextlib.asynccontextmanager +async def trace_span(*args: Any, **kwargs: Any): + """Open a Monocle span when telemetry is active; a no-op otherwise.""" + if _ACTIVE: + from monocle_apptrace.instrumentation.common.instrumentor import amonocle_trace + + async with amonocle_trace(*args, **kwargs): + yield + else: + yield + + +@contextlib.asynccontextmanager +async def trace_scope(*args: Any, **kwargs: Any): + """Open a Monocle scope when telemetry is active; a no-op otherwise.""" + if _ACTIVE: + from monocle_apptrace.instrumentation.common.scope_wrapper import ( + amonocle_trace_scope, + ) + + async with amonocle_trace_scope(*args, **kwargs): + yield + else: + yield + + +def current_span() -> Optional[Any]: + """Return the current Monocle span when telemetry is active, else None.""" + if _ACTIVE: + from monocle_apptrace.instrumentation.common.wrapper import ( + get_current_monocle_span, + ) + + return get_current_monocle_span() + return None diff --git a/setup.py b/setup.py index 24226b5..fd51610 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,12 @@ 'dev': [ 'pytest', 'pytest-asyncio' + ], + # Optional agent observability via Monocle. Install with: + # pip install deep-researcher[monocle] + # and enable at runtime with MONOCLE_TRACING=true (see README). + 'monocle': [ + 'monocle_apptrace' ] }, entry_points={