agentops-otel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from agentops_otel.baggage import BaggageSpanProcessor, is_agentops_baggage_key
4
+ from agentops_otel.logging import (
5
+ ConsoleFormatter,
6
+ CPForwardingHandler,
7
+ JsonFormatter,
8
+ TraceCorrelationFilter,
9
+ configure_logging,
10
+ get_logger,
11
+ is_local,
12
+ resolve_env,
13
+ resolve_log_level,
14
+ )
15
+ from agentops_otel.telemetry import (
16
+ METRIC_PREFIX,
17
+ TelemetrySettings,
18
+ configure_telemetry,
19
+ metric_name,
20
+ otlp_exporter_classes,
21
+ prometheus_exposition,
22
+ prometheus_metrics_enabled,
23
+ span_baggage_enabled,
24
+ )
25
+
26
+ __all__ = [
27
+ "METRIC_PREFIX",
28
+ "BaggageSpanProcessor",
29
+ "CPForwardingHandler",
30
+ "ConsoleFormatter",
31
+ "JsonFormatter",
32
+ "TelemetrySettings",
33
+ "TraceCorrelationFilter",
34
+ "configure_logging",
35
+ "configure_telemetry",
36
+ "get_logger",
37
+ "is_agentops_baggage_key",
38
+ "is_local",
39
+ "metric_name",
40
+ "otlp_exporter_classes",
41
+ "prometheus_exposition",
42
+ "prometheus_metrics_enabled",
43
+ "resolve_env",
44
+ "resolve_log_level",
45
+ "span_baggage_enabled",
46
+ ]
@@ -0,0 +1,52 @@
1
+ """Copy OpenTelemetry baggage onto spans, so tenant/identity context set upstream reaches every span.
2
+
3
+ Span attributes are not inherited by child spans, and identity is often known only after a span
4
+ starts. Baggage solves both: it lives in the context and propagates via the W3C ``baggage`` header, so
5
+ values set once (e.g. by the control plane's auth middleware) reach child spans **and** spans in
6
+ downstream services in the same trace. :class:`BaggageSpanProcessor` promotes those baggage entries to
7
+ span attributes at span start. :func:`configure_telemetry` registers it for every AgentOps service
8
+ (control plane + workers), so a worker span is attributable to the account the control plane set.
9
+
10
+ A tiny in-house equivalent of the OTel-contrib ``BaggageSpanProcessor`` (avoids a new dependency),
11
+ scoped to the AgentOps ``agentops.*`` namespace so unrelated baggage is never promoted to a span tag.
12
+ Only non-PII stable ids belong in baggage — it rides outbound headers to every callee, including
13
+ external ones — so callers must never put email/name there.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+
20
+ from opentelemetry.baggage import get_all as get_all_baggage
21
+ from opentelemetry.context import Context
22
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ AGENTOPS_BAGGAGE_PREFIX = "agentops."
27
+
28
+
29
+ def is_agentops_baggage_key(key: str) -> bool:
30
+ """Whether a baggage key is one AgentOps sets (and may be promoted to a span attribute)."""
31
+ return key.startswith(AGENTOPS_BAGGAGE_PREFIX)
32
+
33
+
34
+ class BaggageSpanProcessor(SpanProcessor):
35
+ """Copy ``agentops.*`` baggage entries onto every span at start (see module docstring)."""
36
+
37
+ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
38
+ try:
39
+ for key, value in get_all_baggage(parent_context).items():
40
+ if is_agentops_baggage_key(key):
41
+ span.set_attribute(key, str(value))
42
+ except Exception: # noqa: BLE001 — telemetry must never break the request path
43
+ logger.debug("otel: BaggageSpanProcessor.on_start failed", exc_info=True)
44
+
45
+ def on_end(self, span: ReadableSpan) -> None:
46
+ return None
47
+
48
+ def shutdown(self) -> None:
49
+ return None
50
+
51
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
52
+ return True
@@ -0,0 +1,323 @@
1
+ """Shared application logging for AgentOps.
2
+
3
+ One entry point, :func:`configure_logging`, installs a single stdout handler on
4
+ the root logger and picks its shape from the environment:
5
+
6
+ * **Local dev** (``AGENTOPS_ENV``/``AGENTOPS_DD_ENV``/``ENV`` unset or ``local``):
7
+ a human-readable, coloured console line — ``time LEVEL name file:line message``.
8
+ * **Everything else** (``staging``, ``production``, …): one-line JSON per event
9
+ with the explicit ``level``/``status`` and ``dd.*`` fields Datadog reads, so a
10
+ Datadog agent tailing container stdout ingests correct severity and correlates
11
+ logs to traces.
12
+
13
+ Trace correlation is *generic OpenTelemetry*: we read the current span through
14
+ the OTel API only. When no tracer/exporter is configured (the common case —
15
+ ``configure_telemetry`` is opt-in) ``get_current_span`` returns an invalid span
16
+ and we add nothing, so there is no dependency on a running collector. When a
17
+ span *is* active, we emit both ``otelTraceID``/``otelSpanID`` and the
18
+ ``dd.trace_id``/``dd.span_id`` (lower-64-bit) form Datadog uses.
19
+
20
+ Every module gets its logger with :func:`get_logger` (a thin
21
+ ``logging.getLogger`` wrapper); logs propagate to the single root handler.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import os
29
+ import sys
30
+ import traceback
31
+ from collections.abc import Callable, Mapping
32
+ from datetime import UTC, datetime
33
+
34
+ try: # OTel API is a dependency of this package; guard only for extreme trimmed installs.
35
+ from opentelemetry import trace
36
+ except ImportError: # pragma: no cover - opentelemetry-api is always present in practice
37
+ trace = None # type: ignore[assignment]
38
+
39
+ __all__ = [
40
+ "CPForwardingHandler",
41
+ "ConsoleFormatter",
42
+ "JsonFormatter",
43
+ "TraceCorrelationFilter",
44
+ "configure_logging",
45
+ "get_logger",
46
+ "is_local",
47
+ "resolve_env",
48
+ "resolve_log_level",
49
+ ]
50
+
51
+ DEFAULT_LOG_LEVEL = "INFO"
52
+ _LOCAL_ENV = "local"
53
+ # Environments that should get the readable console formatter rather than JSON.
54
+ _LOCAL_ENVS = frozenset({"", _LOCAL_ENV, "dev", "development", "test"})
55
+
56
+ # Env vars checked, in order, to decide which environment we run in. The first
57
+ # non-empty wins. ``AGENTOPS_DD_ENV`` is set to ``staging``/``production`` on our
58
+ # deployments (Helm plain env), which makes deployed pods emit JSON automatically.
59
+ _ENV_KEYS = ("AGENTOPS_ENV", "AGENTOPS_DD_ENV", "ENV")
60
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
61
+
62
+ # Attributes present on a bare LogRecord — anything else in ``record.__dict__`` is
63
+ # caller-supplied structured context (``extra={...}``) worth rendering.
64
+ _RESERVED_LOG_ATTRS = frozenset(logging.makeLogRecord({}).__dict__)
65
+ # Correlation/derived keys we render in their own slot, never as generic extras.
66
+ _CORRELATION_KEYS = frozenset({"otelTraceID", "otelSpanID", "dd.trace_id", "dd.span_id"})
67
+ _CONSOLE_SKIP = _RESERVED_LOG_ATTRS | _CORRELATION_KEYS | {"message", "dd.service", "dd.env", "dd.version"}
68
+
69
+ _ROOT_CONFIGURED_FLAG = "_agentops_logging_configured"
70
+
71
+
72
+ def resolve_env(env: Mapping[str, str] | None = None) -> str:
73
+ """Return the lower-cased environment name (``local`` when nothing is set)."""
74
+ env = env if env is not None else os.environ
75
+ for key in _ENV_KEYS:
76
+ value = (env.get(key) or "").strip().lower()
77
+ if value:
78
+ return value
79
+ return _LOCAL_ENV
80
+
81
+
82
+ def is_local(env: Mapping[str, str] | None = None) -> bool:
83
+ """True when the environment name is local/dev/test (ignores telemetry signals)."""
84
+ return resolve_env(env) in _LOCAL_ENVS
85
+
86
+
87
+ def _prefer_json(env: Mapping[str, str]) -> bool:
88
+ """Default to JSON when deployed.
89
+
90
+ ``is_local`` alone is not enough: our worker pods set an OTLP endpoint but no
91
+ environment name, so we also treat a configured OTLP endpoint (or
92
+ ``AGENTOPS_OTEL_ENABLED``) as a deployed signal.
93
+ """
94
+ if not is_local(env):
95
+ return True
96
+ if (env.get("OTEL_EXPORTER_OTLP_ENDPOINT") or "").strip():
97
+ return True
98
+ return (env.get("AGENTOPS_OTEL_ENABLED") or "").strip().lower() in _TRUTHY
99
+
100
+
101
+ def resolve_log_level(name: str) -> int:
102
+ """Map a level name (e.g. ``"INFO"``) to its int, falling back to INFO.
103
+
104
+ Intentionally shared: ``telemetry.py`` reuses it for the OTLP log handler.
105
+ """
106
+ level = logging.getLevelName(name)
107
+ return level if isinstance(level, int) else logging.INFO
108
+
109
+
110
+ def _coerce_level(level: int | str) -> int:
111
+ return level if isinstance(level, int) else resolve_log_level(level.strip().upper())
112
+
113
+
114
+ def _effective_level(level: int | str | None, env: Mapping[str, str]) -> int:
115
+ """Resolve the root level: explicit arg > ``AGENTOPS_LOG_LEVEL`` > debug flag > INFO."""
116
+ if level is not None:
117
+ return _coerce_level(level)
118
+ name = (env.get("AGENTOPS_LOG_LEVEL") or "").strip()
119
+ if name:
120
+ return resolve_log_level(name.upper())
121
+ if (env.get("AGENTOPS_DEBUG") or "").strip().lower() in _TRUTHY:
122
+ return logging.DEBUG
123
+ return resolve_log_level(DEFAULT_LOG_LEVEL)
124
+
125
+
126
+ def _datadog_tags(env: Mapping[str, str]) -> dict[str, str]:
127
+ """Datadog unified-service tags to stamp on every JSON log, when configured."""
128
+ tags: dict[str, str] = {}
129
+ service = env.get("OTEL_SERVICE_NAME") or env.get("AGENTOPS_SERVICE_NAME")
130
+ version = env.get("OTEL_SERVICE_VERSION") or env.get("AGENTOPS_VERSION")
131
+ dd_env = env.get("AGENTOPS_DD_ENV") or env.get("AGENTOPS_ENV")
132
+ if service:
133
+ tags["dd.service"] = service
134
+ if version:
135
+ tags["dd.version"] = version
136
+ if dd_env:
137
+ tags["dd.env"] = dd_env
138
+ return tags
139
+
140
+
141
+ def _extra_fields(record: logging.LogRecord, skip: frozenset[str]) -> dict[str, object]:
142
+ return {k: v for k, v in record.__dict__.items() if k not in skip}
143
+
144
+
145
+ class TraceCorrelationFilter(logging.Filter):
146
+ """Stamp OTel/Datadog trace-correlation ids onto records from the active span.
147
+
148
+ Uses only the OpenTelemetry *API*: when no SDK/exporter is active the current
149
+ span is invalid and we add no correlation ids — so this is safe to install
150
+ unconditionally and needs no running collector.
151
+ """
152
+
153
+ def filter(self, record: logging.LogRecord) -> bool:
154
+ if trace is None:
155
+ return True
156
+ # get_current_span() always returns a Span (INVALID_SPAN when none is active).
157
+ ctx = trace.get_current_span().get_span_context()
158
+ if ctx.is_valid:
159
+ record.otelTraceID = format(ctx.trace_id, "032x")
160
+ record.otelSpanID = format(ctx.span_id, "016x")
161
+ # Datadog reads the lower 64 bits of the trace id as a decimal string.
162
+ record.__dict__["dd.trace_id"] = str(ctx.trace_id & 0xFFFFFFFFFFFFFFFF)
163
+ record.__dict__["dd.span_id"] = str(ctx.span_id)
164
+ return True
165
+
166
+
167
+ class JsonFormatter(logging.Formatter):
168
+ """One-line JSON per event with explicit level/status so Datadog reads severity.
169
+
170
+ ``static_tags`` (Datadog unified-service ``dd.service``/``dd.env``/``dd.version``)
171
+ are stamped on every event; per-record trace ids come from
172
+ :class:`TraceCorrelationFilter`.
173
+ """
174
+
175
+ def __init__(self, static_tags: Mapping[str, str] | None = None) -> None:
176
+ super().__init__()
177
+ self._static_tags = dict(static_tags or {})
178
+
179
+ def format(self, record: logging.LogRecord) -> str:
180
+ logger_info: dict[str, str] = {"name": record.name}
181
+ if record.threadName:
182
+ logger_info["thread_name"] = record.threadName
183
+ payload: dict[str, object] = {
184
+ "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
185
+ "level": record.levelname,
186
+ "status": record.levelname,
187
+ "message": record.getMessage(),
188
+ "logger": logger_info,
189
+ }
190
+ if record.exc_info:
191
+ exc_type, exc_value, _ = record.exc_info
192
+ payload["error"] = {
193
+ "kind": exc_type.__name__ if exc_type is not None else None,
194
+ "message": str(exc_value) if exc_value is not None else None,
195
+ "stack": self.formatException(record.exc_info),
196
+ }
197
+ elif record.exc_text:
198
+ payload["error"] = {"stack": record.exc_text}
199
+ if record.stack_info:
200
+ logger_info["stack_info"] = self.formatStack(record.stack_info)
201
+ for attr in ("otelTraceID", "otelSpanID"):
202
+ value = getattr(record, attr, None)
203
+ if value and value != "0":
204
+ payload[attr] = value
205
+ for key, value in record.__dict__.items():
206
+ if key not in _RESERVED_LOG_ATTRS and key not in payload:
207
+ payload[key] = value
208
+ for key, value in self._static_tags.items():
209
+ payload.setdefault(key, value)
210
+ return json.dumps(payload, default=str)
211
+
212
+
213
+ _LEVEL_COLORS = {
214
+ logging.DEBUG: "\x1b[36m", # cyan
215
+ logging.INFO: "\x1b[32m", # green
216
+ logging.WARNING: "\x1b[33m", # yellow
217
+ logging.ERROR: "\x1b[31m", # red
218
+ logging.CRITICAL: "\x1b[1;41m", # bold on red
219
+ }
220
+ _DIM = "\x1b[2m"
221
+ _RESET = "\x1b[0m"
222
+
223
+
224
+ class ConsoleFormatter(logging.Formatter):
225
+ """Readable single-line dev output: ``time LEVEL name file:line message k=v``."""
226
+
227
+ def __init__(self, *, use_color: bool) -> None:
228
+ super().__init__(datefmt="%H:%M:%S")
229
+ self._use_color = use_color
230
+
231
+ def _paint(self, text: str, color: str) -> str:
232
+ return f"{color}{text}{_RESET}" if self._use_color else text
233
+
234
+ def format(self, record: logging.LogRecord) -> str:
235
+ ts = f"{self.formatTime(record, self.datefmt)}.{int(record.msecs):03d}"
236
+ level = self._paint(f"{record.levelname:<8}", _LEVEL_COLORS.get(record.levelno, ""))
237
+ location = self._paint(f"{record.name} {os.path.basename(record.pathname)}:{record.lineno}", _DIM)
238
+ parts = [self._paint(ts, _DIM), level, location, record.getMessage()]
239
+ extras = _extra_fields(record, _CONSOLE_SKIP)
240
+ if extras:
241
+ parts.append(self._paint(" ".join(f"{k}={v}" for k, v in extras.items()), _DIM))
242
+ line = " ".join(parts)
243
+ if record.exc_info:
244
+ line += "\n" + self.formatException(record.exc_info)
245
+ if record.stack_info:
246
+ line += "\n" + self.formatStack(record.stack_info)
247
+ return line
248
+
249
+
250
+ class CPForwardingHandler(logging.Handler):
251
+ # sink is injected so this stays free of any control-plane client dependency.
252
+ def __init__(self, sink: Callable[[str, str, str], None]) -> None:
253
+ super().__init__()
254
+ self._sink = sink
255
+
256
+ def emit(self, record: logging.LogRecord) -> None:
257
+ try:
258
+ message = record.getMessage()
259
+ if record.exc_info:
260
+ message = f"{message}\n{''.join(traceback.format_exception(*record.exc_info))}"
261
+ self._sink(message, record.levelname, record.name)
262
+ except Exception:
263
+ self.handleError(record)
264
+
265
+
266
+ def configure_logging(
267
+ level: int | str | None = None,
268
+ *,
269
+ json_logs: bool | None = None,
270
+ env: Mapping[str, str] | None = None,
271
+ force: bool = False,
272
+ ) -> None:
273
+ """Install the single root stdout handler for this process. Idempotent.
274
+
275
+ Args:
276
+ level: Explicit root level. Defaults to ``AGENTOPS_LOG_LEVEL``, then
277
+ ``AGENTOPS_DEBUG`` (DEBUG when truthy), then INFO.
278
+ json_logs: Force JSON (``True``) or console (``False``). Defaults to JSON
279
+ in any non-local environment (or when an OTLP endpoint is configured)
280
+ and console locally.
281
+ env: Environment mapping to read (defaults to ``os.environ``); handy for
282
+ tests.
283
+ force: Reconfigure even if a previous call already set up logging.
284
+ """
285
+ root = logging.getLogger()
286
+ if getattr(root, _ROOT_CONFIGURED_FLAG, False) and not force:
287
+ return
288
+
289
+ env_map = env if env is not None else os.environ
290
+ use_json = _prefer_json(env_map) if json_logs is None else json_logs
291
+ resolved_level = _effective_level(level, env_map)
292
+
293
+ handler = logging.StreamHandler(sys.stdout)
294
+ handler.setLevel(logging.DEBUG)
295
+ if use_json:
296
+ handler.setFormatter(JsonFormatter(_datadog_tags(env_map)))
297
+ else:
298
+ handler.setFormatter(ConsoleFormatter(use_color=_should_color(env_map)))
299
+ handler.addFilter(TraceCorrelationFilter())
300
+
301
+ for existing in list(root.handlers):
302
+ root.removeHandler(existing)
303
+ root.addHandler(handler)
304
+ root.setLevel(resolved_level)
305
+ logging.captureWarnings(True)
306
+ setattr(root, _ROOT_CONFIGURED_FLAG, True)
307
+
308
+
309
+ def _should_color(env: Mapping[str, str]) -> bool:
310
+ if (env.get("NO_COLOR") or "").strip():
311
+ return False
312
+ if (env.get("FORCE_COLOR") or "").strip():
313
+ return True
314
+ return sys.stdout.isatty()
315
+
316
+
317
+ def get_logger(name: str | None = None) -> logging.Logger:
318
+ """Return a standard logger for ``name`` (use ``get_logger(__name__)``).
319
+
320
+ Records propagate to the single root handler installed by
321
+ :func:`configure_logging`, so no per-module handler wiring is needed.
322
+ """
323
+ return logging.getLogger(name)
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import warnings
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from opentelemetry import metrics, trace
11
+ from opentelemetry._logs import set_logger_provider
12
+ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
13
+ OTLPLogExporter as GrpcLogExporter,
14
+ )
15
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
16
+ OTLPMetricExporter as GrpcMetricExporter,
17
+ )
18
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
19
+ OTLPSpanExporter as GrpcSpanExporter,
20
+ )
21
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import (
22
+ OTLPLogExporter as HttpLogExporter,
23
+ )
24
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
25
+ OTLPMetricExporter as HttpMetricExporter,
26
+ )
27
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
28
+ OTLPSpanExporter as HttpSpanExporter,
29
+ )
30
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
31
+ from opentelemetry.instrumentation.logging import LoggingInstrumentor
32
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
33
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
34
+ from opentelemetry.sdk.metrics import MeterProvider
35
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
36
+ from opentelemetry.sdk.resources import Resource
37
+ from opentelemetry.sdk.trace import TracerProvider
38
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
39
+
40
+ try:
41
+ from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
42
+ except ImportError: # pragma: no cover - only reachable when the [sqlalchemy] extra is not installed
43
+ SQLAlchemyInstrumentor = None # type: ignore[assignment, misc]
44
+
45
+ try:
46
+ from opentelemetry.exporter.prometheus import PrometheusMetricReader
47
+ from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest
48
+ except ImportError: # pragma: no cover - only reachable when the [prometheus] extra is not installed
49
+ PrometheusMetricReader = None # type: ignore[assignment, misc]
50
+
51
+ from agentops_otel.baggage import BaggageSpanProcessor
52
+ from agentops_otel.logging import DEFAULT_LOG_LEVEL, resolve_log_level
53
+
54
+ if TYPE_CHECKING:
55
+ from fastapi import FastAPI
56
+ from sqlalchemy.ext.asyncio import AsyncEngine
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Every AgentOps metric lives under this single namespace root so the whole product's metrics are
61
+ # isolated and trivially sliceable in Datadog (``agentops.*``). Names go through :func:`metric_name`
62
+ # at instrument-creation time; there is no SDK-level global rewrite (OTel Python has no dynamic
63
+ # per-instrument prefixing View), so this helper is the shared convention all metric sites use.
64
+ METRIC_PREFIX = "agentops."
65
+
66
+
67
+ def metric_name(name: str) -> str:
68
+ """Return ``name`` under the canonical ``agentops.`` metric namespace.
69
+
70
+ Idempotent: a name that already starts with :data:`METRIC_PREFIX` is returned unchanged, so
71
+ callers may pass either the bare suffix (``"authz.pdp.duration_ms"``) or the full name safely.
72
+ """
73
+ return name if name.startswith(METRIC_PREFIX) else f"{METRIC_PREFIX}{name}"
74
+
75
+
76
+ DEFAULT_PROTOCOL = "http/protobuf"
77
+
78
+ _configured = False
79
+ _prometheus_enabled = False
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class TelemetrySettings:
84
+ """Resolved OpenTelemetry configuration for AgentOps components.
85
+
86
+ Activation is opt-in: telemetry is enabled only when an OTLP endpoint is
87
+ configured or AGENTOPS_OTEL_ENABLED is truthy. All transport details
88
+ (endpoint, headers, sampling) are read directly by the OTel exporters from
89
+ standard OTEL_* env vars; this object only captures what our code branches on.
90
+ """
91
+
92
+ enabled: bool
93
+ protocol: str
94
+ service_name: str
95
+ log_level: str
96
+ logs_enabled: bool
97
+ baggage_enabled: bool
98
+
99
+ @classmethod
100
+ def from_env(
101
+ cls,
102
+ env: Mapping[str, str] | None = None,
103
+ *,
104
+ default_service_name: str,
105
+ ) -> TelemetrySettings:
106
+ """Build a TelemetrySettings from environment variables.
107
+
108
+ Args:
109
+ env: Optional mapping to read from (defaults to os.environ).
110
+ default_service_name: Fallback service name when OTEL_SERVICE_NAME
111
+ is not set. Required and keyword-only — each caller supplies its
112
+ own service name so no component silently inherits an empty one.
113
+ """
114
+ env = env if env is not None else os.environ
115
+ endpoint = env.get("OTEL_EXPORTER_OTLP_ENDPOINT") or ""
116
+ flag = (env.get("AGENTOPS_OTEL_ENABLED") or "").strip().lower()
117
+ enabled = bool(endpoint) or flag in {"1", "true", "yes", "on"}
118
+ protocol = (env.get("OTEL_EXPORTER_OTLP_PROTOCOL") or DEFAULT_PROTOCOL).strip()
119
+ service_name = env.get("OTEL_SERVICE_NAME") or default_service_name
120
+ log_level = (env.get("AGENTOPS_OTEL_LOG_LEVEL") or DEFAULT_LOG_LEVEL).strip().upper()
121
+ # OTLP log export is opt-out; disable where the collector rejects /v1/logs.
122
+ logs_enabled = (env.get("AGENTOPS_OTEL_LOGS_ENABLED") or "true").strip().lower() in {"1", "true", "yes", "on"}
123
+ return cls(
124
+ enabled=enabled,
125
+ protocol=protocol,
126
+ service_name=service_name,
127
+ log_level=log_level,
128
+ logs_enabled=logs_enabled,
129
+ baggage_enabled=span_baggage_enabled(env),
130
+ )
131
+
132
+
133
+ def otlp_exporter_classes(protocol: str) -> tuple[type, type, type]:
134
+ """Return (span_exporter, metric_exporter, log_exporter) classes for a protocol.
135
+
136
+ Each exporter reads its endpoint, headers, and timeout from standard OTEL_*
137
+ env vars at instantiation, so callers construct them with no arguments.
138
+ """
139
+ if protocol == "grpc":
140
+ return GrpcSpanExporter, GrpcMetricExporter, GrpcLogExporter
141
+
142
+ return HttpSpanExporter, HttpMetricExporter, HttpLogExporter
143
+
144
+
145
+ def span_baggage_enabled(env: Mapping[str, str] | None = None) -> bool:
146
+ """Whether baggage→span copying is enabled (opt-out env flag, default on).
147
+
148
+ Read both here (to register :class:`BaggageSpanProcessor`) and by callers that *set* baggage —
149
+ e.g. the control plane's auth middleware — so the set and the processor share one switch.
150
+ """
151
+ env = env if env is not None else os.environ
152
+ return (env.get("AGENTOPS_OTEL_SPAN_BAGGAGE_ENABLED") or "true").strip().lower() in {"1", "true", "yes", "on"}
153
+
154
+
155
+ def configure_telemetry(
156
+ service_name: str,
157
+ *,
158
+ app: FastAPI | None = None,
159
+ engine: AsyncEngine | None = None,
160
+ resource_attributes: Mapping[str, str] | None = None,
161
+ ) -> bool:
162
+ """Initialize OpenTelemetry for an AgentOps component.
163
+
164
+ Returns True if telemetry was activated, False if disabled or already
165
+ configured. ``resource_attributes`` are merged onto every span, metric,
166
+ and log record.
167
+ """
168
+ global _configured, _prometheus_enabled
169
+ if _configured:
170
+ return False
171
+
172
+ settings = TelemetrySettings.from_env(default_service_name=service_name)
173
+ prometheus_on = _prometheus_flag_set()
174
+ if not settings.enabled and not prometheus_on:
175
+ return False
176
+
177
+ attrs: dict[str, str] = {"service.name": settings.service_name}
178
+ if resource_attributes:
179
+ attrs.update(resource_attributes)
180
+ resource = Resource.create(attrs)
181
+
182
+ metric_readers: list[Any] = []
183
+ if settings.enabled:
184
+ span_exporter_cls, metric_exporter_cls, log_exporter_cls = otlp_exporter_classes(settings.protocol)
185
+ tracer_provider = TracerProvider(resource=resource)
186
+ tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter_cls()))
187
+ if settings.baggage_enabled:
188
+ # Promote agentops.* baggage (tenant/identity ids set upstream) onto every span, incl.
189
+ # spans in this service that continue a trace started elsewhere. Set-side lives in the
190
+ # service that has the identity (e.g. controlplane auth middleware).
191
+ tracer_provider.add_span_processor(BaggageSpanProcessor())
192
+ trace.set_tracer_provider(tracer_provider)
193
+ metric_readers.append(PeriodicExportingMetricReader(metric_exporter_cls()))
194
+
195
+ if prometheus_on:
196
+ prometheus_reader = _make_prometheus_reader()
197
+ if prometheus_reader is not None:
198
+ metric_readers.append(prometheus_reader)
199
+ _prometheus_enabled = True
200
+
201
+ meter_provider = MeterProvider(resource=resource, metric_readers=metric_readers)
202
+ metrics.set_meter_provider(meter_provider)
203
+
204
+ if settings.enabled and settings.logs_enabled:
205
+ logger_provider = LoggerProvider(resource=resource)
206
+ logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter_cls()))
207
+ set_logger_provider(logger_provider)
208
+ with warnings.catch_warnings():
209
+ warnings.filterwarnings("ignore", message=".*LoggingHandler.*deprecated.*", category=DeprecationWarning)
210
+ otlp_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
211
+ otlp_handler.addFilter(lambda record: not record.name.startswith("opentelemetry"))
212
+ root_logger = logging.getLogger()
213
+ root_logger.addHandler(otlp_handler)
214
+ root_logger.setLevel(resolve_log_level(settings.log_level))
215
+
216
+ if settings.enabled:
217
+ _instrument(app, engine)
218
+
219
+ _configured = True
220
+ logger.info(
221
+ "opentelemetry: enabled (otlp=%s prometheus=%s service=%s)",
222
+ settings.enabled,
223
+ _prometheus_enabled,
224
+ settings.service_name,
225
+ )
226
+ return True
227
+
228
+
229
+ def _prometheus_flag_set(env: Mapping[str, str] | None = None) -> bool:
230
+ env = env if env is not None else os.environ
231
+ return (env.get("AGENTOPS_PROMETHEUS_METRICS") or "").strip().lower() in {"1", "true", "yes", "on"}
232
+
233
+
234
+ def _make_prometheus_reader() -> object | None:
235
+ if PrometheusMetricReader is None:
236
+ logger.warning("opentelemetry-exporter-prometheus not installed; /metrics disabled")
237
+ return None
238
+ return PrometheusMetricReader()
239
+
240
+
241
+ def prometheus_metrics_enabled() -> bool:
242
+ """True when a Prometheus reader is active and /metrics can be served."""
243
+ return _prometheus_enabled
244
+
245
+
246
+ def prometheus_exposition() -> tuple[bytes, str]:
247
+ """Return (payload, content_type) of the current Prometheus metrics exposition."""
248
+ if PrometheusMetricReader is None:
249
+ raise RuntimeError("prometheus_client / opentelemetry-exporter-prometheus not installed; /metrics unavailable")
250
+ return generate_latest(REGISTRY), CONTENT_TYPE_LATEST
251
+
252
+
253
+ def _instrument(app: FastAPI | None, engine: AsyncEngine | None) -> None:
254
+ """Run the auto-instrumentors.
255
+
256
+ The module-level ``_configured`` flag in ``configure_telemetry`` guards
257
+ against double-instrumentation (``FastAPIInstrumentor.instrument_app`` is
258
+ per-app and would otherwise stack middleware on repeat calls).
259
+ """
260
+ HTTPXClientInstrumentor().instrument()
261
+ LoggingInstrumentor().instrument(set_logging_format=False)
262
+ if app is not None:
263
+ try:
264
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor # noqa: PLC0415
265
+ except ImportError as exc: # pragma: no cover - only hit without fastapi
266
+ raise RuntimeError(
267
+ "configure_telemetry(app=...) requires fastapi; install it "
268
+ "(e.g. `pip install fastapi`) or call configure_telemetry() without an app."
269
+ ) from exc
270
+ # Health probes fire constantly (k8s liveness/readiness) and carry no
271
+ # signal — drop their spans so we don't waste export volume on them.
272
+ FastAPIInstrumentor.instrument_app(app, excluded_urls="healthz")
273
+ if engine is not None and SQLAlchemyInstrumentor is not None:
274
+ SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentops-otel
3
+ Version: 0.1.0
4
+ Summary: Shared OpenTelemetry bootstrap for AgentOps (control plane + SDK workers).
5
+ Author: Komodor Ltd.
6
+ License-Expression: LicenseRef-Komodor-Proprietary
7
+ Project-URL: Homepage, https://agentops.komodor.com
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: httpx
12
+ Requires-Dist: opentelemetry-sdk>=1.27
13
+ Requires-Dist: opentelemetry-exporter-otlp>=1.27
14
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0
15
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0
16
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.48b0
17
+ Provides-Extra: sqlalchemy
18
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.48b0; extra == "sqlalchemy"
19
+ Provides-Extra: prometheus
20
+ Requires-Dist: opentelemetry-exporter-prometheus>=0.48b0; extra == "prometheus"
21
+ Dynamic: license-file
22
+
23
+ # agentops-otel
24
+
25
+ Shared logging + OpenTelemetry bootstrap for AgentOps.
26
+
27
+ ## Logging (`configure_logging`, `get_logger`)
28
+
29
+ One entry point sets up the process's logging; every module then grabs a logger
30
+ and lets records propagate to the single root handler:
31
+
32
+ ```python
33
+ from agentops_otel import configure_logging, get_logger
34
+
35
+ configure_logging() # once, at process start (idempotent)
36
+ log = get_logger(__name__) # in every module
37
+ log.info("worker started", extra={"worker_id": wid})
38
+ ```
39
+
40
+ `configure_logging()` installs **one** stdout handler on the root logger and
41
+ picks its shape from the environment:
42
+
43
+ - **Local** (`AGENTOPS_ENV` / `AGENTOPS_DD_ENV` / `ENV` unset or `local`) — a
44
+ readable, coloured console line: `time LEVEL name file:line message key=val`.
45
+ - **Anything else** (`staging`, `production`, …) — one-line JSON per event with
46
+ explicit `level`/`status` and Datadog unified-service `dd.*` tags, so a Datadog
47
+ agent tailing container stdout reads the real severity and correlates to traces.
48
+
49
+ Our staging/prod control plane sets `AGENTOPS_DD_ENV`, and worker pods set
50
+ `OTEL_EXPORTER_OTLP_ENDPOINT` — either signal flips a deployed process to JSON
51
+ automatically, with no extra config. Overrides: `json_logs=True|False` forces a
52
+ format; level comes from the arg, then `AGENTOPS_LOG_LEVEL`, then
53
+ `AGENTOPS_DEBUG`, then INFO.
54
+
55
+ **Trace correlation is generic OpenTelemetry.** We read the current span through
56
+ the OTel *API* only. When no tracer/exporter is configured (the default —
57
+ `configure_telemetry` is opt-in), there is no active span and nothing is added,
58
+ so logging needs no running collector. When a span *is* active, each JSON record
59
+ gains `otelTraceID`/`otelSpanID` plus the `dd.trace_id`/`dd.span_id`
60
+ (lower-64-bit) form Datadog uses.
61
+
62
+ ### Forwarding worker logs to the control plane (`CPForwardingHandler`)
63
+
64
+ `agentops_otel.CPForwardingHandler` bridges the standard logging module to a
65
+ sink callback — the SDK worker runtime (`komodor_agentops.worker.logging_bridge.install_cp_log_forwarding`)
66
+ uses it to also ship every worker log record to the control plane as a
67
+ `log.created` event, since Komodor has no pod/log access to customer-hosted
68
+ workers. Two env vars control it:
69
+
70
+ - `AGENTOPS_CP_LOG_LEVEL` (default `INFO`) — minimum level forwarded to CP.
71
+ - `AGENTOPS_CP_LOG_CAPTURE_ALL` (default off) — forward only the SDK's own
72
+ loggers (`komodor_agentops`, `agentops`) when unset; set to attach to the
73
+ root logger instead and capture the whole process.
74
+
75
+ ## Telemetry (`configure_telemetry`)
76
+
77
+ `configure_telemetry(service_name, *, app=None, engine=None)` builds OTLP
78
+ tracer/meter/logger providers (HTTP/protobuf or gRPC), attaches the OTel logging
79
+ handler, and runs the FastAPI / httpx / logging auto-instrumentors. SQLAlchemy
80
+ instrumentation is the optional `agentops-otel[sqlalchemy]` extra.
81
+
82
+ Off by default: nothing is installed unless `OTEL_EXPORTER_OTLP_ENDPOINT` or
83
+ `AGENTOPS_OTEL_ENABLED` is set. Used by the control plane and by the SDK worker
84
+ runtime; neither duplicates the setup.
85
+
86
+ ## Metric naming (`metric_name`, `METRIC_PREFIX`)
87
+
88
+ Every AgentOps metric lives under a single `agentops.` namespace root so the
89
+ whole product's metrics are isolated and trivially sliceable in Datadog. **When
90
+ you create an instrument, name it through `metric_name()`** rather than passing a
91
+ raw string:
92
+
93
+ ```python
94
+ from agentops_otel import metric_name
95
+ from opentelemetry import metrics
96
+
97
+ meter = metrics.get_meter("agentops.controlplane.authz")
98
+ hist = meter.create_histogram(metric_name("authz.pdp.duration_ms"), unit="ms")
99
+ # → exported as "agentops.authz.pdp.duration_ms"
100
+ ```
101
+
102
+ `metric_name()` is **idempotent** — a name that already starts with
103
+ `METRIC_PREFIX` (`"agentops."`) is returned unchanged, so passing either the bare
104
+ suffix or the full name is safe.
105
+
106
+ Why a helper and not a global rewrite? OpenTelemetry Python has no
107
+ View/exporter-level facility for *dynamic per-instrument* renaming (a wildcard
108
+ `View`'s `name` is a single static string), so the exported metric name is fixed
109
+ at instrument creation. This helper is therefore the one canonical entry point.
110
+ Note that the meter/instrumentation-scope name is separate from the metric name
111
+ and does not affect it — the `agentops.` prefix must be on the instrument name.
@@ -0,0 +1,9 @@
1
+ agentops_otel/__init__.py,sha256=_g-8fpvvGa2C72iw5afy2U4wUz2yoLxQ9Hb5dIVTBkU,1068
2
+ agentops_otel/baggage.py,sha256=k0LvALiJGs-pjae9yCEpw8IlcwNWeFsZK8FnIDI2Qtg,2363
3
+ agentops_otel/logging.py,sha256=f1uBD6v3bqz8BdB0Eh-48SBxQz1y7TSAqQXHv6sgIrw,12935
4
+ agentops_otel/telemetry.py,sha256=cz9H0GR28OlJ6i9-6CAaOiK1bugcRQJh5XAUDQ1jvRk,11733
5
+ agentops_otel-0.1.0.dist-info/licenses/LICENSE,sha256=tk2w5qlj6wMGprpSF4kOWoOh7BZsjGEUSFcGiQcVKkY,3390
6
+ agentops_otel-0.1.0.dist-info/METADATA,sha256=f4qqEp9XvNyncQ8oIgDhJgzBs4VTUk7AwVHZBF1yv1I,5158
7
+ agentops_otel-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ agentops_otel-0.1.0.dist-info/top_level.txt,sha256=9NjNRA4d5_06lq36hkhwbu5cIZ5XFjMIttGa4BUJLQU,14
9
+ agentops_otel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,53 @@
1
+ Komodor AgentOps License
2
+
3
+ The Komodor AgentOps SDK, together with its associated libraries, documentation,
4
+ and files (the "Software"), is provided by Komodor Ltd. ("Komodor") for integration
5
+ with the Komodor service (the "Service"). By installing, copying, or using the
6
+ Software, you agree to these terms.
7
+
8
+ © 2020 - Komodor Ltd.
9
+
10
+ Grant. Subject to your compliance with these terms, Komodor grants you a
11
+ non-exclusive, non-transferable, non-sublicensable, worldwide, revocable, and
12
+ limited license to (a) install and use the Software to develop, test, and operate
13
+ integrations of your applications and services with the Service, or to use
14
+ applications and services that embed the Software, and (b) reproduce
15
+ and distribute verbatim copies of the Software, but only as embedded within your own
16
+ applications, services, or container images, and not on a standalone basis. No
17
+ separate agreement with Komodor is required; anyone who obtains the Software
18
+ receives this license. If you have a separate written agreement with Komodor
19
+ covering the Software, that agreement controls to the extent of any conflict.
20
+
21
+ You may develop and operate your own software that imports, calls, subclasses,
22
+ configures, or otherwise depends on the Software through its published interfaces;
23
+ doing so is permitted use and does not, by itself, create a modified version or
24
+ derivative work of the Software.
25
+
26
+ Restrictions. You shall not: (a) distribute the Software on a standalone basis, or
27
+ resell, lease, or sublicense it; (b) offer the Software itself, or substantially its
28
+ standalone functionality, to third parties as a hosted or managed service — this
29
+ does not restrict you from operating or offering your own applications or services
30
+ that embed the Software; (c) modify the Software or create a derivative work of its
31
+ files or code; (d) remove, alter, or obscure any copyright, license, or proprietary
32
+ notice, all of which you must retain in every copy you distribute; or (e) claim any
33
+ proprietary interest in the Software, or contest or infringe Komodor's intellectual
34
+ property rights in it.
35
+
36
+ Ownership. The Software is licensed, not sold. Komodor retains all right, title, and
37
+ interest in and to the Software and all intellectual property rights in it. No
38
+ rights are granted except as expressly stated here, and no patent rights are
39
+ granted. This license does not permit use of Komodor's names or trademarks, except
40
+ for reasonable and customary use in describing the origin of the Software.
41
+
42
+ Termination. This license terminates automatically if you breach it and do not cure
43
+ the breach within thirty (30) days of Komodor's written notice. On termination you
44
+ must stop installing, using, and distributing the Software, except that copies
45
+ already embedded in products you distributed to end users before termination may
46
+ remain in use. The Restrictions and this "No warranty; no liability" section survive termination.
47
+
48
+ No warranty; no liability. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
49
+ KIND, EXPRESS OR IMPLIED, INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY,
50
+ FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. IN NO EVENT SHALL
51
+ KOMODOR OR THE COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
52
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT
53
+ OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ agentops_otel