Skip to content

Commit 36f53c9

Browse files
Add optional Monocle observability (opt-in, NAT telemetry plugin)
Signed-off-by: Mohammed Ansari <mohammed.ansari@okahu.ai>
1 parent 364e128 commit 36f53c9

6 files changed

Lines changed: 332 additions & 0 deletions

File tree

deploy/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ DASK_DISTRIBUTED__LOGGING__DISTRIBUTED=warning
118118
# LANGCHAIN_PROJECT=aiq-research
119119
# WANDB_API_KEY=
120120

121+
# Monocle (optional): OpenTelemetry tracing for agents. Requires the `monocle`
122+
# extra (`uv sync --extra monocle`). See docs/source/deployment/observability.md.
123+
# MONOCLE_TRACING=true
124+
# MONOCLE_EXPORTERS=file # file, console, okahu, s3, blob, gcs (default: file)
125+
# OKAHU_API_KEY=okh_xxxxxxxx # required only for the `okahu` exporter
126+
121127
# -----------------------------------------------------------------------------
122128
# Evaluation (optional)
123129
# -----------------------------------------------------------------------------

docs/source/deployment/observability.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,67 @@ general:
255255
shutdown_timeout: 30000
256256
```
257257

258+
## Monocle
259+
260+
[Monocle](https://github.com/monocle2ai/monocle) is an open-source, OpenTelemetry-based tracer for agentic applications. It instruments the frameworks already in use (LangChain, LangGraph, and others) and records each run end-to-end -- LLM calls, agent steps, and tool and MCP invocations, with their inputs, outputs, timings, and token counts. It is packaged as an **optional** tracing backend and is disabled unless you both install it and enable it -- the default install and default behavior are unchanged.
261+
262+
Each run writes one trace file to `.monocle/` in the working directory; open it in the [Monocle VS Code extension](https://marketplace.visualstudio.com/items?itemName=OkahuAI.monocle-apptrace) to inspect the span timeline and token counts. Connect to [Okahu](https://www.okahu.ai), an agent-observability platform, to analyze traces across runs and run trace-based and agentic evaluations (via the `okahu` exporter).
263+
264+
### Install
265+
266+
Monocle ships as an optional extra, so a default install does not pull the OpenTelemetry stack:
267+
268+
```bash
269+
uv sync --extra monocle
270+
# or, in an existing environment:
271+
pip install 'aiq-agent[monocle]'
272+
```
273+
274+
Enabling Monocle without the extra installed fails fast with an actionable error naming the install command; when Monocle is not enabled, `monocle_apptrace` is never imported.
275+
276+
### Enable
277+
278+
There are two ways to opt in. Both forward the exporter list to `setup_monocle_telemetry(workflow_name="nvidia-aiq", monocle_exporters_list=...)` and Monocle initializes at most once per process.
279+
280+
**Environment gate (matches the other demos).** Add the following to your `.env` file (`deploy/.env` for the CLI):
281+
282+
```bash
283+
MONOCLE_TRACING=true
284+
MONOCLE_EXPORTERS=file # file, console, okahu, s3, blob, gcs (default: file)
285+
OKAHU_API_KEY=okh_xxxxxxxx # required only for the `okahu` exporter
286+
```
287+
288+
The CLI reads these at startup and initializes Monocle before the workflow runs.
289+
290+
**NAT telemetry exporter (canonical).** Add the `monocle` exporter to your workflow YAML; it initializes when the workflow builds:
291+
292+
```yaml
293+
general:
294+
telemetry:
295+
tracing:
296+
monocle:
297+
_type: monocle
298+
workflow_name: nvidia-aiq # optional, stamped on spans
299+
exporters: file # optional; comma-separated, e.g. "file" or "file,okahu"
300+
```
301+
302+
`OKAHU_API_KEY` is always read from the environment. `MONOCLE_EXPORTERS` is the default for the YAML `exporters` field, so the two paths agree unless you override it in YAML.
303+
304+
**Precedence.** The environment gate runs at CLI startup, before the workflow (and any YAML `monocle` exporter) is built. When both are set, the env gate wins and the YAML exporter finds Monocle already initialized. To drive the exporter list purely from YAML, leave `MONOCLE_TRACING` unset.
305+
306+
### Data handling
307+
308+
Traces capture span inputs and outputs verbatim -- prompts, tool arguments, and model responses -- plus token usage and timings. The `file` exporter keeps them on local disk and never rotates or cleans them up, so prune `.monocle/` periodically; the remote exporters (`okahu`, `s3`, `blob`, `gcs`) send that same data off-box, so enable only destinations you trust. An unknown exporter name or a missing `OKAHU_API_KEY` fails fast with a clear error before any instrumentation.
309+
310+
### Configuration Reference
311+
312+
| Setting | Env / field | Default | Description |
313+
| :-- | :-- | :-- | :-- |
314+
| Enable | `MONOCLE_TRACING` (env) | off | Truthy gate for the environment opt-in path. |
315+
| Exporters | `MONOCLE_EXPORTERS` (env) / `exporters` (YAML) | `file` | Comma-separated Monocle exporters, validated against `file, console, okahu, s3, blob, gcs`. |
316+
| Okahu key | `OKAHU_API_KEY` (env) | -- | Required only when the `okahu` exporter is selected. |
317+
| Workflow name | `workflow_name` (YAML) | `nvidia-aiq` | Workflow name Monocle stamps onto emitted spans. |
318+
258319
## Verbose Logging
259320

260321
For quick debugging without any external services, enable the built-in verbose callback logger. This prints detailed agent execution information directly to the console.

frontends/cli/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ def main() -> None:
418418
except Exception as e:
419419
print(f"Warning: Failed to load .env file: {e}")
420420

421+
# Optional Monocle observability via the deer-flow-style MONOCLE_TRACING gate.
422+
# No-op (and never imports monocle_apptrace) unless MONOCLE_TRACING is truthy.
423+
from aiq_agent.observability.monocle_exporter import setup_monocle_tracing_if_enabled
424+
425+
setup_monocle_tracing_if_enabled()
426+
421427
# Validate LLM API keys based on config
422428
try:
423429
config_path = Path(args.config_file)
@@ -455,6 +461,11 @@ async def _run():
455461
loop = asyncio.get_event_loop()
456462
loop.run_until_complete(_run())
457463
finally:
464+
# os._exit skips normal interpreter shutdown, so flush pending Monocle/OTel
465+
# spans first — otherwise the last spans of a run are dropped on exit.
466+
from aiq_agent.observability.monocle_exporter import flush_monocle_if_enabled
467+
468+
flush_monocle_if_enabled()
458469
os._exit(0)
459470

460471

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ dependencies = [
5757
s3 = [
5858
"boto3>=1.35.0,<2",
5959
]
60+
# Optional Monocle observability backend, exposed as the `monocle` NAT tracing
61+
# exporter (see src/aiq_agent/observability/monocle_exporter.py). Not required
62+
# for normal operation; install only when you want Monocle tracing:
63+
# uv sync --extra monocle (or) pip install 'aiq-agent[monocle]'
64+
monocle = [
65+
"monocle_apptrace>=0.8.8,<0.9",
66+
]
6067
dev = [
6168
"pytest>=7.4.0",
6269
"pytest-asyncio>=0.21.0",

src/aiq_agent/agents/chat_researcher/register.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from aiq_agent.common.citation_verification import get_or_create_session_registry
3232
from aiq_agent.common.citation_verification import reset_session_registry
3333
from aiq_agent.common.citation_verification import set_session_registry
34+
from aiq_agent.observability.monocle_exporter import ensure_registered as _ensure_monocle_registered
3435
from aiq_agent.observability.otel_header_redaction_exporter import (
3536
ensure_registered as _ensure_otel_redaction_registered,
3637
)
@@ -55,6 +56,7 @@
5556
_REPORT_ASK_TIMEOUT_S = 120
5657

5758
_ensure_otel_redaction_registered()
59+
_ensure_monocle_registered()
5860

5961

6062
def _build_report_ask_prompt(
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Optional Monocle observability for AI-Q.
17+
18+
Monocle (``monocle_apptrace``) instruments supported frameworks (LangChain,
19+
LangGraph, etc.) in place and manages its own OpenTelemetry pipeline. It is not
20+
a hard dependency of AI-Q: it activates only when a user opts in, and only if
21+
``monocle_apptrace`` is installed (``uv sync --extra monocle``).
22+
23+
There are two opt-in paths, both funnelling through :func:`_setup_monocle_once`
24+
so Monocle initializes at most once per process:
25+
26+
* **NAT telemetry exporter (canonical).** Add a ``monocle`` exporter under
27+
``general.telemetry.tracing`` in the workflow YAML. This is the NAT-idiomatic
28+
mechanism; the exporter is built (and Monocle initialized) only when the
29+
workflow that references it is loaded.
30+
* **Environment gate (deer-flow-style).** Set ``MONOCLE_TRACING=true`` and the
31+
CLI initializes Monocle at startup via :func:`setup_monocle_tracing_if_enabled`,
32+
reading ``MONOCLE_EXPORTERS`` / ``OKAHU_API_KEY``. Handy for enabling tracing
33+
without editing the config.
34+
35+
Precedence: the env gate runs at CLI startup, before the workflow (and thus any
36+
YAML ``monocle`` exporter) is built, so when both are set the env gate wins and
37+
the YAML exporter finds Monocle already initialized and does nothing further. To
38+
drive the exporter list purely from YAML, leave ``MONOCLE_TRACING`` unset.
39+
40+
When neither path is taken, this module registers a config type with the NAT
41+
registry but never imports ``monocle_apptrace`` -- default behavior is unchanged.
42+
"""
43+
44+
import logging
45+
import os
46+
47+
from pydantic import Field
48+
49+
from nat.builder.builder import Builder
50+
from nat.cli.register_workflow import register_telemetry_exporter
51+
from nat.data_models.telemetry_exporter import TelemetryExporterBaseConfig
52+
from nat.observability.exporter.base_exporter import BaseExporter
53+
54+
logger = logging.getLogger(__name__)
55+
56+
# Default workflow name stamped onto Monocle spans.
57+
_DEFAULT_WORKFLOW_NAME = "nvidia-aiq"
58+
59+
# Manual mirror of monocle_apptrace's supported exporters, kept local so a typo
60+
# fails fast with a clear message instead of an opaque upstream error. Update
61+
# this tuple when a monocle_apptrace bump adds or renames an exporter.
62+
_MONOCLE_EXPORTERS = ("file", "console", "okahu", "s3", "blob", "gcs")
63+
64+
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
65+
66+
# Guard so global Monocle instrumentation is initialized at most once per process,
67+
# regardless of which opt-in path fires first.
68+
_MONOCLE_INITIALIZED = False
69+
70+
71+
def _env_flag(name: str) -> bool:
72+
"""Whether env var ``name`` is set to a truthy value."""
73+
value = os.environ.get(name)
74+
return bool(value) and value.strip().lower() in _TRUTHY_VALUES
75+
76+
77+
def _parse_exporters(exporters: str) -> list[str]:
78+
"""Split a comma-separated exporter string, dropping blanks."""
79+
return [e.strip() for e in exporters.split(",") if e.strip()]
80+
81+
82+
def _resolve_exporters(exporter_list: list[str], okahu_api_key: str | None) -> list[str]:
83+
"""Validate and return the effective exporter list.
84+
85+
An unknown exporter is a config typo, so it still fails fast with an actionable
86+
``ValueError`` before any instrumentation runs. A missing secret, however, must
87+
degrade gracefully: when ``okahu`` is selected without ``OKAHU_API_KEY`` it is
88+
dropped (with a warning) and the remaining exporters continue. The returned list
89+
may be empty, in which case the caller skips Monocle cleanly rather than crashing.
90+
"""
91+
unknown = [e for e in exporter_list if e not in _MONOCLE_EXPORTERS]
92+
if unknown:
93+
raise ValueError(
94+
f"MONOCLE_EXPORTERS has unknown exporter(s): {', '.join(unknown)}. "
95+
f"Allowed: {', '.join(_MONOCLE_EXPORTERS)}."
96+
)
97+
if "okahu" in exporter_list and not okahu_api_key:
98+
logger.warning(
99+
"Monocle 'okahu' exporter is selected but OKAHU_API_KEY is not set; "
100+
"skipping the okahu exporter and continuing with the remaining exporters."
101+
)
102+
exporter_list = [e for e in exporter_list if e != "okahu"]
103+
return exporter_list
104+
105+
106+
def flush_monocle_if_enabled() -> None:
107+
"""Force-flush pending Monocle/OpenTelemetry spans; a no-op if Monocle was never set up.
108+
109+
Call this before a hard process exit (e.g. ``os._exit``) so pending spans that the
110+
batch span processor has not yet exported are not silently dropped.
111+
"""
112+
if not _MONOCLE_INITIALIZED:
113+
return
114+
try:
115+
from opentelemetry import trace
116+
117+
force_flush = getattr(trace.get_tracer_provider(), "force_flush", None)
118+
if callable(force_flush):
119+
force_flush()
120+
except Exception: # pragma: no cover - best-effort flush during shutdown
121+
logger.debug("Monocle span flush on exit failed.", exc_info=True)
122+
123+
124+
def _setup_monocle_once(workflow_name: str, exporters: str) -> None:
125+
"""Initialize Monocle telemetry once per process.
126+
127+
Imports ``monocle_apptrace`` lazily and raises a clear ``RuntimeError`` with
128+
the install hint when the optional dependency is missing. ``exporters`` is a
129+
comma-separated string passed to ``monocle_exporters_list`` as-is.
130+
"""
131+
global _MONOCLE_INITIALIZED
132+
if _MONOCLE_INITIALIZED:
133+
return
134+
135+
try:
136+
from monocle_apptrace import setup_monocle_telemetry
137+
except ImportError as exc:
138+
raise RuntimeError(
139+
"Monocle observability is enabled but the optional 'monocle_apptrace' package is not installed. "
140+
"Install the 'monocle' extra: `uv sync --extra monocle` (or `pip install 'aiq-agent[monocle]'`)."
141+
) from exc
142+
143+
setup_monocle_telemetry(workflow_name=workflow_name, monocle_exporters_list=exporters or None)
144+
_MONOCLE_INITIALIZED = True
145+
logger.info(
146+
"Monocle telemetry initialized (workflow_name=%s, exporters=%s).",
147+
workflow_name,
148+
exporters or "<from environment>",
149+
)
150+
151+
152+
def setup_monocle_tracing_if_enabled(workflow_name: str = _DEFAULT_WORKFLOW_NAME) -> bool:
153+
"""Initialize Monocle from the environment when ``MONOCLE_TRACING`` is truthy.
154+
155+
Mirrors the env surface documented across the sibling demos:
156+
157+
* ``MONOCLE_TRACING`` -- truthy gate, off by default.
158+
* ``MONOCLE_EXPORTERS`` -- comma-separated exporter list, default ``file``.
159+
* ``OKAHU_API_KEY`` -- required only when the ``okahu`` exporter is selected.
160+
161+
A no-op returning ``False`` when the gate is off. Called from the CLI startup
162+
so embedded/other entry points can call it themselves. Validates before
163+
instrumenting; a bad value raises ``ValueError``.
164+
"""
165+
if not _env_flag("MONOCLE_TRACING"):
166+
return False
167+
168+
exporters = (os.environ.get("MONOCLE_EXPORTERS") or "file").strip() or "file"
169+
exporter_list = _resolve_exporters(_parse_exporters(exporters), os.environ.get("OKAHU_API_KEY"))
170+
if not exporter_list:
171+
logger.warning("MONOCLE_TRACING is enabled but no usable exporters remain; skipping Monocle.")
172+
return False
173+
# Off-box exporters move prompts, tool I/O, and completions beyond local disk.
174+
off_box = [e for e in exporter_list if e not in ("file", "console")]
175+
if off_box:
176+
logger.warning(
177+
"Monocle is exporting trace data (prompts, tool inputs/outputs, completions) beyond the local "
178+
".monocle/ directory via: %s. Make sure that destination is trusted.",
179+
", ".join(off_box),
180+
)
181+
182+
_setup_monocle_once(workflow_name=workflow_name, exporters=",".join(exporter_list))
183+
return True
184+
185+
186+
def ensure_registered() -> None:
187+
"""Import side effect: registers the ``monocle`` telemetry exporter config type.
188+
189+
Importing this module runs the ``@register_telemetry_exporter`` decorator
190+
below, which is all that is needed for ``_type: monocle`` to be resolvable in
191+
workflow config. Kept as an explicit no-op call to mirror the sibling
192+
``otel_header_redaction_exporter`` registration idiom.
193+
"""
194+
return None
195+
196+
197+
class MonocleTelemetryExporter(TelemetryExporterBaseConfig, name="monocle"):
198+
"""Optional Monocle observability backend.
199+
200+
Enable by adding this exporter under ``general.telemetry.tracing`` in a
201+
workflow config. Requires the ``monocle`` optional dependency to be
202+
installed; if it is missing, building the exporter raises a clear error
203+
instead of failing at import time.
204+
"""
205+
206+
workflow_name: str = Field(
207+
default=_DEFAULT_WORKFLOW_NAME,
208+
description="Workflow name Monocle stamps onto emitted spans.",
209+
)
210+
exporters: str = Field(
211+
# Defaults to the same MONOCLE_EXPORTERS env var the CLI env gate reads,
212+
# so the two opt-in paths agree; falls back to 'file'.
213+
default_factory=lambda: (os.environ.get("MONOCLE_EXPORTERS") or "file").strip() or "file",
214+
description="Comma-separated Monocle exporters (passed as monocle_exporters_list), "
215+
"e.g. 'file', 'okahu', or 'file,okahu'. Validated against: " + ", ".join(_MONOCLE_EXPORTERS) + ".",
216+
)
217+
218+
219+
class _MonocleInstrumentationExporter(BaseExporter):
220+
"""No-op NAT exporter that represents the Monocle instrumentation lifecycle.
221+
222+
Monocle exports spans through its own pipeline, so this exporter does not
223+
consume NAT intermediate steps. It exists so Monocle initialization is tied
224+
to NAT's telemetry-exporter lifecycle (built only when configured).
225+
"""
226+
227+
def export(self, event) -> None: # noqa: D102 - inherited semantics; intentionally a no-op
228+
return None
229+
230+
231+
@register_telemetry_exporter(config_type=MonocleTelemetryExporter)
232+
async def monocle_telemetry_exporter(config: MonocleTelemetryExporter, _builder: Builder):
233+
"""Initialize Monocle telemetry when the ``monocle`` exporter is configured."""
234+
# If the env gate (or a prior workflow load) already initialized Monocle, the YAML
235+
# settings are documented as a no-op — return the exporter without re-validating.
236+
if _MONOCLE_INITIALIZED:
237+
yield _MonocleInstrumentationExporter()
238+
return
239+
# OKAHU_API_KEY is always sourced from the environment, matching the env gate.
240+
exporter_list = _resolve_exporters(_parse_exporters(config.exporters), os.environ.get("OKAHU_API_KEY"))
241+
if exporter_list:
242+
_setup_monocle_once(workflow_name=config.workflow_name, exporters=",".join(exporter_list))
243+
else:
244+
logger.warning("Monocle exporter configured but no usable exporters remain; skipping Monocle.")
245+
yield _MonocleInstrumentationExporter()

0 commit comments

Comments
 (0)