edgeone 1.5.9 → 1.6.1
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 +86890 -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.12.0-py3-none-any.whl +0 -0
- package/edgeone-dist/pages/dev/runner-worker.js +86532 -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.12.0-py3-none-any.whl +0 -0
- package/package.json +33 -7
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""EdgeOne Agent Python Observability.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
setup(entry_names) → AgentTracer
|
|
5
|
+
get_tracer() → AgentTracer | None
|
|
6
|
+
start_request_span(name) → (Span, Context)
|
|
7
|
+
end_request_span(span) → None
|
|
8
|
+
shutdown() → None
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .bootstrap import setup, get_tracer, start_request_span, end_request_span, shutdown
|
|
13
|
+
from .tracer import AgentTracer, NoOpTracer
|
|
14
|
+
from .context_patches import set_request_context, clear_request_context
|
|
15
|
+
from .context_propagator import (
|
|
16
|
+
set_agent_conversation_id,
|
|
17
|
+
reset_agent_conversation_id,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"setup",
|
|
22
|
+
"get_tracer",
|
|
23
|
+
"start_request_span",
|
|
24
|
+
"end_request_span",
|
|
25
|
+
"shutdown",
|
|
26
|
+
"AgentTracer",
|
|
27
|
+
"NoOpTracer",
|
|
28
|
+
"set_request_context",
|
|
29
|
+
"clear_request_context",
|
|
30
|
+
"set_agent_conversation_id",
|
|
31
|
+
"reset_agent_conversation_id",
|
|
32
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""OTel SDK compatibility patches.
|
|
2
|
+
|
|
3
|
+
- flags patch: Force span flags=0 for Tencent APM parent_span_id detection.
|
|
4
|
+
- detach patch: Suppress ValueError when detaching tokens across async contexts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_compat_patches() -> None:
|
|
10
|
+
"""Apply OTel SDK compatibility patches. Call before any span export."""
|
|
11
|
+
_patch_span_flags()
|
|
12
|
+
_patch_context_detach()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _patch_span_flags() -> None:
|
|
16
|
+
"""Force OTLP encoder to emit flags=0.
|
|
17
|
+
|
|
18
|
+
Python OTel SDK 1.27+ encodes non-zero flags on OTLP spans.
|
|
19
|
+
Tencent APM silently drops parent_span_id when flags != 0.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
from opentelemetry.exporter.otlp.proto.common._internal import (
|
|
23
|
+
trace_encoder as _enc,
|
|
24
|
+
)
|
|
25
|
+
_enc._span_flags = lambda _parent_span_context: 0 # type: ignore[assignment]
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _patch_context_detach() -> None:
|
|
31
|
+
"""Make context.detach() tolerant of cross-context token resets.
|
|
32
|
+
|
|
33
|
+
Problem: OpenInference instrumentors wrap async generators and call
|
|
34
|
+
context_api.detach(token) in their finally block. When the generator
|
|
35
|
+
is closed via aclose() from a different async task (e.g. adapter's
|
|
36
|
+
streaming loop cancels the generator on client disconnect), the
|
|
37
|
+
ContextVar.reset(token) raises ValueError because the token was
|
|
38
|
+
created in a different execution context.
|
|
39
|
+
|
|
40
|
+
This is harmless — the context will be garbage-collected anyway —
|
|
41
|
+
but produces noisy "Failed to detach context" tracebacks in logs.
|
|
42
|
+
|
|
43
|
+
Fix: patch ContextVarsRuntimeContext.detach to suppress the ValueError.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
from opentelemetry.context.contextvars_context import (
|
|
47
|
+
ContextVarsRuntimeContext,
|
|
48
|
+
)
|
|
49
|
+
except ImportError:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if getattr(ContextVarsRuntimeContext, "_eo_detach_patched", False):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
_orig_detach = ContextVarsRuntimeContext.detach
|
|
56
|
+
|
|
57
|
+
def _safe_detach(self, token): # type: ignore[no-untyped-def]
|
|
58
|
+
try:
|
|
59
|
+
return _orig_detach(self, token)
|
|
60
|
+
except (ValueError, RuntimeError):
|
|
61
|
+
# ValueError: token was created in a different Context — happens
|
|
62
|
+
# when an async generator is closed from a different task.
|
|
63
|
+
# RuntimeError: token has already been used — happens when detach
|
|
64
|
+
# is called twice (e.g. generator finally + outer finally).
|
|
65
|
+
# Both are harmless cleanup failures, suppress silently.
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
ContextVarsRuntimeContext.detach = _safe_detach # type: ignore[assignment]
|
|
69
|
+
ContextVarsRuntimeContext._eo_detach_patched = True # type: ignore[attr-defined]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""EdgeOne Agent Observability — APM adapter layer."""
|
|
2
|
+
from .config import resolve_apm_endpoint, resolve_service_name, apm_identity_attrs
|
|
3
|
+
from .span_exporter import ApmSpanExporter, TranslatorContext
|
|
4
|
+
from .metrics_bridge import ApmMetricsBridge
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"resolve_apm_endpoint",
|
|
8
|
+
"resolve_service_name",
|
|
9
|
+
"apm_identity_attrs",
|
|
10
|
+
"ApmSpanExporter",
|
|
11
|
+
"TranslatorContext",
|
|
12
|
+
"ApmMetricsBridge",
|
|
13
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Pinned runtime constants for the APM adapter layer.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node-side config (src/agent/observability/apm/config.ts) and
|
|
4
|
+
the demo (frameworks-py/src/runner/config.py). Keep in lockstep.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --- APM endpoint resolution (same env protocol as Node) ---
|
|
15
|
+
|
|
16
|
+
APM_ENDPOINTS: dict[str, str] = {
|
|
17
|
+
"ap-beijing": "http://ap-beijing.apm.tencentcs.com:55681",
|
|
18
|
+
"ap-singapore": "http://ap-singapore.apm.tencentcs.com:55681",
|
|
19
|
+
}
|
|
20
|
+
DEFAULT_REGION = "ap-singapore"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_apm_endpoint() -> str:
|
|
24
|
+
"""Resolve APM endpoint from TENCENTCLOUD_REGION env var."""
|
|
25
|
+
region = os.environ.get("TENCENTCLOUD_REGION", DEFAULT_REGION)
|
|
26
|
+
return APM_ENDPOINTS.get(region, APM_ENDPOINTS[DEFAULT_REGION])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_cached_dev_service_name: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_service_name() -> str:
|
|
33
|
+
"""Resolve service_name: {projectId}-{deploymentId}, fallback to dir+datetime."""
|
|
34
|
+
project_id = os.environ.get("PAGES_PROJECT_ID", "")
|
|
35
|
+
deployment_id = os.environ.get("PAGES_DEPLOYMENT_ID", "")
|
|
36
|
+
if project_id and deployment_id:
|
|
37
|
+
return f"{project_id}-{deployment_id}"
|
|
38
|
+
if os.environ.get("OTEL_SERVICE_NAME"):
|
|
39
|
+
return os.environ["OTEL_SERVICE_NAME"]
|
|
40
|
+
# Dev mode: parent process (agent-observability) generates a unified
|
|
41
|
+
# service name via EDGEONE_DEV_SERVICE_NAME so that Node.js and Python
|
|
42
|
+
# runtimes share the same name on APM dual-export.
|
|
43
|
+
if os.environ.get("EDGEONE_DEV_SERVICE_NAME"):
|
|
44
|
+
return os.environ["EDGEONE_DEV_SERVICE_NAME"]
|
|
45
|
+
# Fallback dev mode: project name + datetime (cached per process).
|
|
46
|
+
global _cached_dev_service_name
|
|
47
|
+
if _cached_dev_service_name is None:
|
|
48
|
+
basename = os.path.basename(os.path.dirname(os.path.dirname(os.getcwd()))) or "agent"
|
|
49
|
+
date_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
50
|
+
_cached_dev_service_name = f"{basename}-{date_str}"
|
|
51
|
+
return _cached_dev_service_name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- Pinned constants (must match Node side exactly) ---
|
|
55
|
+
|
|
56
|
+
MIN_LLM_CALL_DURATION_MS = 50
|
|
57
|
+
|
|
58
|
+
METRIC_EXPORT_INTERVAL_MS = 300_000
|
|
59
|
+
METRIC_EXPORT_TIMEOUT_MS = 30_000
|
|
60
|
+
|
|
61
|
+
APM_IDENTITY_LIBRARY_NAME = "langfuse-sdk"
|
|
62
|
+
APM_IDENTITY_LIBRARY_VERSION = "4.3.1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- Identity attrs ---
|
|
66
|
+
|
|
67
|
+
def _local_ip() -> str:
|
|
68
|
+
try:
|
|
69
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
70
|
+
s.connect(("8.8.8.8", 80))
|
|
71
|
+
return s.getsockname()[0]
|
|
72
|
+
except OSError:
|
|
73
|
+
return "127.0.0.1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apm_identity_attrs() -> Mapping[str, str]:
|
|
77
|
+
"""Identity fields APM expects on recognised LLM services."""
|
|
78
|
+
hostname = socket.gethostname()
|
|
79
|
+
return {
|
|
80
|
+
"instrumentation.library.name": APM_IDENTITY_LIBRARY_NAME,
|
|
81
|
+
"instrumentation.library.version": APM_IDENTITY_LIBRARY_VERSION,
|
|
82
|
+
"host.name": hostname,
|
|
83
|
+
"ip": _local_ip(),
|
|
84
|
+
"service.instance": hostname,
|
|
85
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""OpenInference <-> OpenLLMetry semantic translation.
|
|
2
|
+
|
|
3
|
+
Mirrors frameworks-py/src/runner/llm_semconv.py exactly.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
OI_LLM_KINDS: tuple[str, ...] = (
|
|
11
|
+
"LLM",
|
|
12
|
+
"CHAIN",
|
|
13
|
+
"AGENT",
|
|
14
|
+
"TOOL",
|
|
15
|
+
"EMBEDDING",
|
|
16
|
+
"RETRIEVER",
|
|
17
|
+
"RERANKER",
|
|
18
|
+
)
|
|
19
|
+
OI_LLM_KIND_SET: frozenset[str] = frozenset(OI_LLM_KINDS)
|
|
20
|
+
|
|
21
|
+
OI_LLM_LEAF_KIND_SET: frozenset[str] = frozenset({"LLM"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def oi_kind_to_operation(kind: str) -> Literal["chat", "embedding", "rerank"]:
|
|
25
|
+
if kind == "EMBEDDING":
|
|
26
|
+
return "embedding"
|
|
27
|
+
if kind == "RERANKER":
|
|
28
|
+
return "rerank"
|
|
29
|
+
return "chat"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def oi_kind_to_genai_span_kind(kind: str) -> str:
|
|
33
|
+
return "generation" if kind == "LLM" else kind.lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Attribute mirror: (dest_key, src_key, coerce)
|
|
37
|
+
OI_TO_GENAI_MIRROR: tuple[tuple[str, str, str | None], ...] = (
|
|
38
|
+
("gen_ai.usage.prompt_tokens", "llm.token_count.prompt", "number"),
|
|
39
|
+
("gen_ai.usage.completion_tokens", "llm.token_count.completion", "number"),
|
|
40
|
+
("llm.usage.total_tokens", "llm.token_count.total", "number"),
|
|
41
|
+
("gen_ai.usage.input_tokens", "llm.token_count.prompt", "number"),
|
|
42
|
+
("gen_ai.usage.output_tokens", "llm.token_count.completion", "number"),
|
|
43
|
+
("gen_ai.usage.total_tokens", "llm.token_count.total", "number"),
|
|
44
|
+
(
|
|
45
|
+
"gen_ai.usage.cache_read.input_tokens",
|
|
46
|
+
"llm.token_count.prompt_details.cache_read",
|
|
47
|
+
"number",
|
|
48
|
+
),
|
|
49
|
+
("gen_ai.request.model", "llm.model_name", None),
|
|
50
|
+
("gen_ai.response.model", "llm.model_name", None),
|
|
51
|
+
("gen_ai.system", "llm.provider", None),
|
|
52
|
+
("gen_ai.provider.name", "llm.provider", None),
|
|
53
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""APM Metrics bridge — drives gen_ai.client.operation.duration histogram.
|
|
2
|
+
|
|
3
|
+
Mirrors frameworks-py/src/runner/apm_metrics_bridge.py.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry.context import Context
|
|
10
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
11
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
12
|
+
from opentelemetry.sdk.metrics.export import (
|
|
13
|
+
AggregationTemporality,
|
|
14
|
+
PeriodicExportingMetricReader,
|
|
15
|
+
)
|
|
16
|
+
from opentelemetry.sdk.metrics._internal.instrument import (
|
|
17
|
+
Counter,
|
|
18
|
+
Histogram,
|
|
19
|
+
ObservableCounter,
|
|
20
|
+
ObservableGauge,
|
|
21
|
+
ObservableUpDownCounter,
|
|
22
|
+
UpDownCounter,
|
|
23
|
+
)
|
|
24
|
+
from opentelemetry.sdk.resources import Resource
|
|
25
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span
|
|
26
|
+
from opentelemetry.sdk.trace.export import SpanProcessor
|
|
27
|
+
from opentelemetry.trace.status import StatusCode
|
|
28
|
+
|
|
29
|
+
from .config import (
|
|
30
|
+
METRIC_EXPORT_INTERVAL_MS,
|
|
31
|
+
METRIC_EXPORT_TIMEOUT_MS,
|
|
32
|
+
MIN_LLM_CALL_DURATION_MS,
|
|
33
|
+
)
|
|
34
|
+
from .llm_semconv import (
|
|
35
|
+
OI_LLM_KIND_SET,
|
|
36
|
+
OI_LLM_LEAF_KIND_SET,
|
|
37
|
+
oi_kind_to_operation,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 与 span_exporter._INTERRUPT_EXCEPTION_NAMES 保持一致:LangGraph 控制流异常名,
|
|
41
|
+
# 外加 interrupt 触发后流拆除 / 取消的 teardown 信号 GeneratorExit / CancelledError。
|
|
42
|
+
_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
43
|
+
"GraphInterrupt",
|
|
44
|
+
"NodeInterrupt",
|
|
45
|
+
"ParentCommand",
|
|
46
|
+
"GeneratorExit",
|
|
47
|
+
"CancelledError",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
# 与 span_exporter._CREWAI_INTERRUPT_EXCEPTION_NAMES 保持一致:CrewAI @human_feedback
|
|
51
|
+
# 暂停信号 HumanFeedbackPending,非业务错误,仅在 crewai instrumentation scope 内归一,
|
|
52
|
+
# 避免把人工反馈暂停计入 LLM 错误指标。
|
|
53
|
+
_CREWAI_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
54
|
+
"HumanFeedbackPending",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_crewai_scope(span: ReadableSpan) -> bool:
|
|
59
|
+
scope = getattr(span, "instrumentation_scope", None)
|
|
60
|
+
name = getattr(scope, "name", None)
|
|
61
|
+
return isinstance(name, str) and "crewai" in name.lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _iter_exception_type_names(span: ReadableSpan):
|
|
65
|
+
attrs = span.attributes or {}
|
|
66
|
+
for key in ("exception.type", "error.type"):
|
|
67
|
+
v = attrs.get(key)
|
|
68
|
+
if isinstance(v, str) and v:
|
|
69
|
+
yield v.rsplit(".", 1)[-1]
|
|
70
|
+
for ev in getattr(span, "events", None) or ():
|
|
71
|
+
if getattr(ev, "name", None) != "exception":
|
|
72
|
+
continue
|
|
73
|
+
ev_attrs = getattr(ev, "attributes", None) or {}
|
|
74
|
+
v = ev_attrs.get("exception.type")
|
|
75
|
+
if isinstance(v, str) and v:
|
|
76
|
+
yield v.rsplit(".", 1)[-1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_interrupt_span(span: ReadableSpan) -> bool:
|
|
80
|
+
names = list(_iter_exception_type_names(span))
|
|
81
|
+
if any(n in _INTERRUPT_EXCEPTION_NAMES for n in names):
|
|
82
|
+
return True
|
|
83
|
+
if _is_crewai_scope(span) and any(
|
|
84
|
+
n in _CREWAI_INTERRUPT_EXCEPTION_NAMES for n in names
|
|
85
|
+
):
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pick_str(span: ReadableSpan, *keys: str) -> str | None:
|
|
91
|
+
attrs = span.attributes or {}
|
|
92
|
+
for k in keys:
|
|
93
|
+
v = attrs.get(k)
|
|
94
|
+
if isinstance(v, str) and v:
|
|
95
|
+
return v
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _shared_attrs(span: ReadableSpan) -> dict[str, Any]:
|
|
100
|
+
model = (
|
|
101
|
+
_pick_str(span, "llm.model_name", "gen_ai.response.model", "gen_ai.request.model")
|
|
102
|
+
or "unknown"
|
|
103
|
+
)
|
|
104
|
+
system = (
|
|
105
|
+
_pick_str(span, "llm.provider", "gen_ai.system", "gen_ai.provider.name")
|
|
106
|
+
or "unknown"
|
|
107
|
+
)
|
|
108
|
+
oi_kind = _pick_str(span, "openinference.span.kind") or ""
|
|
109
|
+
request_type = _pick_str(span, "llm.request.type") or oi_kind_to_operation(oi_kind)
|
|
110
|
+
out: dict[str, Any] = {
|
|
111
|
+
"gen_ai.system": system,
|
|
112
|
+
"gen_ai.response.model": model,
|
|
113
|
+
"gen_ai.operation.name": request_type,
|
|
114
|
+
}
|
|
115
|
+
streaming = (span.attributes or {}).get("llm.is_streaming")
|
|
116
|
+
if streaming is True or streaming == "true":
|
|
117
|
+
out["stream"] = True
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_oi_llm_leaf(span: ReadableSpan) -> bool:
|
|
122
|
+
kind = _pick_str(span, "openinference.span.kind")
|
|
123
|
+
return kind is not None and kind in OI_LLM_LEAF_KIND_SET
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_oi_llm_span(span: ReadableSpan) -> bool:
|
|
127
|
+
kind = _pick_str(span, "openinference.span.kind")
|
|
128
|
+
return kind is not None and kind in OI_LLM_KIND_SET
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _duration_seconds(span: ReadableSpan) -> float:
|
|
132
|
+
if span.start_time is None or span.end_time is None:
|
|
133
|
+
return 0.0
|
|
134
|
+
return (span.end_time - span.start_time) / 1e9
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_DELTA_PREF = {
|
|
138
|
+
Counter: AggregationTemporality.DELTA,
|
|
139
|
+
UpDownCounter: AggregationTemporality.CUMULATIVE,
|
|
140
|
+
Histogram: AggregationTemporality.DELTA,
|
|
141
|
+
ObservableCounter: AggregationTemporality.DELTA,
|
|
142
|
+
ObservableUpDownCounter: AggregationTemporality.CUMULATIVE,
|
|
143
|
+
ObservableGauge: AggregationTemporality.CUMULATIVE,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ApmMetricsBridge:
|
|
148
|
+
def __init__(self, *, endpoint: str, apm_token: str, resource: Resource) -> None:
|
|
149
|
+
self._exporter = OTLPMetricExporter(
|
|
150
|
+
endpoint=f"{endpoint.rstrip('/')}/v1/metrics",
|
|
151
|
+
preferred_temporality=_DELTA_PREF,
|
|
152
|
+
)
|
|
153
|
+
self._reader = PeriodicExportingMetricReader(
|
|
154
|
+
self._exporter,
|
|
155
|
+
export_interval_millis=METRIC_EXPORT_INTERVAL_MS,
|
|
156
|
+
export_timeout_millis=METRIC_EXPORT_TIMEOUT_MS,
|
|
157
|
+
)
|
|
158
|
+
self._meter_provider = MeterProvider(
|
|
159
|
+
resource=resource,
|
|
160
|
+
metric_readers=[self._reader],
|
|
161
|
+
)
|
|
162
|
+
meter = self._meter_provider.get_meter(
|
|
163
|
+
"edgeone-agent-apm-bridge",
|
|
164
|
+
"0.1.0",
|
|
165
|
+
)
|
|
166
|
+
self._duration_histogram = meter.create_histogram(
|
|
167
|
+
name="gen_ai.client.operation.duration",
|
|
168
|
+
unit="s",
|
|
169
|
+
description="GenAI operation duration",
|
|
170
|
+
)
|
|
171
|
+
self._processor = _BridgeSpanProcessor(self)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def span_processor(self) -> SpanProcessor:
|
|
175
|
+
return self._processor
|
|
176
|
+
|
|
177
|
+
def record(self, span: ReadableSpan) -> None:
|
|
178
|
+
if not _is_oi_llm_span(span):
|
|
179
|
+
return
|
|
180
|
+
if not _is_oi_llm_leaf(span):
|
|
181
|
+
return
|
|
182
|
+
if not _pick_str(
|
|
183
|
+
span,
|
|
184
|
+
"llm.model_name",
|
|
185
|
+
"gen_ai.response.model",
|
|
186
|
+
"gen_ai.request.model",
|
|
187
|
+
):
|
|
188
|
+
return
|
|
189
|
+
dur = _duration_seconds(span)
|
|
190
|
+
if dur * 1000 < MIN_LLM_CALL_DURATION_MS:
|
|
191
|
+
return
|
|
192
|
+
attrs = _shared_attrs(span)
|
|
193
|
+
if (
|
|
194
|
+
span.status
|
|
195
|
+
and span.status.status_code == StatusCode.ERROR
|
|
196
|
+
and not _is_interrupt_span(span)
|
|
197
|
+
):
|
|
198
|
+
err = _pick_str(span, "error.type", "exception.type") or "error"
|
|
199
|
+
attrs["error.type"] = err
|
|
200
|
+
self._duration_histogram.record(dur, attributes=attrs)
|
|
201
|
+
|
|
202
|
+
def shutdown(self) -> None:
|
|
203
|
+
try:
|
|
204
|
+
self._meter_provider.shutdown()
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _BridgeSpanProcessor(SpanProcessor):
|
|
210
|
+
def __init__(self, bridge: ApmMetricsBridge) -> None:
|
|
211
|
+
self._bridge = bridge
|
|
212
|
+
|
|
213
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
217
|
+
try:
|
|
218
|
+
self._bridge.record(span)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
def shutdown(self) -> None:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
226
|
+
return True
|