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.
- agentops_otel/__init__.py +46 -0
- agentops_otel/baggage.py +52 -0
- agentops_otel/logging.py +323 -0
- agentops_otel/telemetry.py +274 -0
- agentops_otel-0.1.0.dist-info/METADATA +111 -0
- agentops_otel-0.1.0.dist-info/RECORD +9 -0
- agentops_otel-0.1.0.dist-info/WHEEL +5 -0
- agentops_otel-0.1.0.dist-info/licenses/LICENSE +53 -0
- agentops_otel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
agentops_otel/baggage.py
ADDED
|
@@ -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
|
agentops_otel/logging.py
ADDED
|
@@ -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,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
|