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.
Files changed (47) hide show
  1. package/README.md +26 -26
  2. package/edgeone-bin/edgeone.js +3 -3
  3. package/edgeone-dist/cli.js +86879 -2307
  4. package/edgeone-dist/libs-pages-agent-toolkit/README.md +8 -0
  5. package/edgeone-dist/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  6. package/edgeone-dist/libs-pages-blob-python/README.md +38 -0
  7. package/edgeone-dist/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
  8. package/edgeone-dist/pages/dev/runner-worker.js +86521 -2090
  9. package/edgeone-dist/pages/observability-python/__init__.py +32 -0
  10. package/edgeone-dist/pages/observability-python/_compat.py +69 -0
  11. package/edgeone-dist/pages/observability-python/apm/__init__.py +13 -0
  12. package/edgeone-dist/pages/observability-python/apm/config.py +85 -0
  13. package/edgeone-dist/pages/observability-python/apm/llm_semconv.py +53 -0
  14. package/edgeone-dist/pages/observability-python/apm/metrics_bridge.py +226 -0
  15. package/edgeone-dist/pages/observability-python/apm/span_exporter.py +384 -0
  16. package/edgeone-dist/pages/observability-python/bootstrap.py +158 -0
  17. package/edgeone-dist/pages/observability-python/build.py +119 -0
  18. package/edgeone-dist/pages/observability-python/context_patches.py +167 -0
  19. package/edgeone-dist/pages/observability-python/context_propagator.py +78 -0
  20. package/edgeone-dist/pages/observability-python/registry.json +95 -0
  21. package/edgeone-dist/pages/observability-python/registry.py +141 -0
  22. package/edgeone-dist/pages/observability-python/telemetry.py +214 -0
  23. package/edgeone-dist/pages/observability-python/tracer.py +165 -0
  24. package/edgeone-dist/pages/templates/agent-python/__init__.py +11 -0
  25. package/edgeone-dist/pages/templates/agent-python/adapter.py +908 -0
  26. package/edgeone-dist/pages/templates/agent-python/context.py +689 -0
  27. package/edgeone-dist/pages/templates/agent-python/local_blob_store.py +172 -0
  28. package/edgeone-dist/pages/templates/agent-python/memory.py +2301 -0
  29. package/edgeone-dist/pages/templates/agent-python/runtime.py +839 -0
  30. package/edgeone-dist/pages/templates/agent-python/store.py +204 -0
  31. package/edgeone-dist/studio/ui/assets/agent-obs-Dvi4IpEy.js +4 -0
  32. package/edgeone-dist/studio/ui/assets/agent-obs-qDJCE0TQ.css +1 -0
  33. package/edgeone-dist/studio/ui/assets/highlight-ClXAL37H.js +3 -0
  34. package/edgeone-dist/studio/ui/assets/index-Cz5oQnXW.css +1 -0
  35. package/edgeone-dist/studio/ui/assets/index-DD3d108t.js +1 -0
  36. package/edgeone-dist/studio/ui/assets/moment-BYRO94Ou.js +10 -0
  37. package/edgeone-dist/studio/ui/assets/react-dom-ZzBHVjtL.js +24 -0
  38. package/edgeone-dist/studio/ui/assets/react-hnpCyKql.js +17 -0
  39. package/edgeone-dist/studio/ui/assets/tea-CADagUwM.css +1 -0
  40. package/edgeone-dist/studio/ui/assets/tea-Slf_ajmf.js +334 -0
  41. package/edgeone-dist/studio/ui/favicon.ico +0 -0
  42. package/edgeone-dist/studio/ui/index.html +31 -0
  43. package/libs-pages-agent-toolkit/README.md +8 -0
  44. package/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  45. package/libs-pages-blob-python/README.md +38 -0
  46. package/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
  47. 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)