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.
@@ -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,5 @@
1
+ """Respan instrumentation for Microsoft Agent Framework."""
2
+
3
+ from ._instrumentation import MicrosoftAgentFrameworkInstrumentor
4
+
5
+ __all__ = ["MicrosoftAgentFrameworkInstrumentor"]
@@ -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