|
| 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