respan-instrumentation-microsoft-agent-framework 0.1.0__tar.gz
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.
- respan_instrumentation_microsoft_agent_framework-0.1.0/PKG-INFO +88 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/README.md +68 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/pyproject.toml +30 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/src/respan_instrumentation_microsoft_agent_framework/__init__.py +5 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/src/respan_instrumentation_microsoft_agent_framework/_constants.py +64 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/src/respan_instrumentation_microsoft_agent_framework/_instrumentation.py +156 -0
- respan_instrumentation_microsoft_agent_framework-0.1.0/src/respan_instrumentation_microsoft_agent_framework/_processor.py +728 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: respan-instrumentation-microsoft-agent-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Respan instrumentation plugin for Microsoft Agent Framework
|
|
5
|
+
License: Apache 2.0
|
|
6
|
+
Author: Respan
|
|
7
|
+
Author-email: team@respan.ai
|
|
8
|
+
Requires-Python: >=3.11,<3.14
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: agent-framework-core (>=1.8.1,<2.0.0)
|
|
14
|
+
Requires-Dist: opentelemetry-semantic-conventions (>=0.59b0)
|
|
15
|
+
Requires-Dist: opentelemetry-semantic-conventions-ai (>=0.5.1)
|
|
16
|
+
Requires-Dist: respan-sdk (>=2.6.1)
|
|
17
|
+
Requires-Dist: respan-tracing (>=2.17.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# respan-instrumentation-microsoft-agent-framework
|
|
21
|
+
|
|
22
|
+
Respan instrumentation plugin for
|
|
23
|
+
[Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/).
|
|
24
|
+
|
|
25
|
+
Microsoft Agent Framework emits native OpenTelemetry spans for agents, tools,
|
|
26
|
+
chat completions, and workflows. This package registers a Respan span processor
|
|
27
|
+
that normalizes those native spans into the canonical Respan/OpenTelemetry span
|
|
28
|
+
contract before export.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install respan-instrumentation-microsoft-agent-framework
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
from agent_framework import Agent
|
|
42
|
+
from agent_framework.openai import OpenAIChatClient
|
|
43
|
+
from respan import Respan
|
|
44
|
+
from respan_instrumentation_microsoft_agent_framework import (
|
|
45
|
+
MicrosoftAgentFrameworkInstrumentor,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
respan_api_key = os.environ["RESPAN_API_KEY"]
|
|
49
|
+
respan_base_url = os.getenv("RESPAN_BASE_URL", "https://api.respan.ai/api")
|
|
50
|
+
|
|
51
|
+
respan = Respan(
|
|
52
|
+
api_key=respan_api_key,
|
|
53
|
+
base_url=respan_base_url,
|
|
54
|
+
app_name="microsoft-agent-framework-example",
|
|
55
|
+
instrumentations=[MicrosoftAgentFrameworkInstrumentor(capture_content=True)],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
os.environ["OPENAI_API_KEY"] = respan_api_key
|
|
59
|
+
os.environ["OPENAI_BASE_URL"] = respan_base_url
|
|
60
|
+
os.environ["OPENAI_MODEL_ID"] = os.getenv("RESPAN_MODEL", "gpt-4.1-nano")
|
|
61
|
+
|
|
62
|
+
client = OpenAIChatClient()
|
|
63
|
+
|
|
64
|
+
agent = Agent(
|
|
65
|
+
client=client,
|
|
66
|
+
name="trace_assistant",
|
|
67
|
+
instructions="Answer concisely.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
result = agent.run("Say hello from Microsoft Agent Framework.")
|
|
71
|
+
print(result)
|
|
72
|
+
|
|
73
|
+
respan.flush()
|
|
74
|
+
respan.shutdown()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- Initialize `Respan(...)` before running agents or workflows so Agent
|
|
80
|
+
Framework uses the active Respan OpenTelemetry provider.
|
|
81
|
+
- `capture_content=True` asks Agent Framework to include sensitive prompt,
|
|
82
|
+
completion, and tool payload data in its native spans. Set it to `False` if
|
|
83
|
+
you only want metadata.
|
|
84
|
+
- The processor emits canonical fields such as `respan.entity.log_type`,
|
|
85
|
+
`gen_ai.prompt.N.*`, `gen_ai.completion.N.*`, `llm.request.functions`, and
|
|
86
|
+
`traceloop.entity.*`. It strips Agent Framework raw message/tool payload
|
|
87
|
+
fields and off-contract aliases before export.
|
|
88
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# respan-instrumentation-microsoft-agent-framework
|
|
2
|
+
|
|
3
|
+
Respan instrumentation plugin for
|
|
4
|
+
[Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/).
|
|
5
|
+
|
|
6
|
+
Microsoft Agent Framework emits native OpenTelemetry spans for agents, tools,
|
|
7
|
+
chat completions, and workflows. This package registers a Respan span processor
|
|
8
|
+
that normalizes those native spans into the canonical Respan/OpenTelemetry span
|
|
9
|
+
contract before export.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install respan-instrumentation-microsoft-agent-framework
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
from agent_framework import Agent
|
|
23
|
+
from agent_framework.openai import OpenAIChatClient
|
|
24
|
+
from respan import Respan
|
|
25
|
+
from respan_instrumentation_microsoft_agent_framework import (
|
|
26
|
+
MicrosoftAgentFrameworkInstrumentor,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
respan_api_key = os.environ["RESPAN_API_KEY"]
|
|
30
|
+
respan_base_url = os.getenv("RESPAN_BASE_URL", "https://api.respan.ai/api")
|
|
31
|
+
|
|
32
|
+
respan = Respan(
|
|
33
|
+
api_key=respan_api_key,
|
|
34
|
+
base_url=respan_base_url,
|
|
35
|
+
app_name="microsoft-agent-framework-example",
|
|
36
|
+
instrumentations=[MicrosoftAgentFrameworkInstrumentor(capture_content=True)],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
os.environ["OPENAI_API_KEY"] = respan_api_key
|
|
40
|
+
os.environ["OPENAI_BASE_URL"] = respan_base_url
|
|
41
|
+
os.environ["OPENAI_MODEL_ID"] = os.getenv("RESPAN_MODEL", "gpt-4.1-nano")
|
|
42
|
+
|
|
43
|
+
client = OpenAIChatClient()
|
|
44
|
+
|
|
45
|
+
agent = Agent(
|
|
46
|
+
client=client,
|
|
47
|
+
name="trace_assistant",
|
|
48
|
+
instructions="Answer concisely.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = agent.run("Say hello from Microsoft Agent Framework.")
|
|
52
|
+
print(result)
|
|
53
|
+
|
|
54
|
+
respan.flush()
|
|
55
|
+
respan.shutdown()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Notes
|
|
59
|
+
|
|
60
|
+
- Initialize `Respan(...)` before running agents or workflows so Agent
|
|
61
|
+
Framework uses the active Respan OpenTelemetry provider.
|
|
62
|
+
- `capture_content=True` asks Agent Framework to include sensitive prompt,
|
|
63
|
+
completion, and tool payload data in its native spans. Set it to `False` if
|
|
64
|
+
you only want metadata.
|
|
65
|
+
- The processor emits canonical fields such as `respan.entity.log_type`,
|
|
66
|
+
`gen_ai.prompt.N.*`, `gen_ai.completion.N.*`, `llm.request.functions`, and
|
|
67
|
+
`traceloop.entity.*`. It strips Agent Framework raw message/tool payload
|
|
68
|
+
fields and off-contract aliases before export.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "respan-instrumentation-microsoft-agent-framework"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Respan instrumentation plugin for Microsoft Agent Framework"
|
|
5
|
+
authors = ["Respan <team@respan.ai>"]
|
|
6
|
+
license = "Apache 2.0"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [
|
|
9
|
+
{ include = "respan_instrumentation_microsoft_agent_framework", from = "./src" },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = ">=3.11,<3.14"
|
|
14
|
+
respan-tracing = ">=2.17.0"
|
|
15
|
+
respan-sdk = ">=2.6.1"
|
|
16
|
+
agent-framework-core = ">=1.8.1,<2.0.0"
|
|
17
|
+
opentelemetry-semantic-conventions-ai = ">=0.5.1"
|
|
18
|
+
opentelemetry-semantic-conventions = ">=0.59b0"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.plugins."respan.instrumentations"]
|
|
21
|
+
microsoft-agent-framework = "respan_instrumentation_microsoft_agent_framework:MicrosoftAgentFrameworkInstrumentor"
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
markers = [
|
|
25
|
+
"integration: live network tests (requires API keys)",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["poetry-core"]
|
|
30
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Microsoft Agent Framework raw attribute keys."""
|
|
2
|
+
|
|
3
|
+
from opentelemetry.semconv._incubating.attributes import (
|
|
4
|
+
gen_ai_attributes as GenAIAttributes,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
AGENT_FRAMEWORK_INSTRUMENTATION_NAME = "microsoft-agent-framework"
|
|
8
|
+
AGENT_FRAMEWORK_SCOPE_PREFIX = "agent_framework"
|
|
9
|
+
AGENT_FRAMEWORK_SYSTEM = "microsoft.agent_framework"
|
|
10
|
+
|
|
11
|
+
# Agent Framework emits these as native OTEL GenAI attributes. Import them
|
|
12
|
+
# from upstream semconv so this package does not shadow shared constants.
|
|
13
|
+
ATTR_GEN_AI_OPERATION_NAME = GenAIAttributes.GEN_AI_OPERATION_NAME
|
|
14
|
+
ATTR_GEN_AI_PROVIDER_NAME = GenAIAttributes.GEN_AI_PROVIDER_NAME
|
|
15
|
+
ATTR_GEN_AI_AGENT_NAME = GenAIAttributes.GEN_AI_AGENT_NAME
|
|
16
|
+
ATTR_GEN_AI_TOOL_NAME = GenAIAttributes.GEN_AI_TOOL_NAME
|
|
17
|
+
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS = GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS
|
|
18
|
+
ATTR_GEN_AI_TOOL_CALL_RESULT = GenAIAttributes.GEN_AI_TOOL_CALL_RESULT
|
|
19
|
+
ATTR_GEN_AI_TOOL_CALL_ID = GenAIAttributes.GEN_AI_TOOL_CALL_ID
|
|
20
|
+
ATTR_GEN_AI_TOOL_DEFINITIONS = GenAIAttributes.GEN_AI_TOOL_DEFINITIONS
|
|
21
|
+
ATTR_GEN_AI_INPUT_MESSAGES = GenAIAttributes.GEN_AI_INPUT_MESSAGES
|
|
22
|
+
ATTR_GEN_AI_OUTPUT_MESSAGES = GenAIAttributes.GEN_AI_OUTPUT_MESSAGES
|
|
23
|
+
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS = GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS
|
|
24
|
+
ATTR_GEN_AI_CONVERSATION_ID = GenAIAttributes.GEN_AI_CONVERSATION_ID
|
|
25
|
+
ATTR_GEN_AI_RESPONSE_MODEL = GenAIAttributes.GEN_AI_RESPONSE_MODEL
|
|
26
|
+
ATTR_GEN_AI_USAGE_INPUT_TOKENS = GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS
|
|
27
|
+
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS
|
|
28
|
+
|
|
29
|
+
ATTR_WORKFLOW_NAME = "workflow.name"
|
|
30
|
+
ATTR_WORKFLOW_ID = "workflow.id"
|
|
31
|
+
ATTR_WORKFLOW_EXECUTOR_ID = "workflow.executor.id"
|
|
32
|
+
ATTR_WORKFLOW_EDGE_GROUP_ID = "workflow.edge_group.id"
|
|
33
|
+
|
|
34
|
+
OPERATION_CHAT = "chat"
|
|
35
|
+
OPERATION_INVOKE_AGENT = "invoke_agent"
|
|
36
|
+
OPERATION_CREATE_AGENT = "create_agent"
|
|
37
|
+
OPERATION_EXECUTE_TOOL = "execute_tool"
|
|
38
|
+
|
|
39
|
+
WORKFLOW_SPAN_PREFIXES = (
|
|
40
|
+
"workflow.run",
|
|
41
|
+
"workflow.start",
|
|
42
|
+
"workflow.resume",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
TASK_SPAN_PREFIXES = (
|
|
46
|
+
"executor.process",
|
|
47
|
+
"executor.send_message",
|
|
48
|
+
"executor.yield_output",
|
|
49
|
+
"edge_group.process",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
TOP_LEVEL_ALIAS_ATTRS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"model",
|
|
55
|
+
"prompt_tokens",
|
|
56
|
+
"completion_tokens",
|
|
57
|
+
"total_request_tokens",
|
|
58
|
+
"tools",
|
|
59
|
+
"tool_calls",
|
|
60
|
+
"span_tools",
|
|
61
|
+
"has_tool_calls",
|
|
62
|
+
"parallel_tool_calls",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Microsoft Agent Framework OTEL instrumentation plugin for Respan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
|
|
11
|
+
from respan_instrumentation_microsoft_agent_framework._constants import (
|
|
12
|
+
AGENT_FRAMEWORK_INSTRUMENTATION_NAME,
|
|
13
|
+
)
|
|
14
|
+
from respan_instrumentation_microsoft_agent_framework._processor import (
|
|
15
|
+
AgentFrameworkSpanProcessor,
|
|
16
|
+
)
|
|
17
|
+
from respan_tracing.core.tracer import RespanTracer
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _active_span_processors(tracer_provider: Any) -> tuple[Any, tuple[Any, ...] | None]:
|
|
23
|
+
active_span_processor = getattr(tracer_provider, "_active_span_processor", None)
|
|
24
|
+
processors = (
|
|
25
|
+
getattr(active_span_processor, "_span_processors", None)
|
|
26
|
+
if active_span_processor is not None
|
|
27
|
+
else None
|
|
28
|
+
)
|
|
29
|
+
return active_span_processor, processors
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _register_processor(
|
|
33
|
+
tracer_provider: Any,
|
|
34
|
+
processor: AgentFrameworkSpanProcessor,
|
|
35
|
+
) -> None:
|
|
36
|
+
active_span_processor, processors = _active_span_processors(tracer_provider)
|
|
37
|
+
if active_span_processor is None or processors is None:
|
|
38
|
+
if hasattr(tracer_provider, "add_span_processor"):
|
|
39
|
+
tracer_provider.add_span_processor(processor)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
remaining_processors = tuple(
|
|
43
|
+
existing_processor
|
|
44
|
+
for existing_processor in processors
|
|
45
|
+
if existing_processor is not processor
|
|
46
|
+
)
|
|
47
|
+
active_span_processor._span_processors = (processor, *remaining_processors)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unregister_processor(
|
|
51
|
+
tracer_provider: Any,
|
|
52
|
+
processor: AgentFrameworkSpanProcessor,
|
|
53
|
+
) -> None:
|
|
54
|
+
active_span_processor, processors = _active_span_processors(tracer_provider)
|
|
55
|
+
if active_span_processor is None or processors is None:
|
|
56
|
+
return
|
|
57
|
+
active_span_processor._span_processors = tuple(
|
|
58
|
+
existing_processor
|
|
59
|
+
for existing_processor in processors
|
|
60
|
+
if existing_processor is not processor
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MicrosoftAgentFrameworkInstrumentor:
|
|
65
|
+
"""Respan instrumentor for Microsoft Agent Framework native OTEL spans."""
|
|
66
|
+
|
|
67
|
+
name = AGENT_FRAMEWORK_INSTRUMENTATION_NAME
|
|
68
|
+
|
|
69
|
+
def __init__(self, *, capture_content: bool = True) -> None:
|
|
70
|
+
self._capture_content = capture_content
|
|
71
|
+
self._processor: AgentFrameworkSpanProcessor | None = None
|
|
72
|
+
self._is_instrumented = False
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _is_respan_tracing_enabled() -> bool:
|
|
76
|
+
tracer = getattr(RespanTracer, "_instance", None)
|
|
77
|
+
if tracer is None:
|
|
78
|
+
return True
|
|
79
|
+
return bool(getattr(tracer, "is_enabled", True))
|
|
80
|
+
|
|
81
|
+
def _enable_agent_framework_observability(self) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
observability = importlib.import_module("agent_framework.observability")
|
|
84
|
+
except ImportError as exc:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Failed to activate Microsoft Agent Framework instrumentation - "
|
|
87
|
+
"missing dependency: %s",
|
|
88
|
+
exc,
|
|
89
|
+
)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
settings = getattr(observability, "OBSERVABILITY_SETTINGS", None)
|
|
93
|
+
if bool(getattr(settings, "is_user_disabled", False)):
|
|
94
|
+
logger.info(
|
|
95
|
+
"Microsoft Agent Framework instrumentation skipped because "
|
|
96
|
+
"Agent Framework observability is user-disabled"
|
|
97
|
+
)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
enable_instrumentation = getattr(observability, "enable_instrumentation", None)
|
|
101
|
+
if callable(enable_instrumentation):
|
|
102
|
+
try:
|
|
103
|
+
enable_instrumentation(enable_sensitive_data=self._capture_content)
|
|
104
|
+
return True
|
|
105
|
+
except TypeError:
|
|
106
|
+
enable_instrumentation()
|
|
107
|
+
|
|
108
|
+
if self._capture_content:
|
|
109
|
+
enable_sensitive_telemetry = getattr(
|
|
110
|
+
observability,
|
|
111
|
+
"enable_sensitive_telemetry",
|
|
112
|
+
None,
|
|
113
|
+
)
|
|
114
|
+
if callable(enable_sensitive_telemetry):
|
|
115
|
+
enable_sensitive_telemetry()
|
|
116
|
+
elif settings is not None and hasattr(settings, "enable_sensitive_data"):
|
|
117
|
+
settings.enable_sensitive_data = True
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def activate(self) -> None:
|
|
121
|
+
"""Activate Respan normalization for Microsoft Agent Framework spans."""
|
|
122
|
+
if self._is_instrumented:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if not self._is_respan_tracing_enabled():
|
|
126
|
+
logger.info(
|
|
127
|
+
"Microsoft Agent Framework instrumentation skipped because "
|
|
128
|
+
"Respan tracing is disabled"
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if not self._enable_agent_framework_observability():
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if self._processor is None:
|
|
136
|
+
self._processor = AgentFrameworkSpanProcessor()
|
|
137
|
+
|
|
138
|
+
_register_processor(
|
|
139
|
+
tracer_provider=trace.get_tracer_provider(),
|
|
140
|
+
processor=self._processor,
|
|
141
|
+
)
|
|
142
|
+
self._is_instrumented = True
|
|
143
|
+
logger.info("Microsoft Agent Framework instrumentation activated")
|
|
144
|
+
|
|
145
|
+
def deactivate(self) -> None:
|
|
146
|
+
"""Deactivate Respan normalization for Microsoft Agent Framework spans."""
|
|
147
|
+
if not self._is_instrumented:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if self._processor is not None:
|
|
151
|
+
_unregister_processor(
|
|
152
|
+
tracer_provider=trace.get_tracer_provider(),
|
|
153
|
+
processor=self._processor,
|
|
154
|
+
)
|
|
155
|
+
self._is_instrumented = False
|
|
156
|
+
logger.info("Microsoft Agent Framework instrumentation deactivated")
|
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Normalize Microsoft Agent Framework native OTEL spans for Respan export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
|
|
12
|
+
from opentelemetry.semconv_ai import LLMRequestTypeValues, SpanAttributes
|
|
13
|
+
|
|
14
|
+
from respan_instrumentation_microsoft_agent_framework._constants import (
|
|
15
|
+
AGENT_FRAMEWORK_SCOPE_PREFIX,
|
|
16
|
+
AGENT_FRAMEWORK_SYSTEM,
|
|
17
|
+
ATTR_GEN_AI_AGENT_NAME,
|
|
18
|
+
ATTR_GEN_AI_CONVERSATION_ID,
|
|
19
|
+
ATTR_GEN_AI_INPUT_MESSAGES,
|
|
20
|
+
ATTR_GEN_AI_OPERATION_NAME,
|
|
21
|
+
ATTR_GEN_AI_OUTPUT_MESSAGES,
|
|
22
|
+
ATTR_GEN_AI_PROVIDER_NAME,
|
|
23
|
+
ATTR_GEN_AI_RESPONSE_MODEL,
|
|
24
|
+
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
|
|
25
|
+
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS,
|
|
26
|
+
ATTR_GEN_AI_TOOL_CALL_ID,
|
|
27
|
+
ATTR_GEN_AI_TOOL_CALL_RESULT,
|
|
28
|
+
ATTR_GEN_AI_TOOL_DEFINITIONS,
|
|
29
|
+
ATTR_GEN_AI_TOOL_NAME,
|
|
30
|
+
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
|
31
|
+
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
|
32
|
+
ATTR_WORKFLOW_EDGE_GROUP_ID,
|
|
33
|
+
ATTR_WORKFLOW_EXECUTOR_ID,
|
|
34
|
+
ATTR_WORKFLOW_ID,
|
|
35
|
+
ATTR_WORKFLOW_NAME,
|
|
36
|
+
OPERATION_CHAT,
|
|
37
|
+
OPERATION_CREATE_AGENT,
|
|
38
|
+
OPERATION_EXECUTE_TOOL,
|
|
39
|
+
OPERATION_INVOKE_AGENT,
|
|
40
|
+
TASK_SPAN_PREFIXES,
|
|
41
|
+
TOP_LEVEL_ALIAS_ATTRS,
|
|
42
|
+
WORKFLOW_SPAN_PREFIXES,
|
|
43
|
+
)
|
|
44
|
+
from respan_sdk.constants import ERROR_MESSAGE_ATTR
|
|
45
|
+
from respan_sdk.constants.llm_logging import (
|
|
46
|
+
LOG_TYPE_AGENT,
|
|
47
|
+
LOG_TYPE_CHAT,
|
|
48
|
+
LOG_TYPE_TASK,
|
|
49
|
+
LOG_TYPE_TOOL,
|
|
50
|
+
LOG_TYPE_WORKFLOW,
|
|
51
|
+
)
|
|
52
|
+
from respan_sdk.constants.span_attributes import (
|
|
53
|
+
RESPAN_LOG_TYPE,
|
|
54
|
+
RESPAN_SESSION_ID,
|
|
55
|
+
RESPAN_SPAN_HANDOFFS,
|
|
56
|
+
RESPAN_SPAN_TOOL_CALLS,
|
|
57
|
+
RESPAN_SPAN_TOOLS,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_OFF_CONTRACT_ALIASES = TOP_LEVEL_ALIAS_ATTRS | frozenset(
|
|
64
|
+
{
|
|
65
|
+
RESPAN_SPAN_TOOLS,
|
|
66
|
+
RESPAN_SPAN_TOOL_CALLS,
|
|
67
|
+
RESPAN_SPAN_HANDOFFS,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_RAW_ATTRS_TO_STRIP = frozenset(
|
|
72
|
+
{
|
|
73
|
+
ATTR_GEN_AI_INPUT_MESSAGES,
|
|
74
|
+
ATTR_GEN_AI_OUTPUT_MESSAGES,
|
|
75
|
+
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
|
|
76
|
+
ATTR_GEN_AI_TOOL_DEFINITIONS,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
_ERROR_TYPE_ATTR = "error.type"
|
|
81
|
+
_EXCEPTION_TYPE_ATTR = "exception.type"
|
|
82
|
+
_EXCEPTION_MESSAGE_ATTR = "exception.message"
|
|
83
|
+
_STATUS_CODE_ATTR = "status_code"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _span_attr(name: str, fallback: str) -> str:
|
|
87
|
+
return str(getattr(SpanAttributes, name, fallback))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_GEN_AI_SYSTEM = SpanAttributes.LLM_SYSTEM
|
|
91
|
+
_GEN_AI_OPERATION_NAME = _span_attr("GEN_AI_OPERATION_NAME", ATTR_GEN_AI_OPERATION_NAME)
|
|
92
|
+
_GEN_AI_PROVIDER_NAME = _span_attr("GEN_AI_PROVIDER_NAME", ATTR_GEN_AI_PROVIDER_NAME)
|
|
93
|
+
_GEN_AI_RESPONSE_MODEL = ATTR_GEN_AI_RESPONSE_MODEL
|
|
94
|
+
_GEN_AI_AGENT_NAME = _span_attr("GEN_AI_AGENT_NAME", ATTR_GEN_AI_AGENT_NAME)
|
|
95
|
+
_GEN_AI_TOOL_NAME = _span_attr("GEN_AI_TOOL_NAME", ATTR_GEN_AI_TOOL_NAME)
|
|
96
|
+
_GEN_AI_TOOL_PREFIX = f"{ATTR_GEN_AI_TOOL_NAME.rsplit('.', 1)[0]}."
|
|
97
|
+
_GEN_AI_TOOL_CALL_ARGUMENTS = _span_attr(
|
|
98
|
+
"GEN_AI_TOOL_CALL_ARGUMENTS",
|
|
99
|
+
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS,
|
|
100
|
+
)
|
|
101
|
+
_GEN_AI_TOOL_CALL_RESULT = _span_attr(
|
|
102
|
+
"GEN_AI_TOOL_CALL_RESULT",
|
|
103
|
+
ATTR_GEN_AI_TOOL_CALL_RESULT,
|
|
104
|
+
)
|
|
105
|
+
_GEN_AI_USAGE_INPUT_TOKENS = _span_attr(
|
|
106
|
+
"GEN_AI_USAGE_INPUT_TOKENS",
|
|
107
|
+
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
|
108
|
+
)
|
|
109
|
+
_GEN_AI_USAGE_OUTPUT_TOKENS = _span_attr(
|
|
110
|
+
"GEN_AI_USAGE_OUTPUT_TOKENS",
|
|
111
|
+
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _safe_json_loads(value: Any) -> Any:
|
|
116
|
+
if not isinstance(value, str):
|
|
117
|
+
return value
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(value)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
stripped = value.strip()
|
|
122
|
+
if not stripped or stripped[:1] not in {"{", "[", "("}:
|
|
123
|
+
return value
|
|
124
|
+
try:
|
|
125
|
+
return ast.literal_eval(stripped)
|
|
126
|
+
except (SyntaxError, ValueError):
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _json_string(value: Any) -> str | None:
|
|
131
|
+
if value is None:
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
return value
|
|
135
|
+
return json.dumps(_to_jsonable(value), default=str)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _to_jsonable(value: Any, *, depth: int = 0) -> Any:
|
|
139
|
+
if depth > 6:
|
|
140
|
+
return repr(value)
|
|
141
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
142
|
+
return value
|
|
143
|
+
if isinstance(value, bytes):
|
|
144
|
+
return value.decode("utf-8", errors="replace")
|
|
145
|
+
if isinstance(value, Mapping):
|
|
146
|
+
return {
|
|
147
|
+
str(key): _to_jsonable(item, depth=depth + 1)
|
|
148
|
+
for key, item in value.items()
|
|
149
|
+
if not callable(item)
|
|
150
|
+
}
|
|
151
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
152
|
+
return [_to_jsonable(item, depth=depth + 1) for item in value]
|
|
153
|
+
|
|
154
|
+
model_dump = getattr(value, "model_dump", None)
|
|
155
|
+
if callable(model_dump):
|
|
156
|
+
try:
|
|
157
|
+
return _to_jsonable(
|
|
158
|
+
model_dump(mode="json", exclude_none=True),
|
|
159
|
+
depth=depth + 1,
|
|
160
|
+
)
|
|
161
|
+
except TypeError:
|
|
162
|
+
return _to_jsonable(model_dump(), depth=depth + 1)
|
|
163
|
+
|
|
164
|
+
to_dict = getattr(value, "dict", None)
|
|
165
|
+
if callable(to_dict):
|
|
166
|
+
return _to_jsonable(to_dict(), depth=depth + 1)
|
|
167
|
+
|
|
168
|
+
if hasattr(value, "__dict__"):
|
|
169
|
+
return {
|
|
170
|
+
key: _to_jsonable(item, depth=depth + 1)
|
|
171
|
+
for key, item in vars(value).items()
|
|
172
|
+
if not key.startswith("_") and not callable(item)
|
|
173
|
+
}
|
|
174
|
+
return repr(value)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _set_if_present(attrs: dict[str, Any], key: str, value: Any) -> None:
|
|
178
|
+
if value not in (None, "", (), []):
|
|
179
|
+
attrs[key] = value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _set_if_missing(attrs: dict[str, Any], key: str, value: Any) -> None:
|
|
183
|
+
if value in (None, "", (), []):
|
|
184
|
+
return
|
|
185
|
+
if attrs.get(key) in (None, "", (), []):
|
|
186
|
+
attrs[key] = value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _int_value(value: Any) -> int | None:
|
|
190
|
+
if isinstance(value, bool):
|
|
191
|
+
return None
|
|
192
|
+
if isinstance(value, int):
|
|
193
|
+
return value
|
|
194
|
+
if isinstance(value, float) and value.is_integer():
|
|
195
|
+
return int(value)
|
|
196
|
+
if isinstance(value, str) and value.isdigit():
|
|
197
|
+
return int(value)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _scope_name(span: ReadableSpan) -> str:
|
|
202
|
+
scope = getattr(span, "instrumentation_scope", None)
|
|
203
|
+
if scope is not None:
|
|
204
|
+
name = getattr(scope, "name", "")
|
|
205
|
+
if isinstance(name, str):
|
|
206
|
+
return name
|
|
207
|
+
instrumentation_info = getattr(span, "instrumentation_info", None)
|
|
208
|
+
name = getattr(instrumentation_info, "name", "")
|
|
209
|
+
return name if isinstance(name, str) else ""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _span_name(span: ReadableSpan) -> str:
|
|
213
|
+
return str(getattr(span, "name", "") or "")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_agent_framework_span(span: ReadableSpan, attrs: Mapping[str, Any]) -> bool:
|
|
217
|
+
scope_name = _scope_name(span)
|
|
218
|
+
if scope_name.startswith(AGENT_FRAMEWORK_SCOPE_PREFIX):
|
|
219
|
+
return True
|
|
220
|
+
if attrs.get(_GEN_AI_SYSTEM) == AGENT_FRAMEWORK_SYSTEM:
|
|
221
|
+
return True
|
|
222
|
+
if any(key.startswith("agent_framework.") for key in attrs):
|
|
223
|
+
return True
|
|
224
|
+
if ATTR_WORKFLOW_NAME in attrs or ATTR_WORKFLOW_ID in attrs:
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _operation_name(attrs: Mapping[str, Any]) -> str | None:
|
|
230
|
+
operation = attrs.get(_GEN_AI_OPERATION_NAME) or attrs.get(ATTR_GEN_AI_OPERATION_NAME)
|
|
231
|
+
return str(operation) if operation else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _has_any_prefix(span_name: str, prefixes: Sequence[str]) -> bool:
|
|
235
|
+
return any(span_name == prefix or span_name.startswith(f"{prefix} ") for prefix in prefixes)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _log_type(span: ReadableSpan, attrs: Mapping[str, Any]) -> str | None:
|
|
239
|
+
operation = _operation_name(attrs)
|
|
240
|
+
span_name = _span_name(span)
|
|
241
|
+
|
|
242
|
+
if operation == OPERATION_CHAT:
|
|
243
|
+
return LOG_TYPE_CHAT
|
|
244
|
+
if operation == OPERATION_EXECUTE_TOOL or attrs.get(_GEN_AI_TOOL_NAME):
|
|
245
|
+
return LOG_TYPE_TOOL
|
|
246
|
+
if operation in {OPERATION_INVOKE_AGENT, OPERATION_CREATE_AGENT}:
|
|
247
|
+
return LOG_TYPE_AGENT
|
|
248
|
+
if _has_any_prefix(span_name, WORKFLOW_SPAN_PREFIXES) or ATTR_WORKFLOW_NAME in attrs:
|
|
249
|
+
return LOG_TYPE_WORKFLOW
|
|
250
|
+
if _has_any_prefix(span_name, TASK_SPAN_PREFIXES):
|
|
251
|
+
return LOG_TYPE_TASK
|
|
252
|
+
if attrs.get(_GEN_AI_AGENT_NAME):
|
|
253
|
+
return LOG_TYPE_AGENT
|
|
254
|
+
if attrs.get(SpanAttributes.LLM_REQUEST_MODEL) or attrs.get(_GEN_AI_PROVIDER_NAME):
|
|
255
|
+
return LOG_TYPE_CHAT
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _suffix_name(span_name: str, prefix: str, fallback: str) -> str:
|
|
260
|
+
if span_name.startswith(f"{prefix} "):
|
|
261
|
+
suffix = span_name[len(prefix) + 1 :].strip()
|
|
262
|
+
if suffix:
|
|
263
|
+
return suffix
|
|
264
|
+
return fallback
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _entity_name_for_log_type(
|
|
268
|
+
span: ReadableSpan,
|
|
269
|
+
attrs: Mapping[str, Any],
|
|
270
|
+
log_type: str,
|
|
271
|
+
) -> str:
|
|
272
|
+
span_name = _span_name(span)
|
|
273
|
+
if log_type == LOG_TYPE_AGENT:
|
|
274
|
+
agent_name = attrs.get(_GEN_AI_AGENT_NAME) or attrs.get(ATTR_GEN_AI_AGENT_NAME)
|
|
275
|
+
if agent_name:
|
|
276
|
+
return str(agent_name)
|
|
277
|
+
return _suffix_name(span_name, OPERATION_INVOKE_AGENT, span_name or "agent")
|
|
278
|
+
if log_type == LOG_TYPE_TOOL:
|
|
279
|
+
tool_name = attrs.get(_GEN_AI_TOOL_NAME) or attrs.get(ATTR_GEN_AI_TOOL_NAME)
|
|
280
|
+
if tool_name:
|
|
281
|
+
return str(tool_name)
|
|
282
|
+
return _suffix_name(span_name, OPERATION_EXECUTE_TOOL, span_name or "tool")
|
|
283
|
+
if log_type == LOG_TYPE_WORKFLOW:
|
|
284
|
+
return str(
|
|
285
|
+
attrs.get(ATTR_WORKFLOW_NAME)
|
|
286
|
+
or attrs.get(ATTR_WORKFLOW_ID)
|
|
287
|
+
or span_name
|
|
288
|
+
or "workflow"
|
|
289
|
+
)
|
|
290
|
+
if log_type == LOG_TYPE_TASK:
|
|
291
|
+
return str(
|
|
292
|
+
attrs.get(ATTR_WORKFLOW_EXECUTOR_ID)
|
|
293
|
+
or attrs.get(ATTR_WORKFLOW_EDGE_GROUP_ID)
|
|
294
|
+
or span_name
|
|
295
|
+
or "task"
|
|
296
|
+
)
|
|
297
|
+
if log_type == LOG_TYPE_CHAT:
|
|
298
|
+
return span_name or "agent_framework.chat"
|
|
299
|
+
return span_name or "agent_framework"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _message_role(message: Mapping[str, Any], *, default: str) -> str:
|
|
303
|
+
role = message.get("role") or message.get("author") or default
|
|
304
|
+
if role == "model":
|
|
305
|
+
return "assistant"
|
|
306
|
+
return str(role)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _part_text(part: Mapping[str, Any]) -> str | None:
|
|
310
|
+
for key in ("text", "content", "value"):
|
|
311
|
+
value = part.get(key)
|
|
312
|
+
if isinstance(value, str):
|
|
313
|
+
return value
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _normalize_arguments(value: Any) -> str:
|
|
318
|
+
if value is None:
|
|
319
|
+
return "{}"
|
|
320
|
+
if isinstance(value, str):
|
|
321
|
+
return value
|
|
322
|
+
return json.dumps(_to_jsonable(value), default=str)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _tool_call_from_part(part: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
326
|
+
part_type = part.get("type")
|
|
327
|
+
function_payload = part.get("function")
|
|
328
|
+
if isinstance(function_payload, Mapping):
|
|
329
|
+
name = function_payload.get("name")
|
|
330
|
+
arguments = function_payload.get("arguments")
|
|
331
|
+
else:
|
|
332
|
+
name = part.get("name") or part.get("tool_name")
|
|
333
|
+
arguments = part.get("arguments") or part.get("args") or part.get("input")
|
|
334
|
+
|
|
335
|
+
if not name and part_type not in {"tool_call", "function_call"}:
|
|
336
|
+
return None
|
|
337
|
+
if not name:
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
tool_call: dict[str, Any] = {
|
|
341
|
+
"type": "function",
|
|
342
|
+
"function": {
|
|
343
|
+
"name": str(name),
|
|
344
|
+
"arguments": _normalize_arguments(arguments),
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
call_id = part.get("id") or part.get("call_id") or part.get("tool_call_id")
|
|
348
|
+
if call_id:
|
|
349
|
+
tool_call["id"] = str(call_id)
|
|
350
|
+
return tool_call
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _message_content_and_tool_calls(
|
|
354
|
+
message: Mapping[str, Any],
|
|
355
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
356
|
+
content = message.get("content")
|
|
357
|
+
if isinstance(content, str):
|
|
358
|
+
text_parts = [content]
|
|
359
|
+
else:
|
|
360
|
+
text_parts = []
|
|
361
|
+
|
|
362
|
+
tool_calls: list[dict[str, Any]] = []
|
|
363
|
+
parts = message.get("parts")
|
|
364
|
+
if isinstance(parts, Sequence) and not isinstance(parts, (str, bytes, bytearray)):
|
|
365
|
+
for part in parts:
|
|
366
|
+
if not isinstance(part, Mapping):
|
|
367
|
+
continue
|
|
368
|
+
text = _part_text(part)
|
|
369
|
+
if text:
|
|
370
|
+
text_parts.append(text)
|
|
371
|
+
tool_call = _tool_call_from_part(part)
|
|
372
|
+
if tool_call is not None:
|
|
373
|
+
tool_calls.append(tool_call)
|
|
374
|
+
tool_response = part.get("response") or part.get("result")
|
|
375
|
+
if tool_response is not None and not text:
|
|
376
|
+
text_parts.append(str(tool_response))
|
|
377
|
+
|
|
378
|
+
direct_tool_calls = message.get("tool_calls")
|
|
379
|
+
parsed_tool_calls = _safe_json_loads(direct_tool_calls)
|
|
380
|
+
if isinstance(parsed_tool_calls, Sequence) and not isinstance(
|
|
381
|
+
parsed_tool_calls,
|
|
382
|
+
(str, bytes, bytearray),
|
|
383
|
+
):
|
|
384
|
+
for tool_call in parsed_tool_calls:
|
|
385
|
+
if isinstance(tool_call, Mapping):
|
|
386
|
+
normalized = _tool_call_from_part(tool_call)
|
|
387
|
+
if normalized is not None:
|
|
388
|
+
tool_calls.append(normalized)
|
|
389
|
+
|
|
390
|
+
content_text = "\n".join(text_parts) if text_parts else None
|
|
391
|
+
return content_text, tool_calls
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _message_list(value: Any) -> list[Mapping[str, Any]]:
|
|
395
|
+
parsed = _safe_json_loads(value)
|
|
396
|
+
if isinstance(parsed, Mapping):
|
|
397
|
+
return [parsed]
|
|
398
|
+
if not isinstance(parsed, Sequence) or isinstance(parsed, (str, bytes, bytearray)):
|
|
399
|
+
return []
|
|
400
|
+
return [message for message in parsed if isinstance(message, Mapping)]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _apply_messages(
|
|
404
|
+
attrs: dict[str, Any],
|
|
405
|
+
*,
|
|
406
|
+
source_messages: list[Mapping[str, Any]],
|
|
407
|
+
target_prefix: str,
|
|
408
|
+
default_role: str,
|
|
409
|
+
start_index: int = 0,
|
|
410
|
+
) -> None:
|
|
411
|
+
for offset, message in enumerate(source_messages):
|
|
412
|
+
index = start_index + offset
|
|
413
|
+
prefix = f"{target_prefix}.{index}"
|
|
414
|
+
attrs.setdefault(f"{prefix}.role", _message_role(message, default=default_role))
|
|
415
|
+
content, tool_calls = _message_content_and_tool_calls(message)
|
|
416
|
+
if content is not None:
|
|
417
|
+
attrs.setdefault(f"{prefix}.content", content)
|
|
418
|
+
if tool_calls:
|
|
419
|
+
attrs.setdefault(f"{prefix}.tool_calls", json.dumps(tool_calls, default=str))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _system_instruction_messages(value: Any) -> list[dict[str, Any]]:
|
|
423
|
+
parsed = _safe_json_loads(value)
|
|
424
|
+
if parsed in (None, "", [], ()):
|
|
425
|
+
return []
|
|
426
|
+
if isinstance(parsed, str):
|
|
427
|
+
return [{"role": "system", "content": parsed}]
|
|
428
|
+
if isinstance(parsed, Mapping):
|
|
429
|
+
content, _tool_calls = _message_content_and_tool_calls(parsed)
|
|
430
|
+
return [{"role": "system", "content": content or _json_string(parsed) or ""}]
|
|
431
|
+
if isinstance(parsed, Sequence):
|
|
432
|
+
text_parts: list[str] = []
|
|
433
|
+
for item in parsed:
|
|
434
|
+
if isinstance(item, Mapping):
|
|
435
|
+
text = _part_text(item)
|
|
436
|
+
if text:
|
|
437
|
+
text_parts.append(text)
|
|
438
|
+
elif isinstance(item, str):
|
|
439
|
+
text_parts.append(item)
|
|
440
|
+
if text_parts:
|
|
441
|
+
return [{"role": "system", "content": "\n".join(text_parts)}]
|
|
442
|
+
return [{"role": "system", "content": _json_string(parsed) or ""}]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _normalize_tool_definition(tool: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
446
|
+
if isinstance(tool.get("function"), Mapping):
|
|
447
|
+
function_payload = dict(tool["function"])
|
|
448
|
+
name = function_payload.get("name")
|
|
449
|
+
if not name:
|
|
450
|
+
return None
|
|
451
|
+
return {
|
|
452
|
+
"type": str(tool.get("type") or "function"),
|
|
453
|
+
"function": function_payload,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
name = tool.get("name") or tool.get("tool_name")
|
|
457
|
+
if not name:
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
function: dict[str, Any] = {"name": str(name)}
|
|
461
|
+
for key in ("description", "parameters"):
|
|
462
|
+
if key in tool:
|
|
463
|
+
function[key] = _to_jsonable(tool[key])
|
|
464
|
+
return {"type": "function", "function": function}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _tool_definitions(value: Any) -> list[dict[str, Any]]:
|
|
468
|
+
parsed = _safe_json_loads(value)
|
|
469
|
+
if isinstance(parsed, Mapping):
|
|
470
|
+
candidates = parsed.get("tools") or parsed.get("functions") or [parsed]
|
|
471
|
+
else:
|
|
472
|
+
candidates = parsed
|
|
473
|
+
if not isinstance(candidates, Sequence) or isinstance(
|
|
474
|
+
candidates,
|
|
475
|
+
(str, bytes, bytearray),
|
|
476
|
+
):
|
|
477
|
+
return []
|
|
478
|
+
definitions: list[dict[str, Any]] = []
|
|
479
|
+
for item in candidates:
|
|
480
|
+
if not isinstance(item, Mapping):
|
|
481
|
+
continue
|
|
482
|
+
normalized = _normalize_tool_definition(item)
|
|
483
|
+
if normalized is not None:
|
|
484
|
+
definitions.append(normalized)
|
|
485
|
+
return definitions
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _apply_chat_attrs(
|
|
489
|
+
span: ReadableSpan,
|
|
490
|
+
attrs: dict[str, Any],
|
|
491
|
+
raw_attrs: Mapping[str, Any],
|
|
492
|
+
) -> None:
|
|
493
|
+
attrs[RESPAN_LOG_TYPE] = LOG_TYPE_CHAT
|
|
494
|
+
attrs.setdefault(SpanAttributes.LLM_REQUEST_TYPE, LLMRequestTypeValues.CHAT.value)
|
|
495
|
+
|
|
496
|
+
provider = raw_attrs.get(_GEN_AI_PROVIDER_NAME) or raw_attrs.get(
|
|
497
|
+
ATTR_GEN_AI_PROVIDER_NAME
|
|
498
|
+
)
|
|
499
|
+
if attrs.get(_GEN_AI_SYSTEM) in (None, "", AGENT_FRAMEWORK_SYSTEM):
|
|
500
|
+
attrs[_GEN_AI_SYSTEM] = str(provider).lower() if provider else AGENT_FRAMEWORK_SYSTEM
|
|
501
|
+
|
|
502
|
+
model = raw_attrs.get(SpanAttributes.LLM_REQUEST_MODEL) or raw_attrs.get(
|
|
503
|
+
_GEN_AI_RESPONSE_MODEL
|
|
504
|
+
)
|
|
505
|
+
_set_if_missing(attrs, SpanAttributes.LLM_REQUEST_MODEL, model)
|
|
506
|
+
|
|
507
|
+
input_tokens = _int_value(raw_attrs.get(_GEN_AI_USAGE_INPUT_TOKENS))
|
|
508
|
+
output_tokens = _int_value(raw_attrs.get(_GEN_AI_USAGE_OUTPUT_TOKENS))
|
|
509
|
+
if input_tokens is not None:
|
|
510
|
+
attrs.setdefault(_GEN_AI_USAGE_INPUT_TOKENS, input_tokens)
|
|
511
|
+
attrs.setdefault(SpanAttributes.LLM_USAGE_PROMPT_TOKENS, input_tokens)
|
|
512
|
+
if output_tokens is not None:
|
|
513
|
+
attrs.setdefault(_GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens)
|
|
514
|
+
attrs.setdefault(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, output_tokens)
|
|
515
|
+
if input_tokens is not None or output_tokens is not None:
|
|
516
|
+
attrs.setdefault(
|
|
517
|
+
SpanAttributes.LLM_USAGE_TOTAL_TOKENS,
|
|
518
|
+
(input_tokens or 0) + (output_tokens or 0),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
system_messages = _system_instruction_messages(
|
|
522
|
+
raw_attrs.get(ATTR_GEN_AI_SYSTEM_INSTRUCTIONS)
|
|
523
|
+
)
|
|
524
|
+
prompt_messages = [
|
|
525
|
+
*system_messages,
|
|
526
|
+
*_message_list(raw_attrs.get(ATTR_GEN_AI_INPUT_MESSAGES)),
|
|
527
|
+
]
|
|
528
|
+
completion_messages = _message_list(raw_attrs.get(ATTR_GEN_AI_OUTPUT_MESSAGES))
|
|
529
|
+
_apply_messages(
|
|
530
|
+
attrs,
|
|
531
|
+
source_messages=prompt_messages,
|
|
532
|
+
target_prefix=SpanAttributes.LLM_PROMPTS,
|
|
533
|
+
default_role="user",
|
|
534
|
+
)
|
|
535
|
+
_apply_messages(
|
|
536
|
+
attrs,
|
|
537
|
+
source_messages=completion_messages,
|
|
538
|
+
target_prefix=SpanAttributes.LLM_COMPLETIONS,
|
|
539
|
+
default_role="assistant",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if prompt_messages:
|
|
543
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_INPUT, _json_string(prompt_messages))
|
|
544
|
+
if completion_messages:
|
|
545
|
+
attrs.setdefault(
|
|
546
|
+
SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
|
|
547
|
+
_json_string(completion_messages),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
tool_definitions = _tool_definitions(raw_attrs.get(ATTR_GEN_AI_TOOL_DEFINITIONS))
|
|
551
|
+
if tool_definitions:
|
|
552
|
+
attrs.setdefault(
|
|
553
|
+
SpanAttributes.LLM_REQUEST_FUNCTIONS,
|
|
554
|
+
json.dumps(tool_definitions, default=str),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
attrs.setdefault(
|
|
558
|
+
SpanAttributes.TRACELOOP_ENTITY_NAME,
|
|
559
|
+
_entity_name_for_log_type(span, attrs, LOG_TYPE_CHAT),
|
|
560
|
+
)
|
|
561
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_PATH, "")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _apply_tool_attrs(
|
|
565
|
+
span: ReadableSpan,
|
|
566
|
+
attrs: dict[str, Any],
|
|
567
|
+
raw_attrs: Mapping[str, Any],
|
|
568
|
+
) -> None:
|
|
569
|
+
attrs[RESPAN_LOG_TYPE] = LOG_TYPE_TOOL
|
|
570
|
+
tool_name = _entity_name_for_log_type(span, raw_attrs, LOG_TYPE_TOOL)
|
|
571
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_NAME, tool_name)
|
|
572
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_PATH, tool_name)
|
|
573
|
+
|
|
574
|
+
arguments = raw_attrs.get(_GEN_AI_TOOL_CALL_ARGUMENTS) or raw_attrs.get(
|
|
575
|
+
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS
|
|
576
|
+
)
|
|
577
|
+
input_payload = {
|
|
578
|
+
"name": tool_name,
|
|
579
|
+
"arguments": _safe_json_loads(arguments),
|
|
580
|
+
}
|
|
581
|
+
call_id = raw_attrs.get(ATTR_GEN_AI_TOOL_CALL_ID)
|
|
582
|
+
if call_id:
|
|
583
|
+
input_payload["id"] = str(call_id)
|
|
584
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_INPUT, _json_string(input_payload))
|
|
585
|
+
|
|
586
|
+
result = raw_attrs.get(_GEN_AI_TOOL_CALL_RESULT) or raw_attrs.get(
|
|
587
|
+
ATTR_GEN_AI_TOOL_CALL_RESULT
|
|
588
|
+
)
|
|
589
|
+
if result is not None:
|
|
590
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, _json_string(result))
|
|
591
|
+
|
|
592
|
+
for key in list(attrs):
|
|
593
|
+
if key.startswith(_GEN_AI_TOOL_PREFIX):
|
|
594
|
+
attrs.pop(key, None)
|
|
595
|
+
for key in (
|
|
596
|
+
SpanAttributes.LLM_REQUEST_TYPE,
|
|
597
|
+
SpanAttributes.LLM_REQUEST_MODEL,
|
|
598
|
+
SpanAttributes.LLM_REQUEST_FUNCTIONS,
|
|
599
|
+
_GEN_AI_SYSTEM,
|
|
600
|
+
):
|
|
601
|
+
attrs.pop(key, None)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _apply_common_span_attrs(
|
|
605
|
+
span: ReadableSpan,
|
|
606
|
+
attrs: dict[str, Any],
|
|
607
|
+
raw_attrs: Mapping[str, Any],
|
|
608
|
+
log_type: str,
|
|
609
|
+
) -> None:
|
|
610
|
+
attrs[RESPAN_LOG_TYPE] = log_type
|
|
611
|
+
entity_name = _entity_name_for_log_type(span, raw_attrs, log_type)
|
|
612
|
+
attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name)
|
|
613
|
+
attrs.setdefault(
|
|
614
|
+
SpanAttributes.TRACELOOP_ENTITY_PATH,
|
|
615
|
+
"" if log_type == LOG_TYPE_WORKFLOW else entity_name,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
input_value = raw_attrs.get(ATTR_GEN_AI_INPUT_MESSAGES)
|
|
619
|
+
output_value = raw_attrs.get(ATTR_GEN_AI_OUTPUT_MESSAGES)
|
|
620
|
+
if input_value is not None:
|
|
621
|
+
attrs.setdefault(
|
|
622
|
+
SpanAttributes.TRACELOOP_ENTITY_INPUT,
|
|
623
|
+
_json_string(_safe_json_loads(input_value)),
|
|
624
|
+
)
|
|
625
|
+
if output_value is not None:
|
|
626
|
+
attrs.setdefault(
|
|
627
|
+
SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
|
|
628
|
+
_json_string(_safe_json_loads(output_value)),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
conversation_id = raw_attrs.get(ATTR_GEN_AI_CONVERSATION_ID)
|
|
632
|
+
if conversation_id and attrs.get(RESPAN_SESSION_ID) in (None, ""):
|
|
633
|
+
attrs[RESPAN_SESSION_ID] = str(conversation_id)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _span_status_is_error(span: ReadableSpan) -> bool:
|
|
637
|
+
status = getattr(span, "status", None)
|
|
638
|
+
status_code = getattr(status, "status_code", None)
|
|
639
|
+
status_name = getattr(status_code, "name", status_code)
|
|
640
|
+
return str(status_name).upper() == "ERROR"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _error_message(span: ReadableSpan, raw_attrs: Mapping[str, Any]) -> str | None:
|
|
644
|
+
for key in (ERROR_MESSAGE_ATTR, _EXCEPTION_MESSAGE_ATTR):
|
|
645
|
+
value = raw_attrs.get(key)
|
|
646
|
+
if value not in (None, ""):
|
|
647
|
+
return str(value)
|
|
648
|
+
|
|
649
|
+
status_description = getattr(getattr(span, "status", None), "description", None)
|
|
650
|
+
if status_description not in (None, ""):
|
|
651
|
+
return str(status_description)
|
|
652
|
+
|
|
653
|
+
for key in (_ERROR_TYPE_ATTR, _EXCEPTION_TYPE_ATTR):
|
|
654
|
+
value = raw_attrs.get(key)
|
|
655
|
+
if value not in (None, ""):
|
|
656
|
+
return str(value)
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _apply_error_attrs(
|
|
661
|
+
span: ReadableSpan,
|
|
662
|
+
attrs: dict[str, Any],
|
|
663
|
+
raw_attrs: Mapping[str, Any],
|
|
664
|
+
) -> None:
|
|
665
|
+
is_error = any(
|
|
666
|
+
raw_attrs.get(key) not in (None, "")
|
|
667
|
+
for key in (
|
|
668
|
+
ERROR_MESSAGE_ATTR,
|
|
669
|
+
_EXCEPTION_MESSAGE_ATTR,
|
|
670
|
+
_ERROR_TYPE_ATTR,
|
|
671
|
+
_EXCEPTION_TYPE_ATTR,
|
|
672
|
+
)
|
|
673
|
+
) or _span_status_is_error(span)
|
|
674
|
+
if not is_error:
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
status_code = _int_value(attrs.get(_STATUS_CODE_ATTR))
|
|
678
|
+
if status_code is None or status_code < 400:
|
|
679
|
+
attrs[_STATUS_CODE_ATTR] = 500
|
|
680
|
+
|
|
681
|
+
message = _error_message(span=span, raw_attrs=raw_attrs)
|
|
682
|
+
if message:
|
|
683
|
+
attrs.setdefault(ERROR_MESSAGE_ATTR, message)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _cleanup_attrs(attrs: dict[str, Any]) -> None:
|
|
687
|
+
attrs.pop(SpanAttributes.TRACELOOP_SPAN_KIND, None)
|
|
688
|
+
for key in _RAW_ATTRS_TO_STRIP | _OFF_CONTRACT_ALIASES:
|
|
689
|
+
attrs.pop(key, None)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class AgentFrameworkSpanProcessor(SpanProcessor):
|
|
693
|
+
"""Translate Microsoft Agent Framework spans before Respan export."""
|
|
694
|
+
|
|
695
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
699
|
+
raw_attrs = dict(getattr(span, "attributes", None) or {})
|
|
700
|
+
if not is_agent_framework_span(span, raw_attrs):
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
attrs = dict(getattr(span, "_attributes", None) or raw_attrs)
|
|
704
|
+
log_type = _log_type(span, raw_attrs)
|
|
705
|
+
if log_type is None:
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
if log_type == LOG_TYPE_CHAT:
|
|
709
|
+
_apply_chat_attrs(span=span, attrs=attrs, raw_attrs=raw_attrs)
|
|
710
|
+
elif log_type == LOG_TYPE_TOOL:
|
|
711
|
+
_apply_tool_attrs(span=span, attrs=attrs, raw_attrs=raw_attrs)
|
|
712
|
+
else:
|
|
713
|
+
_apply_common_span_attrs(
|
|
714
|
+
span=span,
|
|
715
|
+
attrs=attrs,
|
|
716
|
+
raw_attrs=raw_attrs,
|
|
717
|
+
log_type=log_type,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
_apply_error_attrs(span=span, attrs=attrs, raw_attrs=raw_attrs)
|
|
721
|
+
_cleanup_attrs(attrs)
|
|
722
|
+
span._attributes = attrs
|
|
723
|
+
|
|
724
|
+
def shutdown(self) -> None:
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
728
|
+
return True
|