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,167 @@
|
|
|
1
|
+
"""Context propagation patches for OpenInference instrumentors.
|
|
2
|
+
|
|
3
|
+
Fixes two issues when multiple instrumentors coexist:
|
|
4
|
+
|
|
5
|
+
1. LangChain + OpenAI: OpenAI instrumentor's ChatCompletion span should be a
|
|
6
|
+
child of LangChain's ChatOpenAI span, but LangChain uses callbacks (not
|
|
7
|
+
ContextVar) for span management. Fix: patch OpenAI instrumentor to query
|
|
8
|
+
LangChain's get_current_span() before creating its own span.
|
|
9
|
+
|
|
10
|
+
2. CrewAI trace isolation: CrewAI's event bus fires events in a background
|
|
11
|
+
ThreadPoolExecutor thread. That thread has no OTel context from the main
|
|
12
|
+
asyncio task. Fix: patch CrewAI's EventAssembler to remember the ambient
|
|
13
|
+
context at construction time and use it as fallback when the background
|
|
14
|
+
thread's context is empty.
|
|
15
|
+
|
|
16
|
+
These patches are applied AFTER instrumentors are activated (since we need to
|
|
17
|
+
patch the already-instantiated objects) and are idempotent.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_context_patches() -> None:
|
|
28
|
+
"""Apply all context propagation patches. Safe to call multiple times."""
|
|
29
|
+
_patch_openai_langchain_bridge()
|
|
30
|
+
_patch_crewai_ambient_context()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _patch_openai_langchain_bridge() -> None:
|
|
34
|
+
"""Patch OpenAI instrumentor to use LangChain's active span as parent.
|
|
35
|
+
|
|
36
|
+
Without this, ChatCompletion spans from openai instrumentor land under
|
|
37
|
+
the root span instead of under LangChain's ChatOpenAI span.
|
|
38
|
+
|
|
39
|
+
Strategy: wrap _Request.__call__ and _AsyncRequest.__call__ to attach
|
|
40
|
+
the LangChain parent context before the original method runs (which
|
|
41
|
+
calls _start_as_current_span internally). This way the tracer.start_span()
|
|
42
|
+
inside naturally picks up the correct parent from the ambient context.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
from openinference.instrumentation.openai import _request as openai_req
|
|
46
|
+
except ImportError:
|
|
47
|
+
return # openai instrumentor not installed
|
|
48
|
+
|
|
49
|
+
# Check if already patched
|
|
50
|
+
if getattr(openai_req, "_eo_langchain_bridge_patched", False):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from opentelemetry import trace as trace_api
|
|
55
|
+
from opentelemetry import context as context_api
|
|
56
|
+
from opentelemetry.trace import INVALID_SPAN
|
|
57
|
+
|
|
58
|
+
def _get_langchain_parent_context() -> Optional[context_api.Context]:
|
|
59
|
+
"""Query LangChain instrumentor for the current active span."""
|
|
60
|
+
try:
|
|
61
|
+
from openinference.instrumentation.langchain import get_current_span
|
|
62
|
+
parent_span = get_current_span()
|
|
63
|
+
if parent_span is not None and parent_span is not INVALID_SPAN:
|
|
64
|
+
return trace_api.set_span_in_context(parent_span)
|
|
65
|
+
except ImportError:
|
|
66
|
+
pass
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Patch _Request.__call__: attach LangChain context before execution
|
|
72
|
+
_orig_request_call = openai_req._Request.__call__
|
|
73
|
+
|
|
74
|
+
def _patched_request_call(self, wrapped, instance, args, kwargs):
|
|
75
|
+
parent_ctx = _get_langchain_parent_context()
|
|
76
|
+
if parent_ctx is None:
|
|
77
|
+
return _orig_request_call(self, wrapped, instance, args, kwargs)
|
|
78
|
+
# Attach LangChain's span as ambient context so start_span picks it up
|
|
79
|
+
token = context_api.attach(parent_ctx)
|
|
80
|
+
try:
|
|
81
|
+
return _orig_request_call(self, wrapped, instance, args, kwargs)
|
|
82
|
+
finally:
|
|
83
|
+
context_api.detach(token)
|
|
84
|
+
|
|
85
|
+
# Patch _AsyncRequest.__call__
|
|
86
|
+
_orig_async_request_call = openai_req._AsyncRequest.__call__
|
|
87
|
+
|
|
88
|
+
async def _patched_async_request_call(self, wrapped, instance, args, kwargs):
|
|
89
|
+
parent_ctx = _get_langchain_parent_context()
|
|
90
|
+
if parent_ctx is None:
|
|
91
|
+
return await _orig_async_request_call(self, wrapped, instance, args, kwargs)
|
|
92
|
+
token = context_api.attach(parent_ctx)
|
|
93
|
+
try:
|
|
94
|
+
return await _orig_async_request_call(self, wrapped, instance, args, kwargs)
|
|
95
|
+
finally:
|
|
96
|
+
context_api.detach(token)
|
|
97
|
+
|
|
98
|
+
openai_req._Request.__call__ = _patched_request_call
|
|
99
|
+
openai_req._AsyncRequest.__call__ = _patched_async_request_call
|
|
100
|
+
openai_req._eo_langchain_bridge_patched = True
|
|
101
|
+
logger.debug("[observability] patched openai instrumentor with langchain bridge")
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.debug(f"[observability] langchain bridge patch failed: {e}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _patch_crewai_ambient_context() -> None:
|
|
107
|
+
"""Patch CrewAI's kickoff wrapper to inherit the request's OTel context.
|
|
108
|
+
|
|
109
|
+
Problem: CrewAI's crew.kickoff() runs in run_in_executor (thread pool).
|
|
110
|
+
Python's run_in_executor does NOT propagate OTel context to worker threads.
|
|
111
|
+
So the CrewAI instrumentor's wrapper calls start_as_current_span() but
|
|
112
|
+
gets an empty context — creating a new trace instead of joining the request's.
|
|
113
|
+
|
|
114
|
+
Fix: patch _CrewKickoffWrapper.__call__ to attach our stored request
|
|
115
|
+
context before the original logic runs.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
from openinference.instrumentation.crewai import _wrappers as wrappers_mod
|
|
119
|
+
except ImportError:
|
|
120
|
+
return # crewai instrumentor not installed
|
|
121
|
+
|
|
122
|
+
if getattr(wrappers_mod, "_eo_ambient_context_patched", False):
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
from opentelemetry import context as context_api
|
|
127
|
+
|
|
128
|
+
_CrewKickoffWrapper = getattr(wrappers_mod, "_CrewKickoffWrapper", None)
|
|
129
|
+
if _CrewKickoffWrapper is None:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
_orig_call = _CrewKickoffWrapper.__call__
|
|
133
|
+
|
|
134
|
+
def _patched_kickoff_call(self, wrapped, instance, args, kwargs):
|
|
135
|
+
from . import context_patches as _cp
|
|
136
|
+
captured = getattr(_cp, "_crewai_request_context", None)
|
|
137
|
+
if captured is not None:
|
|
138
|
+
token = context_api.attach(captured)
|
|
139
|
+
try:
|
|
140
|
+
return _orig_call(self, wrapped, instance, args, kwargs)
|
|
141
|
+
finally:
|
|
142
|
+
context_api.detach(token)
|
|
143
|
+
return _orig_call(self, wrapped, instance, args, kwargs)
|
|
144
|
+
|
|
145
|
+
_CrewKickoffWrapper.__call__ = _patched_kickoff_call
|
|
146
|
+
wrappers_mod._eo_ambient_context_patched = True
|
|
147
|
+
logger.debug("[observability] patched crewai kickoff wrapper with ambient context")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.debug(f"[observability] crewai ambient context patch failed: {e}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Module-level variable for adapter to set per-request context.
|
|
153
|
+
# The adapter sets this AFTER attaching the root span context,
|
|
154
|
+
# so CrewAI's background thread can read it.
|
|
155
|
+
_crewai_request_context: Optional["context_api.Context"] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def set_request_context(ctx) -> None:
|
|
159
|
+
"""Called by adapter after attaching root span context."""
|
|
160
|
+
global _crewai_request_context
|
|
161
|
+
_crewai_request_context = ctx
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def clear_request_context() -> None:
|
|
165
|
+
"""Called by adapter after request completes."""
|
|
166
|
+
global _crewai_request_context
|
|
167
|
+
_crewai_request_context = None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Agent context propagator — Python parallel of Node's AgentContextPropagator.
|
|
2
|
+
|
|
3
|
+
为每个 span 在 onStart 阶段统一注入:
|
|
4
|
+
- ``agent.run_id`` = 该 span 所属 trace 的 ``traceId``(与 traceId 对齐,
|
|
5
|
+
上报到 APM / langfuse 后体现为 ``langfuse.agent.run_id == traceId``)。
|
|
6
|
+
- ``agent.conversation_id`` = adapter 通过 ``set_agent_conversation_id`` 设置的会话 ID。
|
|
7
|
+
|
|
8
|
+
注意:业务层 ``ctx.run_id`` 仍由 runtime.py 用 ``uuid4()`` 生成,不受影响;
|
|
9
|
+
本 propagator 只影响上报到 OTel exporter 的 attribute。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from contextvars import ContextVar
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from opentelemetry.context import Context
|
|
17
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span
|
|
18
|
+
from opentelemetry.sdk.trace.export import SpanProcessor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 由 adapter 在 attach 完 root span context 之后立刻 set,handler 完成后 reset。
|
|
22
|
+
# 用 ContextVar 而不是模块全局,自然适配 asyncio 的 Task 隔离。
|
|
23
|
+
_conversation_id_var: ContextVar[Optional[str]] = ContextVar(
|
|
24
|
+
"_eo_agent_conversation_id", default=None
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_agent_conversation_id(conversation_id: Optional[str]):
|
|
29
|
+
"""Adapter 调用:设置当前请求的 conversation_id,返回 token 供 reset 用。
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ContextVar token; 调用方应在请求结束时执行
|
|
33
|
+
``_conversation_id_var.reset(token)``。也可直接调用
|
|
34
|
+
:func:`reset_agent_conversation_id` 传入该 token。
|
|
35
|
+
"""
|
|
36
|
+
return _conversation_id_var.set(conversation_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reset_agent_conversation_id(token) -> None:
|
|
40
|
+
try:
|
|
41
|
+
_conversation_id_var.reset(token)
|
|
42
|
+
except Exception:
|
|
43
|
+
# token 失效或跨 context 复位等异常一律忽略,best-effort 清理。
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentContextPropagator(SpanProcessor):
|
|
48
|
+
"""SpanProcessor that injects ``agent.run_id`` (= traceId) and
|
|
49
|
+
``agent.conversation_id`` into every span on start.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def on_start(
|
|
53
|
+
self, span: Span, parent_context: Optional[Context] = None
|
|
54
|
+
) -> None: # noqa: D401
|
|
55
|
+
try:
|
|
56
|
+
ctx = span.get_span_context()
|
|
57
|
+
# 16-byte trace id → 32 char hex,对齐 OTel/langfuse 显示格式。
|
|
58
|
+
trace_id_hex = format(ctx.trace_id, "032x")
|
|
59
|
+
if trace_id_hex and trace_id_hex != "0" * 32:
|
|
60
|
+
span.set_attribute("agent.run_id", trace_id_hex)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
conv_id = _conversation_id_var.get()
|
|
65
|
+
if conv_id:
|
|
66
|
+
try:
|
|
67
|
+
span.set_attribute("agent.conversation_id", conv_id)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def on_end(self, span: ReadableSpan) -> None: # noqa: D401
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None: # noqa: D401
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: D401
|
|
78
|
+
return True
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "langchain",
|
|
4
|
+
"importPatterns": ["langchain", "langgraph", "langchain_openai", "langchain_core"],
|
|
5
|
+
"pipPackages": ["openinference-instrumentation-langchain>=0.1.30"]
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "crewai",
|
|
9
|
+
"importPatterns": ["crewai"],
|
|
10
|
+
"pipPackages": [
|
|
11
|
+
"openinference-instrumentation-crewai>=0.1.8",
|
|
12
|
+
"openinference-instrumentation-litellm>=0.1"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "llama-index",
|
|
17
|
+
"importPatterns": ["llama_index"],
|
|
18
|
+
"pipPackages": ["openinference-instrumentation-llama-index>=4.0"]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "openai",
|
|
22
|
+
"importPatterns": ["openai"],
|
|
23
|
+
"pipPackages": ["openinference-instrumentation-openai>=0.1.15"]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "openai-agents",
|
|
27
|
+
"importPatterns": ["agents"],
|
|
28
|
+
"pipPackages": ["openinference-instrumentation-openai-agents>=1.5.0"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "anthropic",
|
|
32
|
+
"importPatterns": ["anthropic"],
|
|
33
|
+
"pipPackages": ["openinference-instrumentation-anthropic>=1.0.5"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "beeai",
|
|
37
|
+
"importPatterns": ["beeai_framework", "beeai"],
|
|
38
|
+
"pipPackages": ["openinference-instrumentation-beeai>=0.1.19"]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "agno",
|
|
42
|
+
"importPatterns": ["agno"],
|
|
43
|
+
"pipPackages": ["openinference-instrumentation-agno>=0.1.34"]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "autogen-agentchat",
|
|
47
|
+
"importPatterns": ["autogen_agentchat"],
|
|
48
|
+
"pipPackages": ["openinference-instrumentation-autogen-agentchat>=0.1.9"]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "autogen",
|
|
52
|
+
"importPatterns": ["autogen"],
|
|
53
|
+
"pipPackages": ["openinference-instrumentation-autogen>=0.1.14"]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "bedrock",
|
|
57
|
+
"importPatterns": ["boto3", "botocore"],
|
|
58
|
+
"pipPackages": ["openinference-instrumentation-bedrock>=0.1.39"]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "claude-agent-sdk",
|
|
62
|
+
"importPatterns": ["claude_agent_sdk"],
|
|
63
|
+
"pipPackages": ["openinference-instrumentation-claude-agent-sdk>=0.1.4"]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "dspy",
|
|
67
|
+
"importPatterns": ["dspy"],
|
|
68
|
+
"pipPackages": ["openinference-instrumentation-dspy>=0.1.37"]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "google-adk",
|
|
72
|
+
"importPatterns": ["google.adk", "google_adk"],
|
|
73
|
+
"pipPackages": ["openinference-instrumentation-google-adk>=0.1.14"]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "google-genai",
|
|
77
|
+
"importPatterns": ["google.generativeai", "google_generativeai", "google.genai"],
|
|
78
|
+
"pipPackages": ["openinference-instrumentation-google-genai>=1.0.2"]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "groq",
|
|
82
|
+
"importPatterns": ["groq"],
|
|
83
|
+
"pipPackages": ["openinference-instrumentation-groq>=0.1.16"]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "guardrails",
|
|
87
|
+
"importPatterns": ["guardrails"],
|
|
88
|
+
"pipPackages": ["openinference-instrumentation-guardrails>=0.1.14"]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "mistralai",
|
|
92
|
+
"importPatterns": ["mistralai"],
|
|
93
|
+
"pipPackages": ["openinference-instrumentation-mistralai>=2.0.4"]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Framework → OpenInference instrumentor mapping."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Entry:
|
|
10
|
+
name: str
|
|
11
|
+
instrumentor_factories: tuple[Callable[[], object], ...]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _langchain_instrumentor() -> object:
|
|
15
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
|
16
|
+
instrumentor = LangChainInstrumentor()
|
|
17
|
+
# Patch missing callback methods that newer LangChain versions call.
|
|
18
|
+
# openinference-instrumentation-langchain may not have implemented these yet.
|
|
19
|
+
_tracer_cls = None
|
|
20
|
+
try:
|
|
21
|
+
from openinference.instrumentation.langchain._tracer import OpenInferenceTracer
|
|
22
|
+
_tracer_cls = OpenInferenceTracer
|
|
23
|
+
except ImportError:
|
|
24
|
+
pass
|
|
25
|
+
if _tracer_cls is not None:
|
|
26
|
+
for method in ("on_resume", "on_interrupt"):
|
|
27
|
+
if not hasattr(_tracer_cls, method):
|
|
28
|
+
setattr(_tracer_cls, method, lambda self, *a, **kw: None)
|
|
29
|
+
return instrumentor
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _crewai_instrumentor() -> object:
|
|
33
|
+
from openinference.instrumentation.crewai import CrewAIInstrumentor
|
|
34
|
+
return CrewAIInstrumentor()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _litellm_instrumentor() -> object:
|
|
38
|
+
from openinference.instrumentation.litellm import LiteLLMInstrumentor
|
|
39
|
+
return LiteLLMInstrumentor()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _llama_index_instrumentor() -> object:
|
|
43
|
+
from openinference.instrumentation.llama_index import LlamaIndexInstrumentor
|
|
44
|
+
return LlamaIndexInstrumentor()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _openai_instrumentor() -> object:
|
|
48
|
+
from openinference.instrumentation.openai import OpenAIInstrumentor
|
|
49
|
+
return OpenAIInstrumentor()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _openai_agents_instrumentor() -> object:
|
|
53
|
+
from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
|
|
54
|
+
return OpenAIAgentsInstrumentor()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _anthropic_instrumentor() -> object:
|
|
58
|
+
from openinference.instrumentation.anthropic import AnthropicInstrumentor
|
|
59
|
+
return AnthropicInstrumentor()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _beeai_instrumentor() -> object:
|
|
63
|
+
from openinference.instrumentation.beeai import BeeAIInstrumentor
|
|
64
|
+
return BeeAIInstrumentor()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _agno_instrumentor() -> object:
|
|
68
|
+
from openinference.instrumentation.agno import AgnoInstrumentor
|
|
69
|
+
return AgnoInstrumentor()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _autogen_agentchat_instrumentor() -> object:
|
|
73
|
+
from openinference.instrumentation.autogen_agentchat import AutoGenAgentChatInstrumentor
|
|
74
|
+
return AutoGenAgentChatInstrumentor()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _autogen_instrumentor() -> object:
|
|
78
|
+
from openinference.instrumentation.autogen import AutoGenInstrumentor
|
|
79
|
+
return AutoGenInstrumentor()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bedrock_instrumentor() -> object:
|
|
83
|
+
from openinference.instrumentation.bedrock import BedrockInstrumentor
|
|
84
|
+
return BedrockInstrumentor()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _claude_agent_sdk_instrumentor() -> object:
|
|
88
|
+
from openinference.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor
|
|
89
|
+
return ClaudeAgentSDKInstrumentor()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dspy_instrumentor() -> object:
|
|
93
|
+
from openinference.instrumentation.dspy import DSPyInstrumentor
|
|
94
|
+
return DSPyInstrumentor()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _google_adk_instrumentor() -> object:
|
|
98
|
+
from openinference.instrumentation.google_adk import GoogleADKInstrumentor
|
|
99
|
+
return GoogleADKInstrumentor()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _google_genai_instrumentor() -> object:
|
|
103
|
+
from openinference.instrumentation.google_genai import GoogleGenAIInstrumentor
|
|
104
|
+
return GoogleGenAIInstrumentor()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _groq_instrumentor() -> object:
|
|
108
|
+
from openinference.instrumentation.groq import GroqInstrumentor
|
|
109
|
+
return GroqInstrumentor()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _guardrails_instrumentor() -> object:
|
|
113
|
+
from openinference.instrumentation.guardrails import GuardrailsInstrumentor
|
|
114
|
+
return GuardrailsInstrumentor()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mistralai_instrumentor() -> object:
|
|
118
|
+
from openinference.instrumentation.mistralai import MistralAIInstrumentor
|
|
119
|
+
return MistralAIInstrumentor()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
REGISTRY: tuple[Entry, ...] = (
|
|
123
|
+
Entry(name="langchain", instrumentor_factories=(_langchain_instrumentor,)),
|
|
124
|
+
Entry(name="crewai", instrumentor_factories=(_crewai_instrumentor, _litellm_instrumentor)),
|
|
125
|
+
Entry(name="llama-index", instrumentor_factories=(_llama_index_instrumentor,)),
|
|
126
|
+
Entry(name="openai", instrumentor_factories=(_openai_instrumentor,)),
|
|
127
|
+
Entry(name="openai-agents", instrumentor_factories=(_openai_agents_instrumentor,)),
|
|
128
|
+
Entry(name="anthropic", instrumentor_factories=(_anthropic_instrumentor,)),
|
|
129
|
+
Entry(name="beeai", instrumentor_factories=(_beeai_instrumentor,)),
|
|
130
|
+
Entry(name="agno", instrumentor_factories=(_agno_instrumentor,)),
|
|
131
|
+
Entry(name="autogen-agentchat", instrumentor_factories=(_autogen_agentchat_instrumentor,)),
|
|
132
|
+
Entry(name="autogen", instrumentor_factories=(_autogen_instrumentor,)),
|
|
133
|
+
Entry(name="bedrock", instrumentor_factories=(_bedrock_instrumentor,)),
|
|
134
|
+
Entry(name="claude-agent-sdk", instrumentor_factories=(_claude_agent_sdk_instrumentor,)),
|
|
135
|
+
Entry(name="dspy", instrumentor_factories=(_dspy_instrumentor,)),
|
|
136
|
+
Entry(name="google-adk", instrumentor_factories=(_google_adk_instrumentor,)),
|
|
137
|
+
Entry(name="google-genai", instrumentor_factories=(_google_genai_instrumentor,)),
|
|
138
|
+
Entry(name="groq", instrumentor_factories=(_groq_instrumentor,)),
|
|
139
|
+
Entry(name="guardrails", instrumentor_factories=(_guardrails_instrumentor,)),
|
|
140
|
+
Entry(name="mistralai", instrumentor_factories=(_mistralai_instrumentor,)),
|
|
141
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Telemetry initialization — TracerProvider + APM exporter setup."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from opentelemetry import trace as otel_trace
|
|
9
|
+
from opentelemetry.context import Context, get_current
|
|
10
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
11
|
+
from opentelemetry.sdk.resources import Resource
|
|
12
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
13
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
14
|
+
from opentelemetry.trace import Span, Tracer, set_span_in_context
|
|
15
|
+
|
|
16
|
+
from .apm.config import resolve_apm_endpoint, resolve_service_name
|
|
17
|
+
from .apm.metrics_bridge import ApmMetricsBridge
|
|
18
|
+
from .apm.span_exporter import ApmSpanExporter, TranslatorContext
|
|
19
|
+
from ._compat import apply_compat_patches
|
|
20
|
+
from .context_propagator import AgentContextPropagator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Telemetry:
|
|
25
|
+
tracer_provider: TracerProvider
|
|
26
|
+
tracer: Tracer
|
|
27
|
+
metrics_bridge: ApmMetricsBridge
|
|
28
|
+
_root_span_ids: set = field(default_factory=set)
|
|
29
|
+
_root_spans_with_children: set = field(default_factory=set)
|
|
30
|
+
|
|
31
|
+
def start_request_span(
|
|
32
|
+
self, name: str, attributes: dict[str, Any] | None = None
|
|
33
|
+
) -> tuple[Span, Context]:
|
|
34
|
+
span = self.tracer.start_span(name, attributes=attributes or {})
|
|
35
|
+
self._root_span_ids.add(format(span.get_span_context().span_id, "016x"))
|
|
36
|
+
ctx = set_span_in_context(span, get_current())
|
|
37
|
+
return span, ctx
|
|
38
|
+
|
|
39
|
+
def end_request_span(self, span: Span) -> None:
|
|
40
|
+
span_id = format(span.get_span_context().span_id, "016x")
|
|
41
|
+
span.end()
|
|
42
|
+
# Flush pending spans BEFORE removing the root span ID.
|
|
43
|
+
# BatchSpanProcessor exports asynchronously — child spans queued
|
|
44
|
+
# earlier need the root ID still present when _is_trace_entry runs
|
|
45
|
+
# inside the ApmSpanExporter so they get gen_ai.is_entry=true.
|
|
46
|
+
try:
|
|
47
|
+
self.tracer_provider.force_flush(timeout_millis=5000)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
self._root_span_ids.discard(span_id)
|
|
51
|
+
self._root_spans_with_children.discard(span_id)
|
|
52
|
+
|
|
53
|
+
def shutdown(self) -> None:
|
|
54
|
+
self._root_span_ids.clear()
|
|
55
|
+
self._root_spans_with_children.clear()
|
|
56
|
+
try:
|
|
57
|
+
self.metrics_bridge.shutdown()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
try:
|
|
61
|
+
self.tracer_provider.shutdown()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_telemetry: Telemetry | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def setup_telemetry(
|
|
70
|
+
*,
|
|
71
|
+
tracer_provider: TracerProvider | None = None,
|
|
72
|
+
endpoint: str | None = None,
|
|
73
|
+
service_name: str | None = None,
|
|
74
|
+
apm_token: str | None = None,
|
|
75
|
+
) -> Telemetry:
|
|
76
|
+
"""Initialize telemetry. Called once at process startup.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tracer_provider: Override for testing (InMemoryExporter).
|
|
80
|
+
If None, creates production provider with APM exporter.
|
|
81
|
+
"""
|
|
82
|
+
global _telemetry
|
|
83
|
+
if _telemetry is not None:
|
|
84
|
+
return _telemetry
|
|
85
|
+
|
|
86
|
+
apply_compat_patches()
|
|
87
|
+
|
|
88
|
+
endpoint = endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or resolve_apm_endpoint()
|
|
89
|
+
service_name = service_name or resolve_service_name()
|
|
90
|
+
environment = os.environ.get("NODE_ENV") or "development"
|
|
91
|
+
apm_token = apm_token or os.environ.get("SVC_TRACE_KEY_LB6V03", "")
|
|
92
|
+
|
|
93
|
+
# Dev dual-export: optionally send to remote APM alongside local Studio.
|
|
94
|
+
dev_apm_token = os.environ.get("EDGEONE_DEV_APM_TOKEN", "")
|
|
95
|
+
dev_apm_endpoint = os.environ.get("EDGEONE_DEV_APM_ENDPOINT") or resolve_apm_endpoint()
|
|
96
|
+
|
|
97
|
+
# 使用 Resource.create() 以保留 SDK 默认属性(telemetry.sdk.language 等),
|
|
98
|
+
# 否则 inferRuntime() 无法识别 Python runtime,会误判为 agent-node。
|
|
99
|
+
# Resource.create() 会自动合并 OTEL_RESOURCE_ATTRIBUTES 和 SDK 默认属性。
|
|
100
|
+
resource = Resource.create({
|
|
101
|
+
"service.name": service_name,
|
|
102
|
+
"deployment.environment": environment,
|
|
103
|
+
"token": dev_apm_token or apm_token,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
root_span_ids: set[str] = set()
|
|
107
|
+
root_spans_with_children: set[str] = set()
|
|
108
|
+
|
|
109
|
+
if tracer_provider is None:
|
|
110
|
+
translator_ctx = TranslatorContext(
|
|
111
|
+
is_root_span_id=lambda sid: sid in root_span_ids,
|
|
112
|
+
root_span_has_children=lambda sid: sid in root_spans_with_children,
|
|
113
|
+
)
|
|
114
|
+
raw_exporter = OTLPSpanExporter(endpoint=f"{endpoint.rstrip('/')}/v1/traces")
|
|
115
|
+
wrapped_exporter = ApmSpanExporter(raw_exporter, translator_ctx)
|
|
116
|
+
|
|
117
|
+
tracer_provider = TracerProvider(resource=resource)
|
|
118
|
+
tracer_provider.add_span_processor(BatchSpanProcessor(wrapped_exporter))
|
|
119
|
+
|
|
120
|
+
# Dev APM dual-export processor (if EDGEONE_DEV_APM_TOKEN is set)
|
|
121
|
+
if dev_apm_token:
|
|
122
|
+
dev_apm_raw = OTLPSpanExporter(endpoint=f"{dev_apm_endpoint.rstrip('/')}/v1/traces")
|
|
123
|
+
dev_apm_wrapped = ApmSpanExporter(dev_apm_raw, translator_ctx)
|
|
124
|
+
tracer_provider.add_span_processor(BatchSpanProcessor(dev_apm_wrapped))
|
|
125
|
+
print(f"[observability] APM dual-export enabled → {dev_apm_endpoint}")
|
|
126
|
+
|
|
127
|
+
metrics_bridge = ApmMetricsBridge(
|
|
128
|
+
endpoint=dev_apm_endpoint if dev_apm_token else endpoint,
|
|
129
|
+
apm_token=dev_apm_token or apm_token,
|
|
130
|
+
resource=resource,
|
|
131
|
+
)
|
|
132
|
+
tracer_provider.add_span_processor(metrics_bridge.span_processor)
|
|
133
|
+
|
|
134
|
+
# Inject agent.run_id (= traceId) and agent.conversation_id into every span.
|
|
135
|
+
# Mirrors Node's AgentContextPropagator so langfuse.agent.run_id aligns with traceId.
|
|
136
|
+
tracer_provider.add_span_processor(AgentContextPropagator())
|
|
137
|
+
|
|
138
|
+
# Track which root spans have children (for gen_ai.is_entry on root span)
|
|
139
|
+
tracer_provider.add_span_processor(
|
|
140
|
+
_RootChildrenTracker(root_span_ids, root_spans_with_children)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
otel_trace.set_tracer_provider(tracer_provider)
|
|
144
|
+
else:
|
|
145
|
+
# Test mode — no APM exporter, no metrics bridge, don't touch global state
|
|
146
|
+
metrics_bridge = _NoOpMetricsBridge()
|
|
147
|
+
# 测试模式下也注册 propagator,让 InMemoryExporter 能验证注入行为。
|
|
148
|
+
try:
|
|
149
|
+
tracer_provider.add_span_processor(AgentContextPropagator())
|
|
150
|
+
except Exception:
|
|
151
|
+
# 某些测试 provider 可能不支持重复添加,忽略即可。
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
tracer = tracer_provider.get_tracer("edgeone-agent-observability", "0.1.0")
|
|
155
|
+
|
|
156
|
+
_telemetry = Telemetry(
|
|
157
|
+
tracer_provider=tracer_provider,
|
|
158
|
+
tracer=tracer,
|
|
159
|
+
metrics_bridge=metrics_bridge,
|
|
160
|
+
_root_span_ids=root_span_ids,
|
|
161
|
+
_root_spans_with_children=root_spans_with_children,
|
|
162
|
+
)
|
|
163
|
+
return _telemetry
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_telemetry() -> Telemetry | None:
|
|
167
|
+
return _telemetry
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def reset_telemetry() -> None:
|
|
171
|
+
"""Reset global state. For testing only."""
|
|
172
|
+
global _telemetry
|
|
173
|
+
_telemetry = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _NoOpMetricsBridge:
|
|
177
|
+
"""Stub for test mode."""
|
|
178
|
+
@property
|
|
179
|
+
def span_processor(self):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def shutdown(self) -> None:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _RootChildrenTracker:
|
|
187
|
+
"""SpanProcessor that marks root spans as having children when a child starts."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, root_span_ids: set[str], root_spans_with_children: set[str]) -> None:
|
|
190
|
+
self._root_span_ids = root_span_ids
|
|
191
|
+
self._root_spans_with_children = root_spans_with_children
|
|
192
|
+
|
|
193
|
+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
|
|
194
|
+
parent = span.parent if hasattr(span, "parent") else None
|
|
195
|
+
if parent is None:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
parent_id = format(parent.span_id, "016x")
|
|
199
|
+
except (AttributeError, TypeError):
|
|
200
|
+
return
|
|
201
|
+
if parent_id in self._root_span_ids:
|
|
202
|
+
self._root_spans_with_children.add(parent_id)
|
|
203
|
+
|
|
204
|
+
def _on_ending(self, span) -> None:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
def on_end(self, span) -> None:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def shutdown(self) -> None:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
214
|
+
return True
|