edgeone 1.5.9 → 1.6.0
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.
- package/README.md +26 -26
- package/edgeone-bin/edgeone.js +3 -3
- package/edgeone-dist/cli.js +86879 -2307
- package/edgeone-dist/libs-pages-agent-toolkit/README.md +8 -0
- package/edgeone-dist/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
- package/edgeone-dist/libs-pages-blob-python/README.md +38 -0
- package/edgeone-dist/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
- package/edgeone-dist/pages/dev/runner-worker.js +86521 -2090
- package/edgeone-dist/pages/observability-python/__init__.py +32 -0
- package/edgeone-dist/pages/observability-python/_compat.py +69 -0
- package/edgeone-dist/pages/observability-python/apm/__init__.py +13 -0
- package/edgeone-dist/pages/observability-python/apm/config.py +85 -0
- package/edgeone-dist/pages/observability-python/apm/llm_semconv.py +53 -0
- package/edgeone-dist/pages/observability-python/apm/metrics_bridge.py +226 -0
- package/edgeone-dist/pages/observability-python/apm/span_exporter.py +384 -0
- package/edgeone-dist/pages/observability-python/bootstrap.py +158 -0
- package/edgeone-dist/pages/observability-python/build.py +119 -0
- package/edgeone-dist/pages/observability-python/context_patches.py +167 -0
- package/edgeone-dist/pages/observability-python/context_propagator.py +78 -0
- package/edgeone-dist/pages/observability-python/registry.json +95 -0
- package/edgeone-dist/pages/observability-python/registry.py +141 -0
- package/edgeone-dist/pages/observability-python/telemetry.py +214 -0
- package/edgeone-dist/pages/observability-python/tracer.py +165 -0
- package/edgeone-dist/pages/templates/agent-python/__init__.py +11 -0
- package/edgeone-dist/pages/templates/agent-python/adapter.py +908 -0
- package/edgeone-dist/pages/templates/agent-python/context.py +689 -0
- package/edgeone-dist/pages/templates/agent-python/local_blob_store.py +172 -0
- package/edgeone-dist/pages/templates/agent-python/memory.py +2301 -0
- package/edgeone-dist/pages/templates/agent-python/runtime.py +839 -0
- package/edgeone-dist/pages/templates/agent-python/store.py +204 -0
- package/edgeone-dist/studio/ui/assets/agent-obs-Dvi4IpEy.js +4 -0
- package/edgeone-dist/studio/ui/assets/agent-obs-qDJCE0TQ.css +1 -0
- package/edgeone-dist/studio/ui/assets/highlight-ClXAL37H.js +3 -0
- package/edgeone-dist/studio/ui/assets/index-Cz5oQnXW.css +1 -0
- package/edgeone-dist/studio/ui/assets/index-DD3d108t.js +1 -0
- package/edgeone-dist/studio/ui/assets/moment-BYRO94Ou.js +10 -0
- package/edgeone-dist/studio/ui/assets/react-dom-ZzBHVjtL.js +24 -0
- package/edgeone-dist/studio/ui/assets/react-hnpCyKql.js +17 -0
- package/edgeone-dist/studio/ui/assets/tea-CADagUwM.css +1 -0
- package/edgeone-dist/studio/ui/assets/tea-Slf_ajmf.js +334 -0
- package/edgeone-dist/studio/ui/favicon.ico +0 -0
- package/edgeone-dist/studio/ui/index.html +31 -0
- package/libs-pages-agent-toolkit/README.md +8 -0
- package/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
- package/libs-pages-blob-python/README.md +38 -0
- package/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
- package/package.json +33 -7
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""APM span translator — OpenInference LLM spans -> Tencent APM format.
|
|
2
|
+
|
|
3
|
+
Mirrors frameworks-py/src/runner/apm_span_exporter.py.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Sequence
|
|
9
|
+
|
|
10
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
11
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
12
|
+
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
|
|
13
|
+
from opentelemetry.trace import SpanKind
|
|
14
|
+
from opentelemetry.trace.status import Status, StatusCode
|
|
15
|
+
|
|
16
|
+
from .config import (
|
|
17
|
+
APM_IDENTITY_LIBRARY_NAME,
|
|
18
|
+
APM_IDENTITY_LIBRARY_VERSION,
|
|
19
|
+
apm_identity_attrs,
|
|
20
|
+
)
|
|
21
|
+
from .llm_semconv import (
|
|
22
|
+
OI_LLM_KIND_SET,
|
|
23
|
+
OI_TO_GENAI_MIRROR,
|
|
24
|
+
oi_kind_to_genai_span_kind,
|
|
25
|
+
oi_kind_to_operation,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TranslatorContext:
|
|
31
|
+
is_root_span_id: Callable[[str], bool]
|
|
32
|
+
root_span_has_children: Callable[[str], bool]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# LangGraph 控制流异常名(通过 interrupt() / Command 跳转触发),
|
|
36
|
+
# instrumentor 会经 on_chain_error 把对应 span 标成 ERROR,但这不是业务错误。
|
|
37
|
+
# 另外 GeneratorExit / CancelledError 是 interrupt 触发后流被拆除 / 取消时的
|
|
38
|
+
# teardown 信号(async generator aclose、task cancel),同样不是业务错误,
|
|
39
|
+
# 一并归一为非错误,避免 APM/langfuse 误报。
|
|
40
|
+
_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
41
|
+
"GraphInterrupt",
|
|
42
|
+
"NodeInterrupt",
|
|
43
|
+
"ParentCommand",
|
|
44
|
+
"GeneratorExit",
|
|
45
|
+
"CancelledError",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# CrewAI 的人工反馈暂停信号:crewai.flow.async_feedback.types.HumanFeedbackPending
|
|
49
|
+
# 是 ``Exception`` 的子类(源码注释 noqa N818 — Not an error, a control flow signal),
|
|
50
|
+
# 用于 @human_feedback flow 暂停执行、持久化状态并把异常对象返回给调用者,并非业务错误。
|
|
51
|
+
# openinference CrewAIInstrumentor 仍会把它经 record_exception 标到 span 上导致误报。
|
|
52
|
+
# 与 LangGraph interrupt 不同,这类名字本身虽 CrewAI 专属,但为保守起见仅在 span 来自
|
|
53
|
+
# crewai instrumentation scope 时才归一,避免误伤其它来源的同名异常。
|
|
54
|
+
_CREWAI_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
55
|
+
"HumanFeedbackPending",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _attr(span: ReadableSpan, key: str) -> str | None:
|
|
60
|
+
v = span.attributes.get(key) if span.attributes else None
|
|
61
|
+
if v is None:
|
|
62
|
+
return None
|
|
63
|
+
return str(v)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _iter_exception_type_names(span: ReadableSpan):
|
|
67
|
+
"""产出 span 上记录的异常类名(去掉模块前缀)。
|
|
68
|
+
|
|
69
|
+
覆盖两种惯例:异常类型写在 ``exception.type`` / ``error.type`` 属性上,
|
|
70
|
+
或作为 OTel 标准 "exception" event 的 ``exception.type`` 记录。
|
|
71
|
+
"""
|
|
72
|
+
attrs = span.attributes or {}
|
|
73
|
+
for key in ("exception.type", "error.type"):
|
|
74
|
+
v = attrs.get(key)
|
|
75
|
+
if isinstance(v, str) and v:
|
|
76
|
+
yield v.rsplit(".", 1)[-1]
|
|
77
|
+
for ev in getattr(span, "events", None) or ():
|
|
78
|
+
if getattr(ev, "name", None) != "exception":
|
|
79
|
+
continue
|
|
80
|
+
ev_attrs = getattr(ev, "attributes", None) or {}
|
|
81
|
+
v = ev_attrs.get("exception.type")
|
|
82
|
+
if isinstance(v, str) and v:
|
|
83
|
+
yield v.rsplit(".", 1)[-1]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_crewai_scope(span: ReadableSpan) -> bool:
|
|
87
|
+
"""span 是否来自 crewai instrumentation scope。
|
|
88
|
+
|
|
89
|
+
覆盖 openinference 的 ``openinference.instrumentation.crewai`` 与 crewai 原生埋点,
|
|
90
|
+
统一按 scope 名是否包含 "crewai"(忽略大小写)判断。
|
|
91
|
+
"""
|
|
92
|
+
scope = getattr(span, "instrumentation_scope", None)
|
|
93
|
+
name = getattr(scope, "name", None)
|
|
94
|
+
return isinstance(name, str) and "crewai" in name.lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _matches_interrupt_name(name: str, crewai_scope: bool) -> bool:
|
|
98
|
+
"""判断异常类名(已去掉模块前缀)是否为控制流暂停信号。
|
|
99
|
+
|
|
100
|
+
LangGraph 控制流异常全局归一;CrewAI HumanFeedbackPending 仅在 crewai scope 内归一。
|
|
101
|
+
"""
|
|
102
|
+
if name in _INTERRUPT_EXCEPTION_NAMES:
|
|
103
|
+
return True
|
|
104
|
+
if crewai_scope and name in _CREWAI_INTERRUPT_EXCEPTION_NAMES:
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_interrupt_span(span: ReadableSpan) -> bool:
|
|
110
|
+
"""检测 span 是否由控制流暂停信号触发(非业务错误)。
|
|
111
|
+
|
|
112
|
+
依据 OpenTelemetry / OpenInference 把异常类名写到 ``exception.type`` 或
|
|
113
|
+
``error.type`` 这一惯例。匹配时支持 ``module.GraphInterrupt`` 这类全限定名。
|
|
114
|
+
"""
|
|
115
|
+
crewai_scope = _is_crewai_scope(span)
|
|
116
|
+
return any(
|
|
117
|
+
_matches_interrupt_name(n, crewai_scope)
|
|
118
|
+
for n in _iter_exception_type_names(span)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _filter_interrupt_exception_events(span: ReadableSpan):
|
|
123
|
+
"""剔除 span 上记录的控制流暂停异常 event,返回过滤后的 event 元组。
|
|
124
|
+
|
|
125
|
+
APM/langfuse 平台会依据 span 上 ``record_exception()`` 产生的 ``exception``
|
|
126
|
+
event 判定 ``error=true`` / ``statusCode=ERROR`` 并展示堆栈,仅把 OTel status
|
|
127
|
+
归一为 UNSET 不足以消除误报。因此对 interrupt span 同步剔除对应的 exception
|
|
128
|
+
event。仅剔除类型命中 interrupt 名单的 event,保留其它真实异常 event。
|
|
129
|
+
|
|
130
|
+
返回 None 表示没有任何 event 被剔除(调用方应沿用原始 events)。
|
|
131
|
+
"""
|
|
132
|
+
crewai_scope = _is_crewai_scope(span)
|
|
133
|
+
kept = []
|
|
134
|
+
dropped = False
|
|
135
|
+
for ev in getattr(span, "events", None) or ():
|
|
136
|
+
if getattr(ev, "name", None) == "exception":
|
|
137
|
+
ev_attrs = getattr(ev, "attributes", None) or {}
|
|
138
|
+
etype = ev_attrs.get("exception.type")
|
|
139
|
+
if (
|
|
140
|
+
isinstance(etype, str)
|
|
141
|
+
and etype
|
|
142
|
+
and _matches_interrupt_name(etype.rsplit(".", 1)[-1], crewai_scope)
|
|
143
|
+
):
|
|
144
|
+
dropped = True
|
|
145
|
+
continue
|
|
146
|
+
kept.append(ev)
|
|
147
|
+
return tuple(kept) if dropped else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_oi_llm_span(span: ReadableSpan) -> bool:
|
|
151
|
+
kind = _attr(span, "openinference.span.kind")
|
|
152
|
+
return kind is not None and kind in OI_LLM_KIND_SET
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_trace_entry(span: ReadableSpan, ctx: TranslatorContext) -> bool:
|
|
156
|
+
parent = span.parent
|
|
157
|
+
if parent is None:
|
|
158
|
+
return True
|
|
159
|
+
try:
|
|
160
|
+
pid_int = parent.span_id
|
|
161
|
+
except AttributeError:
|
|
162
|
+
return False
|
|
163
|
+
pid_hex = format(pid_int, "016x")
|
|
164
|
+
return ctx.is_root_span_id(pid_hex)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _genai_mirror(src: dict[str, Any]) -> dict[str, Any]:
|
|
168
|
+
out: dict[str, Any] = {}
|
|
169
|
+
for dest_key, src_key, coerce in OI_TO_GENAI_MIRROR:
|
|
170
|
+
if dest_key in src:
|
|
171
|
+
continue
|
|
172
|
+
v = src.get(src_key)
|
|
173
|
+
if v is None:
|
|
174
|
+
continue
|
|
175
|
+
if coerce == "number":
|
|
176
|
+
try:
|
|
177
|
+
n = float(v)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
if n != n or n in (float("inf"), float("-inf")):
|
|
181
|
+
continue
|
|
182
|
+
out[dest_key] = int(n) if n.is_integer() else n
|
|
183
|
+
else:
|
|
184
|
+
out[dest_key] = str(v)
|
|
185
|
+
|
|
186
|
+
oi_kind = src.get("openinference.span.kind")
|
|
187
|
+
if isinstance(oi_kind, str):
|
|
188
|
+
mapped = oi_kind_to_operation(oi_kind)
|
|
189
|
+
if "llm.request.type" not in src:
|
|
190
|
+
out["llm.request.type"] = mapped
|
|
191
|
+
if "gen_ai.operation.name" not in src:
|
|
192
|
+
out["gen_ai.operation.name"] = mapped
|
|
193
|
+
if "gen_ai.span.kind" not in src:
|
|
194
|
+
out["gen_ai.span.kind"] = oi_kind_to_genai_span_kind(oi_kind)
|
|
195
|
+
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _TranslatedSpan:
|
|
200
|
+
__slots__ = ("_src", "_attributes", "_status", "_scope", "_events")
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
src: ReadableSpan,
|
|
205
|
+
attributes: dict[str, Any],
|
|
206
|
+
status: Status,
|
|
207
|
+
scope: InstrumentationScope,
|
|
208
|
+
events: Any = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
self._src = src
|
|
211
|
+
self._attributes = attributes
|
|
212
|
+
self._status = status
|
|
213
|
+
self._scope = scope
|
|
214
|
+
self._events = events
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def name(self) -> str:
|
|
218
|
+
return self._src.name
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def kind(self) -> SpanKind:
|
|
222
|
+
return SpanKind.INTERNAL
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def context(self):
|
|
226
|
+
return self._src.context
|
|
227
|
+
|
|
228
|
+
def get_span_context(self):
|
|
229
|
+
return self._src.get_span_context()
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def parent(self):
|
|
233
|
+
return self._src.parent
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def start_time(self) -> int | None:
|
|
237
|
+
return self._src.start_time
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def end_time(self) -> int | None:
|
|
241
|
+
return self._src.end_time
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def status(self) -> Status:
|
|
245
|
+
return self._status
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def attributes(self) -> dict[str, Any]:
|
|
249
|
+
return self._attributes
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def instrumentation_scope(self) -> InstrumentationScope:
|
|
253
|
+
return self._scope
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def instrumentation_info(self) -> InstrumentationScope:
|
|
257
|
+
return self._scope
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def events(self):
|
|
261
|
+
return self._src.events if self._events is None else self._events
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def links(self):
|
|
265
|
+
return self._src.links
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def resource(self):
|
|
269
|
+
return self._src.resource
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def dropped_attributes(self) -> int:
|
|
273
|
+
return getattr(self._src, "dropped_attributes", 0)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def dropped_events(self) -> int:
|
|
277
|
+
return getattr(self._src, "dropped_events", 0)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def dropped_links(self) -> int:
|
|
281
|
+
return getattr(self._src, "dropped_links", 0)
|
|
282
|
+
|
|
283
|
+
def to_json(self, *args: Any, **kwargs: Any) -> str:
|
|
284
|
+
return self._src.to_json(*args, **kwargs)
|
|
285
|
+
|
|
286
|
+
def __getattr__(self, name: str) -> Any:
|
|
287
|
+
return getattr(self._src, name)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _translate(span: ReadableSpan, ctx: TranslatorContext) -> ReadableSpan:
|
|
291
|
+
src_attrs: dict[str, Any] = dict(span.attributes or {})
|
|
292
|
+
model = src_attrs.get("llm.model_name") or "unknown"
|
|
293
|
+
provider = src_attrs.get("llm.provider") or "unknown"
|
|
294
|
+
oi_kind = src_attrs.get("openinference.span.kind") or "unknown"
|
|
295
|
+
|
|
296
|
+
# APM 平台对普通 attribute 有 1 KB 的截断限制,但以 "langfuse." 开头的
|
|
297
|
+
# attribute 不受限制。所有 span attribute 统一加 "langfuse." 前缀以避免截断。
|
|
298
|
+
merged: dict[str, Any] = {f"langfuse.{k}": v for k, v in src_attrs.items()}
|
|
299
|
+
merged.setdefault("custom_key_1", str(model))
|
|
300
|
+
merged.setdefault("custom_key_2", str(provider))
|
|
301
|
+
merged.setdefault("custom_key_3", str(oi_kind))
|
|
302
|
+
|
|
303
|
+
merged.update(_genai_mirror(src_attrs))
|
|
304
|
+
|
|
305
|
+
if "gen_ai.is_entry" not in merged and _is_trace_entry(span, ctx):
|
|
306
|
+
merged["gen_ai.is_entry"] = True
|
|
307
|
+
|
|
308
|
+
for k, v in apm_identity_attrs().items():
|
|
309
|
+
merged.setdefault(k, v)
|
|
310
|
+
|
|
311
|
+
status = span.status or Status(StatusCode.UNSET)
|
|
312
|
+
if status.status_code == StatusCode.OK:
|
|
313
|
+
status = Status(StatusCode.UNSET)
|
|
314
|
+
events_override = None
|
|
315
|
+
# LangGraph interrupt 不是真错误,把 ERROR 归一回 UNSET,并打标方便筛选。
|
|
316
|
+
if status.status_code == StatusCode.ERROR and _is_interrupt_span(span):
|
|
317
|
+
status = Status(StatusCode.UNSET)
|
|
318
|
+
merged["agent.interrupt"] = True
|
|
319
|
+
merged["langfuse.agent.interrupt"] = True
|
|
320
|
+
# 同步剔除 interrupt 异常 event,避免 APM 仍据此判 error 并展示堆栈。
|
|
321
|
+
events_override = _filter_interrupt_exception_events(span)
|
|
322
|
+
|
|
323
|
+
scope = InstrumentationScope(
|
|
324
|
+
name=APM_IDENTITY_LIBRARY_NAME,
|
|
325
|
+
version=APM_IDENTITY_LIBRARY_VERSION,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return _TranslatedSpan(
|
|
329
|
+
src=span,
|
|
330
|
+
attributes=merged,
|
|
331
|
+
status=status,
|
|
332
|
+
scope=scope,
|
|
333
|
+
events=events_override,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _prefix_only(span: ReadableSpan, ctx: TranslatorContext) -> ReadableSpan:
|
|
338
|
+
"""对非 OI LLM span 只做 attribute 前缀处理,不注入 APM 专用字段。
|
|
339
|
+
例外:root span 若有子 span(说明该请求有实际的追踪活动),标记 gen_ai.is_entry。
|
|
340
|
+
"""
|
|
341
|
+
src_attrs: dict[str, Any] = dict(span.attributes or {})
|
|
342
|
+
prefixed: dict[str, Any] = {f"langfuse.{k}": v for k, v in src_attrs.items()}
|
|
343
|
+
span_id = format(span.get_span_context().span_id, "016x")
|
|
344
|
+
if ctx.is_root_span_id(span_id) and ctx.root_span_has_children(span_id):
|
|
345
|
+
prefixed["gen_ai.is_entry"] = True
|
|
346
|
+
|
|
347
|
+
status = span.status or Status(StatusCode.UNSET)
|
|
348
|
+
events_override = None
|
|
349
|
+
if status.status_code == StatusCode.ERROR and _is_interrupt_span(span):
|
|
350
|
+
status = Status(StatusCode.UNSET)
|
|
351
|
+
prefixed["agent.interrupt"] = True
|
|
352
|
+
prefixed["langfuse.agent.interrupt"] = True
|
|
353
|
+
# 同步剔除 interrupt 异常 event,避免 APM 仍据此判 error 并展示堆栈。
|
|
354
|
+
events_override = _filter_interrupt_exception_events(span)
|
|
355
|
+
|
|
356
|
+
return _TranslatedSpan(
|
|
357
|
+
src=span,
|
|
358
|
+
attributes=prefixed,
|
|
359
|
+
status=status,
|
|
360
|
+
scope=span.instrumentation_scope,
|
|
361
|
+
events=events_override,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class ApmSpanExporter(SpanExporter):
|
|
366
|
+
def __init__(self, inner: SpanExporter, ctx: TranslatorContext) -> None:
|
|
367
|
+
self._inner = inner
|
|
368
|
+
self._ctx = ctx
|
|
369
|
+
|
|
370
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
371
|
+
translated = [
|
|
372
|
+
_translate(s, self._ctx) if _is_oi_llm_span(s) else _prefix_only(s, self._ctx)
|
|
373
|
+
for s in spans
|
|
374
|
+
]
|
|
375
|
+
return self._inner.export(translated)
|
|
376
|
+
|
|
377
|
+
def shutdown(self) -> None:
|
|
378
|
+
self._inner.shutdown()
|
|
379
|
+
|
|
380
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
381
|
+
try:
|
|
382
|
+
return self._inner.force_flush(timeout_millis)
|
|
383
|
+
except AttributeError:
|
|
384
|
+
return True
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Bootstrap: main entry point for the observability package.
|
|
2
|
+
|
|
3
|
+
CLI calls setup() once at process startup. Returns an AgentTracer for ctx.tracer.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
12
|
+
from opentelemetry.trace import Span
|
|
13
|
+
from opentelemetry.context import Context
|
|
14
|
+
|
|
15
|
+
from .tracer import AgentTracer, NoOpTracer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_setup_done = False
|
|
19
|
+
_last_tracer: AgentTracer | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _suppress_noisy_loggers() -> None:
|
|
23
|
+
"""Suppress noisy OTel exporter retry/timeout logs and OpenInference callback errors.
|
|
24
|
+
|
|
25
|
+
These loggers produce very verbose output during normal operation:
|
|
26
|
+
- Span/metrics export retries when APM endpoint is unreachable
|
|
27
|
+
- OpenInferenceTracer missing callback method errors
|
|
28
|
+
- BatchSpanProcessor timeout warnings
|
|
29
|
+
"""
|
|
30
|
+
for name in (
|
|
31
|
+
"opentelemetry.sdk.trace.export",
|
|
32
|
+
"opentelemetry.sdk.metrics.export",
|
|
33
|
+
"opentelemetry.exporter.otlp",
|
|
34
|
+
"opentelemetry.exporter.otlp.proto.http",
|
|
35
|
+
"opentelemetry.exporter.otlp.proto.http.trace_exporter",
|
|
36
|
+
"opentelemetry.exporter.otlp.proto.http.metric_exporter",
|
|
37
|
+
"openinference.instrumentation.langchain",
|
|
38
|
+
"openinference.instrumentation",
|
|
39
|
+
):
|
|
40
|
+
logging.getLogger(name).setLevel(logging.CRITICAL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup(
|
|
44
|
+
entry_names: list[str],
|
|
45
|
+
*,
|
|
46
|
+
endpoint: str | None = None,
|
|
47
|
+
service_name: str | None = None,
|
|
48
|
+
apm_token: str | None = None,
|
|
49
|
+
_tracer_provider: TracerProvider | None = None,
|
|
50
|
+
) -> AgentTracer:
|
|
51
|
+
"""Main entry point: activate instrumentors + initialize telemetry + return tracer.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
entry_names: List of framework names to activate (e.g. ["langchain", "openai"])
|
|
55
|
+
endpoint: OTLP endpoint override (default: from env / APM config)
|
|
56
|
+
service_name: Service name override (default: from env)
|
|
57
|
+
apm_token: APM token override (default: from env)
|
|
58
|
+
_tracer_provider: For testing — inject a custom TracerProvider with InMemoryExporter
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
AgentTracer instance to be set on context.tracer
|
|
62
|
+
"""
|
|
63
|
+
global _setup_done, _last_tracer
|
|
64
|
+
if _setup_done and _tracer_provider is None:
|
|
65
|
+
from .telemetry import get_telemetry
|
|
66
|
+
tel = get_telemetry()
|
|
67
|
+
if tel:
|
|
68
|
+
return _last_tracer or AgentTracer(tracer_provider=tel.tracer_provider)
|
|
69
|
+
return NoOpTracer() # type: ignore[return-value]
|
|
70
|
+
_setup_done = True
|
|
71
|
+
|
|
72
|
+
# 0. Suppress noisy OTel exporter/retry logs
|
|
73
|
+
_suppress_noisy_loggers()
|
|
74
|
+
|
|
75
|
+
# 1. Initialize telemetry (sets global TracerProvider)
|
|
76
|
+
from .telemetry import setup_telemetry
|
|
77
|
+
telemetry = setup_telemetry(
|
|
78
|
+
tracer_provider=_tracer_provider,
|
|
79
|
+
endpoint=endpoint,
|
|
80
|
+
service_name=service_name,
|
|
81
|
+
apm_token=apm_token,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 2. Activate instrumentors with the provider
|
|
85
|
+
if entry_names:
|
|
86
|
+
from .registry import REGISTRY
|
|
87
|
+
activated: list[str] = []
|
|
88
|
+
for entry in REGISTRY:
|
|
89
|
+
if entry.name not in entry_names:
|
|
90
|
+
continue
|
|
91
|
+
for factory in entry.instrumentor_factories:
|
|
92
|
+
try:
|
|
93
|
+
instrumentor = factory()
|
|
94
|
+
instrumentor.instrument(tracer_provider=telemetry.tracer_provider) # type: ignore
|
|
95
|
+
activated.append(entry.name)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"[observability] failed to activate {entry.name}: {e}",
|
|
98
|
+
file=sys.stderr, flush=True)
|
|
99
|
+
|
|
100
|
+
if activated:
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
unique = [n for n in activated if not (n in seen or seen.add(n))] # type: ignore
|
|
103
|
+
print(f"[observability] enabled: {', '.join(unique)}", file=sys.stderr, flush=True)
|
|
104
|
+
|
|
105
|
+
# 3. Apply context propagation patches
|
|
106
|
+
try:
|
|
107
|
+
from .context_patches import apply_context_patches
|
|
108
|
+
apply_context_patches()
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
_last_tracer = AgentTracer(tracer_provider=telemetry.tracer_provider)
|
|
113
|
+
return _last_tracer
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start_request_span(
|
|
117
|
+
name: str, attributes: dict[str, Any] | None = None
|
|
118
|
+
) -> tuple[Span, Context]:
|
|
119
|
+
"""Create a per-request root span. Called by adapter for each request."""
|
|
120
|
+
from .telemetry import get_telemetry
|
|
121
|
+
tel = get_telemetry()
|
|
122
|
+
if tel is None:
|
|
123
|
+
from opentelemetry.trace import INVALID_SPAN
|
|
124
|
+
from opentelemetry.context import get_current
|
|
125
|
+
return INVALID_SPAN, get_current()
|
|
126
|
+
return tel.start_request_span(name, attributes)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def end_request_span(span: Span) -> None:
|
|
130
|
+
"""End a request span."""
|
|
131
|
+
from .telemetry import get_telemetry
|
|
132
|
+
tel = get_telemetry()
|
|
133
|
+
if tel:
|
|
134
|
+
tel.end_request_span(span)
|
|
135
|
+
else:
|
|
136
|
+
span.end()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def shutdown() -> None:
|
|
140
|
+
"""Flush and shutdown telemetry."""
|
|
141
|
+
from .telemetry import get_telemetry
|
|
142
|
+
tel = get_telemetry()
|
|
143
|
+
if tel:
|
|
144
|
+
tel.shutdown()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_tracer() -> AgentTracer | None:
|
|
148
|
+
"""Return the tracer created by the last setup() call, or None."""
|
|
149
|
+
return _last_tracer
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def reset() -> None:
|
|
153
|
+
"""Reset all state. For testing only."""
|
|
154
|
+
global _setup_done, _last_tracer
|
|
155
|
+
_setup_done = False
|
|
156
|
+
_last_tracer = None
|
|
157
|
+
from .telemetry import reset_telemetry
|
|
158
|
+
reset_telemetry()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Build-time API — resolve entry names to pip dependencies.
|
|
2
|
+
|
|
3
|
+
CLI calls this (via subprocess or direct import) to determine
|
|
4
|
+
what pip packages to install for the matched frameworks.
|
|
5
|
+
|
|
6
|
+
from edgeone_agent_observability.build import resolve
|
|
7
|
+
result = resolve(["langchain", "openai"])
|
|
8
|
+
# result.entries → pass to setup() at runtime
|
|
9
|
+
# result.required_deps → pip packages to install
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ResolveResult:
|
|
18
|
+
"""What CLI needs to install and pass to setup()."""
|
|
19
|
+
entries: list[str]
|
|
20
|
+
required_deps: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Framework → pip packages needed for instrumentation.
|
|
24
|
+
# Mirrors pyproject.toml [project.optional-dependencies] but exposed
|
|
25
|
+
# programmatically so CLI doesn't have to parse TOML.
|
|
26
|
+
_INSTRUMENTATION_DEPS: dict[str, list[str]] = {
|
|
27
|
+
"langchain": ["openinference-instrumentation-langchain>=0.1.30"],
|
|
28
|
+
"crewai": [
|
|
29
|
+
"openinference-instrumentation-crewai>=0.1.8",
|
|
30
|
+
"openinference-instrumentation-litellm>=0.1",
|
|
31
|
+
],
|
|
32
|
+
"llama-index": ["openinference-instrumentation-llama-index>=4.0"],
|
|
33
|
+
"openai": ["openinference-instrumentation-openai>=0.1.15"],
|
|
34
|
+
"openai-agents": ["openinference-instrumentation-openai-agents>=1.5.0"],
|
|
35
|
+
"anthropic": ["openinference-instrumentation-anthropic>=1.0.5"],
|
|
36
|
+
"beeai": ["openinference-instrumentation-beeai>=0.1.19"],
|
|
37
|
+
"agno": ["openinference-instrumentation-agno>=0.1.34"],
|
|
38
|
+
"autogen-agentchat": ["openinference-instrumentation-autogen-agentchat>=0.1.9"],
|
|
39
|
+
"autogen": ["openinference-instrumentation-autogen>=0.1.14"],
|
|
40
|
+
"bedrock": ["openinference-instrumentation-bedrock>=0.1.39"],
|
|
41
|
+
"claude-agent-sdk": ["openinference-instrumentation-claude-agent-sdk>=0.1.4"],
|
|
42
|
+
"dspy": ["openinference-instrumentation-dspy>=0.1.37"],
|
|
43
|
+
"google-adk": ["openinference-instrumentation-google-adk>=0.1.14"],
|
|
44
|
+
"google-genai": ["openinference-instrumentation-google-genai>=1.0.2"],
|
|
45
|
+
"groq": ["openinference-instrumentation-groq>=0.1.16"],
|
|
46
|
+
"guardrails": ["openinference-instrumentation-guardrails>=0.1.14"],
|
|
47
|
+
"mistralai": ["openinference-instrumentation-mistralai>=2.0.4"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Python import patterns that identify each framework.
|
|
51
|
+
# CLI scans .py files for these patterns to determine entry_names.
|
|
52
|
+
IMPORT_PATTERNS: dict[str, list[str]] = {
|
|
53
|
+
"langchain": ["langchain", "langgraph", "langchain_openai", "langchain_core"],
|
|
54
|
+
"crewai": ["crewai"],
|
|
55
|
+
"llama-index": ["llama_index"],
|
|
56
|
+
"openai": ["openai"],
|
|
57
|
+
"openai-agents": ["agents"],
|
|
58
|
+
"anthropic": ["anthropic"],
|
|
59
|
+
"beeai": ["beeai_framework", "beeai"],
|
|
60
|
+
"agno": ["agno"],
|
|
61
|
+
"autogen-agentchat": ["autogen_agentchat"],
|
|
62
|
+
"autogen": ["autogen"],
|
|
63
|
+
"bedrock": ["boto3", "botocore"],
|
|
64
|
+
"claude-agent-sdk": ["claude_agent_sdk"],
|
|
65
|
+
"dspy": ["dspy"],
|
|
66
|
+
"google-adk": ["google.adk", "google_adk"],
|
|
67
|
+
"google-genai": ["google.generativeai", "google_generativeai", "google.genai"],
|
|
68
|
+
"groq": ["groq"],
|
|
69
|
+
"guardrails": ["guardrails"],
|
|
70
|
+
"mistralai": ["mistralai"],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Base OTel packages always needed.
|
|
74
|
+
_OTEL_DEPS: list[str] = [
|
|
75
|
+
"opentelemetry-api>=1.28",
|
|
76
|
+
"opentelemetry-sdk>=1.28",
|
|
77
|
+
"opentelemetry-exporter-otlp-proto-http>=1.28",
|
|
78
|
+
"opentelemetry-semantic-conventions>=0.49b0",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# When a higher-level framework is active, suppress the lower-level openai
|
|
83
|
+
# instrumentor to avoid duplicate LLM spans.
|
|
84
|
+
# Key: entry name to suppress → set of entry names that trigger suppression.
|
|
85
|
+
_SUPPRESSED_BY: dict[str, list[str]] = {
|
|
86
|
+
# openai-agents SDK already traces LLM calls at the framework level;
|
|
87
|
+
# openai instrumentor on top produces duplicate ChatCompletion spans.
|
|
88
|
+
"openai": ["openai-agents"],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve(entry_names: list[str]) -> ResolveResult:
|
|
93
|
+
"""Given a list of framework names, return what to install.
|
|
94
|
+
|
|
95
|
+
Pure function — no side effects.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
entry_names: Frameworks detected in user code (e.g. ["langchain", "openai"])
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ResolveResult with entries (pass to setup) and required pip packages.
|
|
102
|
+
"""
|
|
103
|
+
if not entry_names:
|
|
104
|
+
return ResolveResult(entries=[], required_deps=[])
|
|
105
|
+
|
|
106
|
+
name_set = set(entry_names)
|
|
107
|
+
matched: list[str] = []
|
|
108
|
+
deps: list[str] = list(_OTEL_DEPS)
|
|
109
|
+
|
|
110
|
+
for name in entry_names:
|
|
111
|
+
if name not in _INSTRUMENTATION_DEPS:
|
|
112
|
+
continue
|
|
113
|
+
suppressors = _SUPPRESSED_BY.get(name, [])
|
|
114
|
+
if any(s in name_set for s in suppressors):
|
|
115
|
+
continue
|
|
116
|
+
matched.append(name)
|
|
117
|
+
deps.extend(_INSTRUMENTATION_DEPS[name])
|
|
118
|
+
|
|
119
|
+
return ResolveResult(entries=matched, required_deps=deps)
|