1154 lines
43 KiB
Python
1154 lines
43 KiB
Python
"""
|
|
Agent-plane observability using the OpenTelemetry SDK directly.
|
|
|
|
See ``designs/OBSERVABILITY.md`` for the full design. The module
|
|
is intentionally thin — it holds only the omnigent-specific
|
|
concerns:
|
|
|
|
* **Trace ID derivation from the response ID.** Agent-plane response
|
|
IDs are ``resp_<32-char hex>``. We reuse the hex suffix as the
|
|
W3C trace ID so operators can look up a trace by its response ID
|
|
without a lookup table. :func:`trace_context_for_response` injects
|
|
a synthetic ``traceparent`` via the W3C TraceContext propagator.
|
|
|
|
* **Runtime init.** :func:`init` installs an OTLP ``TracerProvider``
|
|
when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set. When the endpoint is
|
|
absent, tracing is still enabled so operators who install their own
|
|
provider externally get spans for free; the default no-op provider
|
|
discards them silently.
|
|
|
|
* **Subprocess trace propagation.** :func:`get_traceparent_env`
|
|
serializes the current trace context into env vars the executor
|
|
subprocess launchers can merge into their child process env.
|
|
|
|
* **A handful of record helpers** where the work is non-trivial
|
|
(LLM usage normalization, cancellation tagging). Trivial
|
|
operations like ``span.set_attribute(...)`` are called directly
|
|
at instrumentation sites.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import logging
|
|
import os
|
|
import re
|
|
from collections.abc import Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
from opentelemetry.context import Context
|
|
from opentelemetry.sdk._logs.export import LogExporter
|
|
from opentelemetry.sdk.metrics.export import MetricExporter
|
|
from opentelemetry.trace import Span
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_RESP_PREFIX = "resp_"
|
|
_HEX_LEN = 32
|
|
# Sentinel span ID used in trace_context_for_response. start_agent_span
|
|
# detects this value and strips the parent so the agent span is exported
|
|
# as a true root span (parent_span_id absent in OTLP proto).
|
|
SENTINEL_PARENT_SPAN_ID = 0x1000000000000001
|
|
|
|
_capture_content: bool = False
|
|
_initialized: bool = False
|
|
_metrics_initialized: bool = False
|
|
_logs_initialized: bool = False
|
|
|
|
# Session (conversation) id for the current execution context. Set once at a
|
|
# session boundary (request hook, executor turn, forwarder task); the
|
|
# _SessionIdSpanProcessor reads it on_start and stamps `session.id` on EVERY
|
|
# span created in that context — so runner/harness operations are tagged
|
|
# generically, with no per-operation code. Default None = no stamping.
|
|
_session_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
"omnigent_session_id", default=None
|
|
)
|
|
|
|
|
|
def _env_bool(name: str) -> bool:
|
|
"""
|
|
Parse a boolean environment variable.
|
|
|
|
Truthy values are ``"true"``, ``"1"``, ``"yes"`` (case-insensitive).
|
|
Anything else (including unset) is ``False``.
|
|
|
|
:param name: The environment variable name, e.g.
|
|
``"OMNIGENT_OTEL_CAPTURE_CONTENT"``.
|
|
:returns: ``True`` if the env var is set to a truthy value.
|
|
"""
|
|
return os.environ.get(name, "").strip().lower() in ("true", "1", "yes")
|
|
|
|
|
|
def should_capture_content() -> bool:
|
|
"""
|
|
Return whether message content should be included on spans.
|
|
|
|
Controlled by ``OMNIGENT_OTEL_CAPTURE_CONTENT``. Call sites
|
|
read this flag before populating span inputs / outputs with user
|
|
messages or tool results. Content capture is off by default
|
|
because messages may contain PII or secrets.
|
|
|
|
:returns: ``True`` when content capture is enabled.
|
|
"""
|
|
return _capture_content
|
|
|
|
|
|
def telemetry_enabled() -> bool:
|
|
"""
|
|
Return whether telemetry is enabled for this process.
|
|
|
|
Master opt-in controlled by ``OMNIGENT_TELEMETRY_ENABLED`` (off by
|
|
default). When it is unset/false, :func:`init` is a no-op and every
|
|
instrumentor and manual-span helper short-circuits — a default
|
|
install installs no instrumentation, creates no spans, and emits no
|
|
telemetry, so users who never opt in pay nothing. Read directly from
|
|
the environment (not cached) so it is correct no matter the order in
|
|
which instrumentation entry points run relative to :func:`init`.
|
|
|
|
:returns: ``True`` when ``OMNIGENT_TELEMETRY_ENABLED`` is truthy.
|
|
"""
|
|
return _env_bool("OMNIGENT_TELEMETRY_ENABLED")
|
|
|
|
|
|
# Max characters of a serialized payload to attach to a span. Bodies can be
|
|
# large; the trace backend is not a payload store, so cap aggressively.
|
|
_CONTENT_MAX_LEN = 4096
|
|
|
|
# Substrings that mark a payload key as a secret to redact even when content
|
|
# capture is on — a frame body like ``host.launch_runner`` carries a
|
|
# ``binding_token``, and we never want a credential on a span.
|
|
_REDACT_KEY_SUBSTRINGS = (
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"authorization",
|
|
"credential",
|
|
"api_key",
|
|
"apikey",
|
|
)
|
|
|
|
|
|
def _redact_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Copy a message payload, redacting secret-looking values.
|
|
|
|
Drops the W3C propagation keys (they ride in the envelope, not the
|
|
message) and replaces any value whose key looks like a credential
|
|
with ``"[redacted]"``. Non-secret values pass through unchanged.
|
|
|
|
:param payload: The decoded frame / message dict.
|
|
:returns: A shallow copy safe to serialize onto a span.
|
|
"""
|
|
redacted: dict[str, Any] = {}
|
|
for key, value in payload.items():
|
|
lowered = key.lower()
|
|
if lowered in ("traceparent", "tracestate"):
|
|
continue
|
|
if any(token in lowered for token in _REDACT_KEY_SUBSTRINGS):
|
|
redacted[key] = "[redacted]"
|
|
else:
|
|
redacted[key] = value
|
|
return redacted
|
|
|
|
|
|
def _payload_to_attribute(payload: Mapping[str, Any]) -> str:
|
|
"""
|
|
Serialize a redacted payload to a length-capped JSON string.
|
|
|
|
:param payload: The message dict to record.
|
|
:returns: A JSON string, truncated to :data:`_CONTENT_MAX_LEN`.
|
|
"""
|
|
import json
|
|
|
|
redacted = _redact_payload(payload)
|
|
try:
|
|
text = json.dumps(redacted, default=str)
|
|
except (TypeError, ValueError):
|
|
text = str(redacted)
|
|
if len(text) > _CONTENT_MAX_LEN:
|
|
text = text[:_CONTENT_MAX_LEN] + "…[truncated]"
|
|
return text
|
|
|
|
|
|
def record_message_payload(
|
|
payload: Mapping[str, Any],
|
|
*,
|
|
span: Any = None,
|
|
key: str = "omnigent.message.payload",
|
|
) -> None:
|
|
"""
|
|
Attach a message payload to a span, gated by content capture.
|
|
|
|
No-op unless ``OMNIGENT_OTEL_CAPTURE_CONTENT`` is set — payloads may
|
|
hold PII, so capturing the literal body of an inter-service message
|
|
is opt-in. The payload is redacted (:func:`_redact_payload`) and
|
|
length-capped before it is recorded.
|
|
|
|
:param payload: The decoded frame / message dict to record.
|
|
:param span: The span to annotate. Defaults to the currently active
|
|
span; a no-op if none is recording.
|
|
:param key: The span attribute name to use.
|
|
"""
|
|
if not should_capture_content():
|
|
return
|
|
target = span
|
|
if target is None:
|
|
from opentelemetry import trace as otel_trace
|
|
|
|
target = otel_trace.get_current_span()
|
|
if target is None or not getattr(target, "is_recording", lambda: False)():
|
|
return
|
|
target.set_attribute(key, _payload_to_attribute(payload))
|
|
|
|
|
|
def set_session_id(session_id: str | None) -> None:
|
|
"""
|
|
Stamp ``session.id`` on the currently active span.
|
|
|
|
For session-scoped work where the conversation id is NOT in the
|
|
request path — notably ``POST /v1/sessions`` (create), where the id
|
|
is minted server-side and returned in the body, so the path-based
|
|
:func:`_fastapi_session_id_hook` cannot tag it. Call this once the id
|
|
is known so the span joins the session's ``session.id`` group.
|
|
|
|
Best-effort and gated by the master opt-in: a no-op when telemetry is
|
|
off, the id is falsy, or no span is recording.
|
|
|
|
:param session_id: The Omnigent session (conversation) id, e.g.
|
|
``"conv_…"``.
|
|
"""
|
|
if not session_id or not telemetry_enabled():
|
|
return
|
|
# Bind for the rest of this context so child spans (e.g. the create's DB
|
|
# writes) get tagged by _SessionIdSpanProcessor too; then stamp the
|
|
# already-started active span directly (its on_start has already passed).
|
|
_session_id_var.set(session_id)
|
|
try:
|
|
from opentelemetry import trace as otel_trace
|
|
|
|
span = otel_trace.get_current_span()
|
|
if span is not None and span.is_recording():
|
|
span.set_attribute("session.id", session_id)
|
|
except Exception: # pragma: no cover - telemetry must never break requests
|
|
pass
|
|
|
|
|
|
def current_session_id() -> str | None:
|
|
"""
|
|
Return the session id bound in the current context, or ``None``.
|
|
|
|
Lets a caller (e.g. the executor adapter) prefer the conversation id the
|
|
request hook already bound — authoritative, from the ``/sessions/<conv>/``
|
|
path — over a less-reliable local key.
|
|
|
|
:returns: The active ``session.id`` value, or ``None`` if unset.
|
|
"""
|
|
return _session_id_var.get()
|
|
|
|
|
|
@contextmanager
|
|
def session_scope(session_id: str | None) -> Iterator[None]:
|
|
"""
|
|
Bind a session id for the current execution context and its children.
|
|
|
|
Every span started while this scope is active is tagged with
|
|
``session.id`` by :class:`_SessionIdSpanProcessor`. Set it ONCE at a
|
|
session boundary — the FastAPI request hook, an executor turn, a
|
|
forwarder task — so all runner/harness operations (current and future,
|
|
including DB/httpx child spans) get the attribute generically, instead
|
|
of stamping each span by hand. No-op for a falsy id or when telemetry
|
|
is off.
|
|
|
|
:param session_id: The Omnigent session (conversation) id, e.g. ``conv_…``.
|
|
"""
|
|
if not session_id or not telemetry_enabled():
|
|
yield
|
|
return
|
|
token = _session_id_var.set(session_id)
|
|
try:
|
|
yield
|
|
finally:
|
|
_session_id_var.reset(token)
|
|
|
|
|
|
def _make_session_id_processor() -> Any:
|
|
"""
|
|
Build a span processor that stamps ``session.id`` from the active
|
|
:data:`_session_id_var` onto every recording span.
|
|
|
|
Registered on the runtime ``TracerProvider`` (:func:`_init_otel_traces`)
|
|
so the session id flows onto all spans — server, runner, harness, and any
|
|
future operation — with no per-call-site code. Subclasses the SDK
|
|
``SpanProcessor`` so it satisfies the full processor interface (e.g. the
|
|
internal ``_on_ending`` hook); only ``on_start`` is overridden. Built
|
|
lazily because the OTel SDK is not a hard import dependency of this module.
|
|
|
|
:returns: A ``SpanProcessor`` instance.
|
|
"""
|
|
from opentelemetry.sdk.trace import SpanProcessor
|
|
|
|
class _SessionIdSpanProcessor(SpanProcessor):
|
|
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
try:
|
|
session_id = _session_id_var.get()
|
|
if session_id and span.is_recording():
|
|
span.set_attribute("session.id", session_id)
|
|
except Exception: # pragma: no cover - telemetry must never break spans
|
|
pass
|
|
|
|
return _SessionIdSpanProcessor()
|
|
|
|
|
|
def _fastapi_instrumentation_enabled() -> bool:
|
|
"""
|
|
Decide whether to install FastAPI server instrumentation.
|
|
|
|
Default-on when a tracing backend is configured
|
|
(``OTEL_EXPORTER_OTLP_ENDPOINT`` is set) — that is the only situation
|
|
where HTTP server spans have somewhere to go, and it is where
|
|
end-to-end trace propagation across the server / runner / harness
|
|
ASGI apps matters.
|
|
|
|
The explicit ``OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION`` flag always
|
|
wins when set: ``true`` forces it on (e.g. with an in-memory
|
|
exporter in tests), any other value forces it off. Bare installs
|
|
with no backend stay uninstrumented, avoiding span overhead for
|
|
users who are not tracing.
|
|
|
|
:returns: ``True`` if FastAPI instrumentation should be installed.
|
|
"""
|
|
if not telemetry_enabled():
|
|
return False
|
|
explicit = os.environ.get("OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION")
|
|
if explicit is not None:
|
|
return explicit.strip().lower() in ("true", "1", "yes")
|
|
return bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip())
|
|
|
|
|
|
# Session id as it appears in a request path: ``/v1/sessions/<conv_…>/...``
|
|
# (runner-internal conversations use the ``agy_conv_`` prefix). Used to stamp
|
|
# ``session.id`` onto the auto-created FastAPI server span.
|
|
_SESSION_ID_IN_PATH = re.compile(r"/sessions/((?:agy_)?conv_[0-9a-f]+)")
|
|
|
|
|
|
def _fastapi_session_id_hook(span: Any, scope: Mapping[str, Any]) -> None:
|
|
"""
|
|
FastAPI server-request hook: stamp ``session.id`` from the request path.
|
|
|
|
Runs on every server span ``FastAPIInstrumentor`` creates. When the path
|
|
is a session-scoped route it tags the span with the Omnigent session
|
|
(conversation) id, so server and runner request spans — POST ``/events``,
|
|
the SSE stream, and the tunneled server→runner hops — share the
|
|
``session.id`` grouping key. This is also what links the *decoupled* JSONL
|
|
forwarder (which re-POSTs into ``/events`` under its own trace) back to a
|
|
session: its server-side span carries the id even though it cannot share
|
|
the originating request's trace context.
|
|
|
|
Best-effort and defensive — telemetry must never break request handling.
|
|
|
|
:param span: The server span started by ``FastAPIInstrumentor``.
|
|
:param scope: The ASGI connection scope; ``scope["path"]`` is the route.
|
|
"""
|
|
try:
|
|
match = _SESSION_ID_IN_PATH.search(scope.get("path") or "")
|
|
if not match:
|
|
return
|
|
session_id = match.group(1)
|
|
# Bind for the request's whole span tree (DB, httpx, policy, …) via the
|
|
# processor, then stamp the server span directly (it already started, so
|
|
# the processor's on_start has already run for it).
|
|
_session_id_var.set(session_id)
|
|
if span is not None and span.is_recording():
|
|
span.set_attribute("session.id", session_id)
|
|
except Exception: # pragma: no cover - telemetry must never break requests
|
|
pass
|
|
|
|
|
|
def instrument_fastapi_app(app: FastAPI) -> None:
|
|
"""
|
|
Install OpenTelemetry FastAPI server instrumentation on an app.
|
|
|
|
Enabled by default whenever a tracing backend is configured (see
|
|
:func:`_fastapi_instrumentation_enabled`); set
|
|
``OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION`` to force it on or off.
|
|
Installing it on the server, runner, and harness ASGI apps is what
|
|
continues an incoming ``traceparent`` across each HTTP boundary.
|
|
|
|
:param app: FastAPI app instance to instrument.
|
|
"""
|
|
if not _fastapi_instrumentation_enabled():
|
|
return
|
|
try:
|
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
|
|
FastAPIInstrumentor.instrument_app(app, server_request_hook=_fastapi_session_id_hook)
|
|
except Exception:
|
|
_logger.exception("failed to initialize FastAPI OpenTelemetry instrumentation")
|
|
|
|
|
|
def _instrument_httpx() -> None:
|
|
"""
|
|
Install OpenTelemetry HTTPX client instrumentation process-wide.
|
|
|
|
Wraps every ``httpx`` client so outbound requests inject the W3C
|
|
``traceparent`` header and emit a client span. This is what carries
|
|
the trace across the TUI/SDK client → server hop, the server →
|
|
runner reverse-tunnel (whose transport forwards request headers
|
|
verbatim), and the native-harness policy HTTP hook.
|
|
|
|
Disabled when ``OMNIGENT_OTEL_HTTP_CLIENT_INSTRUMENTATION=false``.
|
|
Set this to suppress internal API call spans (server↔runner↔harness
|
|
requests) from appearing in the trace backend alongside agent spans.
|
|
|
|
Idempotent: ``HTTPXClientInstrumentor`` no-ops if already
|
|
instrumented. Failures degrade quietly — tracing is best-effort and
|
|
must never break request handling.
|
|
"""
|
|
explicit = os.environ.get("OMNIGENT_OTEL_HTTP_CLIENT_INSTRUMENTATION")
|
|
if explicit is not None and explicit.strip().lower() not in ("true", "1", "yes"):
|
|
return
|
|
try:
|
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
|
|
HTTPXClientInstrumentor().instrument()
|
|
except Exception:
|
|
_logger.exception("failed to initialize HTTPX OpenTelemetry instrumentation")
|
|
|
|
|
|
def instrument_httpx_client(client: Any) -> None:
|
|
"""
|
|
Instrument a single httpx client built on a custom transport.
|
|
|
|
The process-wide :func:`_instrument_httpx` only patches httpx's
|
|
*standard* transports' request methods. A client constructed with a
|
|
custom :class:`~httpx.AsyncBaseTransport` — notably the server→runner
|
|
``WSTunnelTransport`` — defines its own ``handle_async_request`` and
|
|
is therefore invisible to that global hook: its outbound requests
|
|
carry no ``traceparent`` and emit no client span, so the runner roots
|
|
a fresh trace instead of nesting under the caller's span.
|
|
|
|
``instrument_client`` wraps the client *instance* directly, which does
|
|
work over a custom transport. Calling it on the cached per-runner
|
|
client makes the synchronous event-forward propagate the active trace
|
|
context across the tunnel, so the runner's request span becomes a
|
|
child of the originating server request — one connected trace across
|
|
the server→runner boundary.
|
|
|
|
Best-effort: failures degrade quietly — tracing must never break
|
|
request handling.
|
|
|
|
:param client: The ``httpx.AsyncClient`` (or sync ``Client``) to
|
|
instrument in place.
|
|
"""
|
|
if not telemetry_enabled():
|
|
return
|
|
try:
|
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
|
|
HTTPXClientInstrumentor.instrument_client(client)
|
|
except Exception:
|
|
_logger.exception("failed to instrument httpx client over custom transport")
|
|
|
|
|
|
def instrument_sqlalchemy_engine(engine: Any) -> None:
|
|
"""
|
|
Install OpenTelemetry instrumentation on a single SQLAlchemy engine.
|
|
|
|
Every statement executed on ``engine`` becomes a child span under
|
|
the active trace, so a request's database work shows up inline in
|
|
the trace waterfall. Called once per engine at creation time (see
|
|
``omnigent.db.utils.get_or_create_engine``).
|
|
|
|
``opentelemetry-instrumentation-sqlalchemy`` is an optional
|
|
dependency; when it is absent (bare installs without the tracing
|
|
extras) this degrades to a no-op. Other failures are logged but not
|
|
raised — instrumentation must never block engine creation.
|
|
|
|
:param engine: The SQLAlchemy :class:`~sqlalchemy.engine.Engine` to
|
|
instrument.
|
|
"""
|
|
if not telemetry_enabled():
|
|
return
|
|
try:
|
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
|
|
|
SQLAlchemyInstrumentor().instrument(engine=engine)
|
|
except ImportError:
|
|
_logger.debug("SQLAlchemy OpenTelemetry instrumentation not installed; skipping")
|
|
except Exception:
|
|
_logger.exception("failed to instrument SQLAlchemy engine")
|
|
|
|
|
|
def parse_provider_name(model: str) -> tuple[str, str]:
|
|
"""
|
|
Split a provider-prefixed model string into ``(provider, model)``.
|
|
|
|
Agent-plane model strings follow ``"<provider>/<model>"``, e.g.
|
|
``"openai/gpt-5.4"`` becomes ``("openai", "gpt-5.4")``. Unprefixed
|
|
strings return an empty provider string so the span always has a
|
|
value to record.
|
|
|
|
:param model: The model identifier, e.g. ``"openai/gpt-5.4"``
|
|
or ``"gpt-5.4"``.
|
|
:returns: ``(provider, model)`` tuple. Provider is empty if the
|
|
input has no prefix.
|
|
"""
|
|
if "/" in model:
|
|
provider, _, rest = model.partition("/")
|
|
return provider, rest
|
|
return "", model
|
|
|
|
|
|
def trace_id_from_response_id(response_id: str) -> str:
|
|
"""
|
|
Extract the 32-char hex trace ID from an omnigent response ID.
|
|
|
|
Response IDs have the format ``resp_<32-char hex>`` (generated
|
|
via ``generate_task_id``). The hex suffix is a valid 128-bit
|
|
W3C trace ID. Reusing it as the trace ID lets operators jump
|
|
from a response ID to its trace by stripping the ``resp_``
|
|
prefix — no lookup table, no search query.
|
|
|
|
:param response_id: The response/task ID, e.g.
|
|
``"resp_d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3"``.
|
|
:returns: The 32-char lowercase hex trace ID.
|
|
:raises ValueError: If the response ID does not start with
|
|
``"resp_"`` or the hex suffix is not exactly 32 chars.
|
|
"""
|
|
if not response_id.startswith(_RESP_PREFIX):
|
|
raise ValueError(f"Expected {_RESP_PREFIX!r} prefix, got {response_id!r}")
|
|
hex_part = response_id[len(_RESP_PREFIX) :]
|
|
if len(hex_part) > _HEX_LEN:
|
|
raise ValueError(
|
|
f"Expected at most {_HEX_LEN} hex chars after prefix, "
|
|
f"got {len(hex_part)} in {response_id!r}"
|
|
)
|
|
# Zero-pad short hex suffixes (e.g. 24-char harness-allocated
|
|
# IDs) to a valid 128-bit W3C trace ID. The padding preserves
|
|
# uniqueness — the original hex is a prefix of the trace ID.
|
|
hex_part = hex_part.ljust(_HEX_LEN, "0")
|
|
try:
|
|
int(hex_part, 16)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid hex suffix in {response_id!r}: {exc}") from exc
|
|
return hex_part
|
|
|
|
|
|
@contextmanager
|
|
def trace_context_for_response(
|
|
response_id: str,
|
|
*,
|
|
root_response_id: str | None = None,
|
|
) -> Iterator[None]:
|
|
"""
|
|
Set the active trace context for a workflow invocation.
|
|
|
|
Derives the W3C trace ID from ``root_response_id`` (if set) or
|
|
``response_id``, then injects a synthetic ``traceparent`` header via
|
|
the W3C TraceContext propagator to make any span started inside the
|
|
context manager inherit this trace ID.
|
|
|
|
For root invocations pass only ``response_id``; the trace ID is
|
|
derived from it so direct response-ID → trace-ID lookup works.
|
|
For sub-agent invocations pass both ``response_id`` (the
|
|
sub-agent's own ID, exposed as ``task.id`` on the span) and
|
|
``root_response_id`` (the root of the spawn tree, used as the
|
|
trace ID) so all sub-agents share the root's trace.
|
|
|
|
:param response_id: The response/task ID for this invocation,
|
|
e.g. ``"resp_d8e9f0a1..."``.
|
|
:param root_response_id: The root response ID if this is a
|
|
sub-agent invocation, otherwise ``None``.
|
|
:raises ValueError: If ``response_id`` (or ``root_response_id``
|
|
when set) cannot be parsed.
|
|
"""
|
|
from opentelemetry import context
|
|
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
|
|
effective = root_response_id or response_id
|
|
trace_id_hex = trace_id_from_response_id(effective)
|
|
|
|
# Inject a synthetic traceparent to pin all spans to the response-derived
|
|
# trace ID. The dummy parent span ID (1000000000000001) is a sentinel —
|
|
# it never matches any real span so the agent span is effectively the
|
|
# root for display purposes, even though it has a non-null parent_id in
|
|
# the OTLP payload.
|
|
traceparent = f"00-{trace_id_hex}-{SENTINEL_PARENT_SPAN_ID:016x}-01"
|
|
ctx = TraceContextTextMapPropagator().extract({"traceparent": traceparent})
|
|
token = context.attach(ctx)
|
|
try:
|
|
yield
|
|
finally:
|
|
context.detach(token)
|
|
|
|
|
|
# OTel GenAI semantic convention attribute keys for token usage.
|
|
_GEN_AI_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
_GEN_AI_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
_GEN_AI_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
|
|
_GEN_AI_CACHE_READ_TOKENS = "gen_ai.usage.cache_read_input_tokens"
|
|
_GEN_AI_CACHE_CREATION_TOKENS = "gen_ai.usage.cache_creation_input_tokens"
|
|
|
|
|
|
def record_llm_usage(span: Span, usage: dict[str, Any]) -> None:
|
|
"""
|
|
Record token usage on an LLM span.
|
|
|
|
Uses OTel GenAI semantic convention attributes
|
|
(``gen_ai.usage.*``) so the data is readable by any OTel backend
|
|
without MLflow-specific translation.
|
|
|
|
Cache breakdown attributes are recorded only when present.
|
|
Their absence is meaningful (the provider did not report
|
|
caching) and should not be masked with invented zeros.
|
|
|
|
:param span: The LLM span to annotate.
|
|
:param usage: Token usage dict from the LLM response. Known
|
|
keys: ``"input_tokens"``, ``"output_tokens"``,
|
|
``"total_tokens"``, ``"cache_read_input_tokens"``,
|
|
``"cache_creation_input_tokens"``.
|
|
"""
|
|
input_tokens = int(usage.get("input_tokens", 0))
|
|
output_tokens = int(usage.get("output_tokens", 0))
|
|
total = usage.get("total_tokens")
|
|
if total is None:
|
|
total = input_tokens + output_tokens
|
|
span.set_attribute(_GEN_AI_INPUT_TOKENS, input_tokens)
|
|
span.set_attribute(_GEN_AI_OUTPUT_TOKENS, output_tokens)
|
|
span.set_attribute(_GEN_AI_TOTAL_TOKENS, int(total))
|
|
if "cache_read_input_tokens" in usage:
|
|
span.set_attribute(_GEN_AI_CACHE_READ_TOKENS, int(usage["cache_read_input_tokens"]))
|
|
if "cache_creation_input_tokens" in usage:
|
|
span.set_attribute(
|
|
_GEN_AI_CACHE_CREATION_TOKENS, int(usage["cache_creation_input_tokens"])
|
|
)
|
|
|
|
|
|
def record_error(span: Span, exc: BaseException) -> None:
|
|
"""
|
|
Mark a span as failed with an ``error.type`` attribute.
|
|
|
|
``span.record_exception`` captures the stack trace and message;
|
|
this helper adds the ``error.type`` attribute (exception class
|
|
name) so operators can filter by class in the trace backend
|
|
without reading the exception event.
|
|
|
|
:param span: The span to mark as failed.
|
|
:param exc: The exception that caused the failure.
|
|
"""
|
|
from opentelemetry.trace import StatusCode
|
|
|
|
span.set_status(StatusCode.ERROR, str(exc))
|
|
span.set_attribute("error.type", type(exc).__name__)
|
|
span.set_attribute("error.message", str(exc))
|
|
span.record_exception(exc)
|
|
|
|
|
|
def record_cancellation(span: Span) -> None:
|
|
"""
|
|
Mark a span as cancelled.
|
|
|
|
Neither OTel nor MLflow has a dedicated ``CANCELLED`` status, so
|
|
we use ``ERROR`` with ``error.type = "cancelled"`` as the
|
|
distinguishing attribute. Operators filter cancelled traces via
|
|
the attribute.
|
|
|
|
:param span: The span to mark as cancelled.
|
|
"""
|
|
from opentelemetry.trace import StatusCode
|
|
|
|
span.set_status(StatusCode.ERROR)
|
|
span.set_attribute("error.type", "cancelled")
|
|
|
|
|
|
def get_traceparent_env() -> dict[str, str]:
|
|
"""
|
|
Serialize the current trace context into env vars for subprocess
|
|
inheritance.
|
|
|
|
Used by executor subprocess launchers (Claude Agent SDK) to
|
|
propagate the parent trace into a child process that emits its
|
|
own OTel spans — the child's spans nest under the omnigent
|
|
root span in the same trace.
|
|
|
|
:returns: A dict with ``TRACEPARENT`` (and optionally
|
|
``TRACESTATE``) suitable for merging into the ``env`` dict
|
|
passed to ``subprocess.Popen`` or executor SDK options.
|
|
Empty dict when no span is active.
|
|
"""
|
|
from opentelemetry.trace.propagation.tracecontext import (
|
|
TraceContextTextMapPropagator,
|
|
)
|
|
|
|
carrier: dict[str, str] = {}
|
|
TraceContextTextMapPropagator().inject(carrier)
|
|
result: dict[str, str] = {}
|
|
if "traceparent" in carrier:
|
|
result["TRACEPARENT"] = carrier["traceparent"]
|
|
if "tracestate" in carrier:
|
|
result["TRACESTATE"] = carrier["tracestate"]
|
|
return result
|
|
|
|
|
|
def inject_trace_context(carrier: dict[str, str]) -> dict[str, str]:
|
|
"""
|
|
Inject the active W3C trace context into a dict carrier.
|
|
|
|
For the JSON-frame transports that no auto-instrumentor can see —
|
|
the host-daemon tunnel (``host/frames.py``) and the session-updates
|
|
websocket — call this on the **send** side so the frame carries a
|
|
``traceparent`` (and ``tracestate`` when present). The receiver pairs
|
|
it with :func:`extract_trace_context`.
|
|
|
|
Uses the global OpenTelemetry propagator (W3C Trace Context by
|
|
default), so the standard lowercase header names are written. When no
|
|
span is active the carrier is left unchanged.
|
|
|
|
:param carrier: A mutable string-keyed dict, typically the frame
|
|
about to be serialized to JSON.
|
|
:returns: The same ``carrier`` for convenient chaining.
|
|
"""
|
|
from opentelemetry.propagate import inject
|
|
|
|
inject(carrier)
|
|
return carrier
|
|
|
|
|
|
def extract_trace_context(carrier: Mapping[str, str]) -> Context:
|
|
"""
|
|
Extract a remote W3C trace context from a dict carrier.
|
|
|
|
The **receive**-side counterpart to :func:`inject_trace_context`.
|
|
Pass the returned context to ``start_as_current_span(context=...)``
|
|
so the span created while handling the frame nests under the sender's
|
|
trace instead of rooting a new one.
|
|
|
|
:param carrier: A string-keyed mapping that may hold ``traceparent`` /
|
|
``tracestate`` keys, e.g. a decoded JSON frame.
|
|
:returns: An OpenTelemetry :class:`~opentelemetry.context.Context`.
|
|
When the carrier holds no trace headers this is the current
|
|
(possibly empty) context, so spans started under it simply root a
|
|
new trace.
|
|
"""
|
|
from opentelemetry.propagate import extract
|
|
|
|
return extract(carrier)
|
|
|
|
|
|
@contextmanager
|
|
def consume_frame_span(
|
|
name: str,
|
|
carrier: Mapping[str, str],
|
|
*,
|
|
attributes: Mapping[str, Any] | None = None,
|
|
) -> Iterator[Any]:
|
|
"""
|
|
Open a CONSUMER span parented on a received frame's trace context.
|
|
|
|
The receive-side wrapper for the JSON-frame websockets: extracts the
|
|
W3C context that :func:`inject_trace_context` wrote into ``carrier``,
|
|
then opens a span that nests under the sender's trace. Any frame
|
|
encoded while this span is active (e.g. a result frame sent back in
|
|
reply) inherits it, so request/response round trips stay linked.
|
|
|
|
:param name: Span name, e.g. ``"host.launch_runner"``.
|
|
:param carrier: The decoded inbound frame (a JSON dict) that may
|
|
hold ``traceparent`` / ``tracestate`` keys.
|
|
:param attributes: Optional span attributes to set, e.g.
|
|
``{"host.request_id": "req_1"}``.
|
|
:returns: A context manager yielding the started span.
|
|
"""
|
|
if not telemetry_enabled():
|
|
from opentelemetry.trace import INVALID_SPAN
|
|
|
|
yield INVALID_SPAN
|
|
return
|
|
from opentelemetry import trace as otel_trace
|
|
|
|
parent = extract_trace_context(carrier)
|
|
tracer = otel_trace.get_tracer("omnigent.frames")
|
|
with tracer.start_as_current_span(
|
|
name,
|
|
context=parent,
|
|
kind=otel_trace.SpanKind.CONSUMER,
|
|
) as span:
|
|
for key, value in (attributes or {}).items():
|
|
span.set_attribute(key, value)
|
|
# Record the received message body (redacted, capped) when content
|
|
# capture is on, so operators can see exactly what crossed the wire.
|
|
record_message_payload(carrier, span=span)
|
|
yield span
|
|
|
|
|
|
@contextmanager
|
|
def span(
|
|
name: str,
|
|
*,
|
|
attributes: Mapping[str, Any] | None = None,
|
|
) -> Iterator[Any]:
|
|
"""
|
|
Open a plain child span under the currently active trace context.
|
|
|
|
A thin wrapper over the OpenTelemetry tracer for instrumenting
|
|
infrastructure boundaries that are not JSON-frame transports — e.g.
|
|
a terminal-attach session or an in-process policy evaluation. The
|
|
parent is whatever context is active (a FastAPI-extracted request
|
|
span, an agent-turn span, etc.), so the new span nests correctly
|
|
without any explicit carrier.
|
|
|
|
:param name: Span name, e.g. ``"terminal.attach"`` or
|
|
``"policy.evaluate"``.
|
|
:param attributes: Optional span attributes to set at start.
|
|
:returns: A context manager yielding the started span.
|
|
"""
|
|
if not telemetry_enabled():
|
|
from opentelemetry.trace import INVALID_SPAN
|
|
|
|
yield INVALID_SPAN
|
|
return
|
|
from opentelemetry import trace as otel_trace
|
|
|
|
tracer = otel_trace.get_tracer("omnigent")
|
|
with tracer.start_as_current_span(name) as started:
|
|
for key, value in (attributes or {}).items():
|
|
started.set_attribute(key, value)
|
|
yield started
|
|
|
|
|
|
def _metrics_exporter_name() -> str:
|
|
"""
|
|
Return the configured OpenTelemetry metrics exporter name.
|
|
|
|
``OTEL_METRICS_EXPORTER`` is the standard OpenTelemetry knob. If
|
|
it is unset and an OTLP endpoint is configured, Omnigent uses
|
|
``"otlp"`` so server performance metrics are exported alongside
|
|
traces.
|
|
|
|
:returns: Exporter name, e.g. ``"otlp"`` or ``"none"``.
|
|
"""
|
|
configured = os.environ.get("OTEL_METRICS_EXPORTER")
|
|
if configured is not None:
|
|
return configured.strip().lower()
|
|
if os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip():
|
|
return "otlp"
|
|
return "none"
|
|
|
|
|
|
def _otlp_protocol() -> str:
|
|
"""
|
|
Return the configured OTLP transport protocol.
|
|
|
|
OpenTelemetry's default OTLP protocol is gRPC; Omnigent follows
|
|
that default unless ``OTEL_EXPORTER_OTLP_PROTOCOL`` explicitly
|
|
requests HTTP/protobuf.
|
|
|
|
:returns: ``"grpc"`` or ``"http/protobuf"``.
|
|
:raises ValueError: If the protocol is unsupported.
|
|
"""
|
|
protocol = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").strip().lower()
|
|
if protocol in ("", "grpc"):
|
|
return "grpc"
|
|
if protocol == "http/protobuf":
|
|
return "http/protobuf"
|
|
raise ValueError(f"Unsupported OTLP protocol for metrics export: {protocol!r}")
|
|
|
|
|
|
def _create_otlp_span_exporter() -> Any:
|
|
"""
|
|
Create an OTLP span exporter using standard OTel environment vars.
|
|
|
|
:returns: OTLP span exporter configured from the process environment.
|
|
:raises ValueError: If ``OTEL_EXPORTER_OTLP_PROTOCOL`` is not supported.
|
|
"""
|
|
protocol = _otlp_protocol()
|
|
if protocol == "http/protobuf":
|
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
|
|
return OTLPSpanExporter()
|
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
|
|
return OTLPSpanExporter()
|
|
|
|
|
|
def _create_otlp_metric_exporter() -> MetricExporter:
|
|
"""
|
|
Create an OTLP metric exporter using standard OTel environment vars.
|
|
|
|
:returns: OTLP metric exporter configured from the process
|
|
environment.
|
|
:raises ValueError: If ``OTEL_EXPORTER_OTLP_PROTOCOL`` is not
|
|
supported.
|
|
"""
|
|
protocol = _otlp_protocol()
|
|
if protocol == "http/protobuf":
|
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
|
OTLPMetricExporter,
|
|
)
|
|
|
|
return OTLPMetricExporter()
|
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
|
OTLPMetricExporter,
|
|
)
|
|
|
|
return OTLPMetricExporter()
|
|
|
|
|
|
def _init_otel_traces(endpoint: str) -> None:
|
|
"""
|
|
Initialize the OpenTelemetry SDK tracer provider.
|
|
|
|
When ``endpoint`` is set, installs a ``TracerProvider`` backed by
|
|
an OTLP ``BatchSpanProcessor``. When absent, tracing is still
|
|
enabled so operators who install their own provider externally get
|
|
spans; the default no-op provider discards them silently.
|
|
|
|
:param endpoint: ``OTEL_EXPORTER_OTLP_ENDPOINT`` value (may be empty).
|
|
"""
|
|
try:
|
|
if endpoint:
|
|
from opentelemetry import trace
|
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
from opentelemetry.sdk.trace import TracerProvider
|
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
|
|
service_name = os.environ.get("OTEL_SERVICE_NAME", "omnigent")
|
|
provider = TracerProvider(resource=Resource.create({SERVICE_NAME: service_name}))
|
|
# Enrich every span with session.id from the active context (set via
|
|
# session_scope at the request hook / executor turn / forwarder).
|
|
provider.add_span_processor(_make_session_id_processor())
|
|
provider.add_span_processor(BatchSpanProcessor(_create_otlp_span_exporter()))
|
|
trace.set_tracer_provider(provider)
|
|
|
|
from omnigent.inner.tracing import enable_tracing
|
|
|
|
enable_tracing()
|
|
except Exception:
|
|
_logger.exception("failed to initialize OpenTelemetry tracing")
|
|
|
|
|
|
def _init_otel_metrics() -> None:
|
|
"""
|
|
Initialize the OpenTelemetry SDK meter provider when configured.
|
|
|
|
Metrics remain no-op unless the operator configures an OTLP
|
|
endpoint or sets ``OTEL_METRICS_EXPORTER=otlp``. Setting
|
|
``OTEL_METRICS_EXPORTER=none`` explicitly disables metrics.
|
|
"""
|
|
global _metrics_initialized
|
|
|
|
if _metrics_initialized:
|
|
return
|
|
|
|
exporter_name = _metrics_exporter_name()
|
|
if exporter_name == "none":
|
|
_metrics_initialized = True
|
|
return
|
|
if exporter_name != "otlp":
|
|
_logger.warning(
|
|
"unsupported OTEL_METRICS_EXPORTER=%s; server metrics export disabled",
|
|
exporter_name,
|
|
)
|
|
_metrics_initialized = True
|
|
return
|
|
|
|
try:
|
|
from opentelemetry import metrics as otel_metrics
|
|
from opentelemetry.sdk.metrics import MeterProvider
|
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
|
|
exporter = _create_otlp_metric_exporter()
|
|
reader = PeriodicExportingMetricReader(exporter)
|
|
service_name = os.environ.get("OTEL_SERVICE_NAME", "omnigent")
|
|
provider = MeterProvider(
|
|
metric_readers=[reader],
|
|
resource=Resource.create({SERVICE_NAME: service_name}),
|
|
)
|
|
otel_metrics.set_meter_provider(provider)
|
|
_metrics_initialized = True
|
|
except Exception:
|
|
_logger.exception("failed to initialize OpenTelemetry metrics")
|
|
_metrics_initialized = True
|
|
|
|
|
|
def _logs_exporter_name() -> str:
|
|
"""
|
|
Return the configured OpenTelemetry logs exporter name.
|
|
|
|
``OTEL_LOGS_EXPORTER`` is the standard OpenTelemetry knob. If
|
|
it is unset and an OTLP endpoint is configured, Omnigent uses
|
|
``"otlp"`` so log records flow alongside traces and metrics.
|
|
|
|
:returns: Exporter name, e.g. ``"otlp"`` or ``"none"``.
|
|
"""
|
|
configured = os.environ.get("OTEL_LOGS_EXPORTER")
|
|
if configured is not None:
|
|
return configured.strip().lower()
|
|
if os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip():
|
|
return "otlp"
|
|
return "none"
|
|
|
|
|
|
def _create_otlp_log_exporter() -> LogExporter:
|
|
"""
|
|
Create an OTLP log exporter using standard OTel environment vars.
|
|
|
|
:returns: OTLP log exporter configured from the process
|
|
environment.
|
|
:raises ValueError: If ``OTEL_EXPORTER_OTLP_PROTOCOL`` is not
|
|
supported.
|
|
"""
|
|
protocol = _otlp_protocol()
|
|
if protocol == "http/protobuf":
|
|
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
|
|
OTLPLogExporter,
|
|
)
|
|
|
|
return OTLPLogExporter()
|
|
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
|
|
OTLPLogExporter,
|
|
)
|
|
|
|
return OTLPLogExporter()
|
|
|
|
|
|
def _init_otel_logs() -> None:
|
|
"""
|
|
Initialize the OpenTelemetry LoggerProvider when configured.
|
|
|
|
Bridges Python ``logging`` to OTel so logs emitted inside an
|
|
active span carry ``trace_id`` and ``span_id`` automatically.
|
|
No-op when no OTLP endpoint is configured or
|
|
``OTEL_LOGS_EXPORTER=none`` is set.
|
|
|
|
Mirrors :func:`_init_otel_metrics`: a ``LoggerProvider`` is
|
|
registered globally, an OTLP log exporter is attached via a
|
|
``BatchLogRecordProcessor``, and a ``LoggingHandler`` is
|
|
installed on the root logger so any ``logging.getLogger`` call
|
|
in the runtime flows through the bridge.
|
|
"""
|
|
global _logs_initialized
|
|
|
|
if _logs_initialized:
|
|
return
|
|
|
|
exporter_name = _logs_exporter_name()
|
|
if exporter_name == "none":
|
|
_logs_initialized = True
|
|
return
|
|
if exporter_name != "otlp":
|
|
_logger.warning(
|
|
"unsupported OTEL_LOGS_EXPORTER=%s; log bridge disabled",
|
|
exporter_name,
|
|
)
|
|
_logs_initialized = True
|
|
return
|
|
|
|
try:
|
|
from opentelemetry._logs import set_logger_provider
|
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
|
|
service_name = os.environ.get("OTEL_SERVICE_NAME", "omnigent")
|
|
provider = LoggerProvider(
|
|
resource=Resource.create({SERVICE_NAME: service_name}),
|
|
)
|
|
exporter = _create_otlp_log_exporter()
|
|
provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
|
|
set_logger_provider(provider)
|
|
|
|
handler = LoggingHandler(logger_provider=provider)
|
|
root_logger = logging.getLogger()
|
|
# Mark the handler so re-init does not stack duplicates on
|
|
# the root logger when init() runs again after a flag reset.
|
|
handler.set_name("omnigent-otel-log-bridge")
|
|
for existing in root_logger.handlers:
|
|
if existing.get_name() == "omnigent-otel-log-bridge":
|
|
root_logger.removeHandler(existing)
|
|
root_logger.addHandler(handler)
|
|
_logs_initialized = True
|
|
except Exception:
|
|
_logger.exception("failed to initialize OpenTelemetry logs")
|
|
_logs_initialized = True
|
|
|
|
|
|
def init(service_name: str | None = None) -> None:
|
|
"""
|
|
Initialize OpenTelemetry tracing for the omnigent runtime.
|
|
|
|
Gated by the ``OMNIGENT_TELEMETRY_ENABLED`` master opt-in (off by
|
|
default): when it is unset/false this is a no-op — no provider is
|
|
installed, no instrumentation is wired, and no spans are created, so
|
|
a default install incurs zero telemetry cost. Set it truthy to opt
|
|
in; ``OTEL_EXPORTER_OTLP_ENDPOINT`` then selects the export target.
|
|
|
|
Safe to call multiple times; the second and subsequent calls
|
|
refresh the content-capture flag but do not re-register providers.
|
|
|
|
:param service_name: The OpenTelemetry ``service.name`` for this
|
|
process, e.g. ``"omni-server"`` / ``"omni-runner"`` /
|
|
``"omni-harness"`` / ``"omni-host"``. Each entrypoint names
|
|
itself so the trace backend can attribute spans to a component;
|
|
a child process overrides the parent's inherited name with its
|
|
own. When ``None``, an operator-set ``OTEL_SERVICE_NAME`` is
|
|
honored, otherwise it defaults to ``"omnigent"``. Deployment-
|
|
level identity (environment, region) belongs in
|
|
``OTEL_RESOURCE_ATTRIBUTES``.
|
|
|
|
Two modes based on the environment:
|
|
|
|
* **OTLP export to an external collector.** When
|
|
``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, installs a
|
|
``TracerProvider`` backed by an OTLP ``BatchSpanProcessor``
|
|
(Jaeger, Tempo, Grafana, etc.).
|
|
|
|
* **No-op / external provider.** When the endpoint is absent,
|
|
tracing is still enabled so operators who configure their own
|
|
``TracerProvider`` externally get spans automatically. The
|
|
default OTel no-op provider discards spans silently.
|
|
"""
|
|
global _capture_content, _initialized
|
|
|
|
if not telemetry_enabled():
|
|
# Master opt-in off (the default): stay fully inert — no provider, no
|
|
# OTEL_SERVICE_NAME mutation, no httpx/metrics/logs instrumentation, no
|
|
# spans. Only operators who set OMNIGENT_TELEMETRY_ENABLED pay any cost.
|
|
return
|
|
|
|
_capture_content = _env_bool("OMNIGENT_OTEL_CAPTURE_CONTENT")
|
|
|
|
if _initialized:
|
|
return
|
|
|
|
# Per-component service identity. The trace / metrics / logs
|
|
# providers below all read OTEL_SERVICE_NAME when they build their
|
|
# Resource, so this must run before any provider is created. A
|
|
# passed name wins over an inherited one so each process (server /
|
|
# runner / harness / host) is attributable in the trace backend
|
|
# instead of collapsing to one anonymous service.
|
|
os.environ["OTEL_SERVICE_NAME"] = (
|
|
service_name or os.environ.get("OTEL_SERVICE_NAME") or "omnigent"
|
|
)
|
|
|
|
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
|
_init_otel_traces(endpoint)
|
|
_init_otel_metrics()
|
|
_init_otel_logs()
|
|
_instrument_httpx()
|
|
|
|
# NOTE: FastAPI server instrumentation is installed by the app
|
|
# factories (server / runner / harness) via ``instrument_fastapi_app``.
|
|
# It defaults on when a tracing backend is configured and can be
|
|
# forced on/off with ``OMNIGENT_OTEL_FASTAPI_INSTRUMENTATION``.
|
|
|
|
_initialized = True
|
|
_logger.info(
|
|
"omnigent telemetry initialized (endpoint=%s, capture_content=%s)",
|
|
endpoint or "<none>",
|
|
_capture_content,
|
|
)
|