openinference-instrumentation-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.
- openinference_instrumentation_agent_framework-0.1.0/.gitignore +5 -0
- openinference_instrumentation_agent_framework-0.1.0/PKG-INFO +219 -0
- openinference_instrumentation_agent_framework-0.1.0/README.md +188 -0
- openinference_instrumentation_agent_framework-0.1.0/pyproject.toml +95 -0
- openinference_instrumentation_agent_framework-0.1.0/src/openinference/instrumentation/agent_framework/__init__.py +47 -0
- openinference_instrumentation_agent_framework-0.1.0/src/openinference/instrumentation/agent_framework/package.py +2 -0
- openinference_instrumentation_agent_framework-0.1.0/src/openinference/instrumentation/agent_framework/processor.py +138 -0
- openinference_instrumentation_agent_framework-0.1.0/src/openinference/instrumentation/agent_framework/semantic_conventions.py +587 -0
- openinference_instrumentation_agent_framework-0.1.0/src/openinference/instrumentation/agent_framework/utils.py +23 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openinference-instrumentation-agent-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenInference span processor for Microsoft Agent Framework - transforms native OpenTelemetry spans to OpenInference format
|
|
5
|
+
Project-URL: Homepage, https://github.com/Arize-ai/openinference/tree/main/python/instrumentation/openinference-instrumentation-agent-framework
|
|
6
|
+
Author-email: OpenInference Authors <oss@arize.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: <3.15,>=3.10
|
|
19
|
+
Requires-Dist: openinference-semantic-conventions>=0.1.25
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.39.0
|
|
21
|
+
Requires-Dist: opentelemetry-sdk>=1.39.0
|
|
22
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.52b0
|
|
23
|
+
Requires-Dist: typing-extensions
|
|
24
|
+
Provides-Extra: instruments
|
|
25
|
+
Requires-Dist: agent-framework>=1.0.0b260130; extra == 'instruments'
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest-recording; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# OpenInference Microsoft Agent Framework Instrumentation
|
|
33
|
+
|
|
34
|
+
OpenInference span processor for Microsoft Agent Framework that transforms native OpenTelemetry spans to OpenInference format for compatibility with OpenInference-compliant backends like [Arize Phoenix](https://github.com/Arize-ai/phoenix).
|
|
35
|
+
|
|
36
|
+
**Tested with agent-framework `1.0.0b260130` (January 30, 2026)**
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install openinference-instrumentation-agent-framework
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Install with agent-framework:
|
|
45
|
+
```bash
|
|
46
|
+
pip install openinference-instrumentation-agent-framework[instruments]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Overview
|
|
50
|
+
|
|
51
|
+
Microsoft Agent Framework emits telemetry using GenAI semantic conventions (`gen_ai.*` attributes). This package provides a `SpanProcessor` that transforms these spans to OpenInference format, enabling compatibility with observability tools that support the OpenInference standard.
|
|
52
|
+
|
|
53
|
+
**Note:** Agent Framework is in beta and its API may change between versions. This instrumentation tracks the latest stable release.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Basic Setup
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from opentelemetry import trace
|
|
61
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
62
|
+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
|
|
63
|
+
from agent_framework.observability import enable_instrumentation
|
|
64
|
+
from openinference.instrumentation.agent_framework import (
|
|
65
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Create tracer provider
|
|
69
|
+
tracer_provider = TracerProvider()
|
|
70
|
+
|
|
71
|
+
# Add OpenInference processor to transform spans
|
|
72
|
+
tracer_provider.add_span_processor(
|
|
73
|
+
AgentFrameworkToOpenInferenceProcessor()
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Add exporter (Console for demo, use OTLP for Phoenix)
|
|
77
|
+
tracer_provider.add_span_processor(
|
|
78
|
+
SimpleSpanProcessor(ConsoleSpanExporter())
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Set as global tracer provider
|
|
82
|
+
trace.set_tracer_provider(tracer_provider)
|
|
83
|
+
|
|
84
|
+
# Enable agent-framework instrumentation
|
|
85
|
+
enable_instrumentation(enable_sensitive_data=True)
|
|
86
|
+
|
|
87
|
+
# Use framework normally - spans will be transformed automatically
|
|
88
|
+
from agent_framework.openai import OpenAIChatClient
|
|
89
|
+
|
|
90
|
+
client = OpenAIChatClient(model_id="gpt-4o-mini", api_key="your-key")
|
|
91
|
+
agent = client.as_agent(name="Assistant", instructions="You are helpful.")
|
|
92
|
+
response = await agent.run("Hello!")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### With Phoenix (Arize)
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from opentelemetry import trace
|
|
99
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
100
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
101
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
102
|
+
from agent_framework.observability import enable_instrumentation
|
|
103
|
+
from openinference.instrumentation.agent_framework import (
|
|
104
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Configure Phoenix endpoint
|
|
108
|
+
endpoint = "http://localhost:6006/v1/traces"
|
|
109
|
+
|
|
110
|
+
# Setup tracer with OpenInference processor
|
|
111
|
+
tracer_provider = TracerProvider()
|
|
112
|
+
tracer_provider.add_span_processor(AgentFrameworkToOpenInferenceProcessor())
|
|
113
|
+
tracer_provider.add_span_processor(
|
|
114
|
+
SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
|
|
115
|
+
)
|
|
116
|
+
trace.set_tracer_provider(tracer_provider)
|
|
117
|
+
|
|
118
|
+
# Enable instrumentation
|
|
119
|
+
enable_instrumentation(enable_sensitive_data=True)
|
|
120
|
+
|
|
121
|
+
# Your agent code here
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Transformation Details
|
|
125
|
+
|
|
126
|
+
### Span Kind Mapping
|
|
127
|
+
|
|
128
|
+
| MS Agent Framework Operation | OpenInference Span Kind |
|
|
129
|
+
|------------------------------|-------------------------|
|
|
130
|
+
| `chat` | LLM |
|
|
131
|
+
| `execute_tool` | TOOL |
|
|
132
|
+
| `invoke_agent` | AGENT |
|
|
133
|
+
| `workflow.run` | CHAIN |
|
|
134
|
+
| `executor.process` | CHAIN |
|
|
135
|
+
|
|
136
|
+
### Attribute Mapping
|
|
137
|
+
|
|
138
|
+
| Source (GenAI) | Target (OpenInference) |
|
|
139
|
+
|----------------|------------------------|
|
|
140
|
+
| `gen_ai.request.model` | `llm.model_name` |
|
|
141
|
+
| `gen_ai.provider.name` | `llm.provider` |
|
|
142
|
+
| `gen_ai.usage.input_tokens` | `llm.token_count.prompt` |
|
|
143
|
+
| `gen_ai.usage.output_tokens` | `llm.token_count.completion` |
|
|
144
|
+
| `gen_ai.input.messages` | `llm.input_messages.*` (flattened) |
|
|
145
|
+
| `gen_ai.output.messages` | `llm.output_messages.*` (flattened) |
|
|
146
|
+
| `gen_ai.tool.name` | `tool.name` |
|
|
147
|
+
| `gen_ai.tool.call.id` | `tool.call_id` |
|
|
148
|
+
| `gen_ai.tool.call.arguments` | `tool.parameters` |
|
|
149
|
+
| `gen_ai.conversation.id` | `session.id` |
|
|
150
|
+
|
|
151
|
+
### Message Format Transformation
|
|
152
|
+
|
|
153
|
+
MS Agent Framework messages:
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"role": "user",
|
|
157
|
+
"parts": [
|
|
158
|
+
{"type": "text", "content": "Hello"}
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Transformed to OpenInference flattened format:
|
|
164
|
+
```
|
|
165
|
+
llm.input_messages.0.message.role = "user"
|
|
166
|
+
llm.input_messages.0.message.content = "Hello"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Debug Mode
|
|
170
|
+
|
|
171
|
+
Enable debug mode to log transformation details:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
processor = AgentFrameworkToOpenInferenceProcessor(debug=True)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Requirements
|
|
178
|
+
|
|
179
|
+
- Python >= 3.10, < 3.15
|
|
180
|
+
- opentelemetry-api >= 1.39.0
|
|
181
|
+
- opentelemetry-sdk >= 1.39.0
|
|
182
|
+
- openinference-semantic-conventions >= 0.1.25
|
|
183
|
+
- agent-framework >= 1.0.0b260130 (optional, install with `[instruments]` extra)
|
|
184
|
+
|
|
185
|
+
## Important Notes
|
|
186
|
+
|
|
187
|
+
### Agent Framework API Stability
|
|
188
|
+
|
|
189
|
+
Microsoft Agent Framework is in active beta development. API changes between versions are possible:
|
|
190
|
+
- This instrumentation is tested against `agent-framework==1.0.0b260130`
|
|
191
|
+
- The `-latest` test variant tracks breaking changes in new releases
|
|
192
|
+
- If you encounter API compatibility issues, pin to the tested version:
|
|
193
|
+
```bash
|
|
194
|
+
pip install agent-framework==1.0.0b260130
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Sensitive Data
|
|
198
|
+
|
|
199
|
+
Set `enable_sensitive_data=True` when calling `enable_instrumentation()` to capture message content in traces. This is required for full observability but may include PII.
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
### Running Tests
|
|
204
|
+
|
|
205
|
+
Tests use VCR cassettes to replay recorded API interactions:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Run with recorded cassettes (no API key needed)
|
|
209
|
+
pytest tests/test_processor.py -v
|
|
210
|
+
|
|
211
|
+
# Re-record cassettes (requires OPENAI_API_KEY)
|
|
212
|
+
export OPENAI_API_KEY=your_key
|
|
213
|
+
rm -rf tests/cassettes/
|
|
214
|
+
pytest tests/test_processor.py -v --record-mode=rewrite
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
Apache-2.0
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# OpenInference Microsoft Agent Framework Instrumentation
|
|
2
|
+
|
|
3
|
+
OpenInference span processor for Microsoft Agent Framework that transforms native OpenTelemetry spans to OpenInference format for compatibility with OpenInference-compliant backends like [Arize Phoenix](https://github.com/Arize-ai/phoenix).
|
|
4
|
+
|
|
5
|
+
**Tested with agent-framework `1.0.0b260130` (January 30, 2026)**
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install openinference-instrumentation-agent-framework
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install with agent-framework:
|
|
14
|
+
```bash
|
|
15
|
+
pip install openinference-instrumentation-agent-framework[instruments]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
Microsoft Agent Framework emits telemetry using GenAI semantic conventions (`gen_ai.*` attributes). This package provides a `SpanProcessor` that transforms these spans to OpenInference format, enabling compatibility with observability tools that support the OpenInference standard.
|
|
21
|
+
|
|
22
|
+
**Note:** Agent Framework is in beta and its API may change between versions. This instrumentation tracks the latest stable release.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Basic Setup
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from opentelemetry import trace
|
|
30
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
31
|
+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
|
|
32
|
+
from agent_framework.observability import enable_instrumentation
|
|
33
|
+
from openinference.instrumentation.agent_framework import (
|
|
34
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Create tracer provider
|
|
38
|
+
tracer_provider = TracerProvider()
|
|
39
|
+
|
|
40
|
+
# Add OpenInference processor to transform spans
|
|
41
|
+
tracer_provider.add_span_processor(
|
|
42
|
+
AgentFrameworkToOpenInferenceProcessor()
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Add exporter (Console for demo, use OTLP for Phoenix)
|
|
46
|
+
tracer_provider.add_span_processor(
|
|
47
|
+
SimpleSpanProcessor(ConsoleSpanExporter())
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Set as global tracer provider
|
|
51
|
+
trace.set_tracer_provider(tracer_provider)
|
|
52
|
+
|
|
53
|
+
# Enable agent-framework instrumentation
|
|
54
|
+
enable_instrumentation(enable_sensitive_data=True)
|
|
55
|
+
|
|
56
|
+
# Use framework normally - spans will be transformed automatically
|
|
57
|
+
from agent_framework.openai import OpenAIChatClient
|
|
58
|
+
|
|
59
|
+
client = OpenAIChatClient(model_id="gpt-4o-mini", api_key="your-key")
|
|
60
|
+
agent = client.as_agent(name="Assistant", instructions="You are helpful.")
|
|
61
|
+
response = await agent.run("Hello!")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### With Phoenix (Arize)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from opentelemetry import trace
|
|
68
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
69
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
70
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
71
|
+
from agent_framework.observability import enable_instrumentation
|
|
72
|
+
from openinference.instrumentation.agent_framework import (
|
|
73
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Configure Phoenix endpoint
|
|
77
|
+
endpoint = "http://localhost:6006/v1/traces"
|
|
78
|
+
|
|
79
|
+
# Setup tracer with OpenInference processor
|
|
80
|
+
tracer_provider = TracerProvider()
|
|
81
|
+
tracer_provider.add_span_processor(AgentFrameworkToOpenInferenceProcessor())
|
|
82
|
+
tracer_provider.add_span_processor(
|
|
83
|
+
SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
|
|
84
|
+
)
|
|
85
|
+
trace.set_tracer_provider(tracer_provider)
|
|
86
|
+
|
|
87
|
+
# Enable instrumentation
|
|
88
|
+
enable_instrumentation(enable_sensitive_data=True)
|
|
89
|
+
|
|
90
|
+
# Your agent code here
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Transformation Details
|
|
94
|
+
|
|
95
|
+
### Span Kind Mapping
|
|
96
|
+
|
|
97
|
+
| MS Agent Framework Operation | OpenInference Span Kind |
|
|
98
|
+
|------------------------------|-------------------------|
|
|
99
|
+
| `chat` | LLM |
|
|
100
|
+
| `execute_tool` | TOOL |
|
|
101
|
+
| `invoke_agent` | AGENT |
|
|
102
|
+
| `workflow.run` | CHAIN |
|
|
103
|
+
| `executor.process` | CHAIN |
|
|
104
|
+
|
|
105
|
+
### Attribute Mapping
|
|
106
|
+
|
|
107
|
+
| Source (GenAI) | Target (OpenInference) |
|
|
108
|
+
|----------------|------------------------|
|
|
109
|
+
| `gen_ai.request.model` | `llm.model_name` |
|
|
110
|
+
| `gen_ai.provider.name` | `llm.provider` |
|
|
111
|
+
| `gen_ai.usage.input_tokens` | `llm.token_count.prompt` |
|
|
112
|
+
| `gen_ai.usage.output_tokens` | `llm.token_count.completion` |
|
|
113
|
+
| `gen_ai.input.messages` | `llm.input_messages.*` (flattened) |
|
|
114
|
+
| `gen_ai.output.messages` | `llm.output_messages.*` (flattened) |
|
|
115
|
+
| `gen_ai.tool.name` | `tool.name` |
|
|
116
|
+
| `gen_ai.tool.call.id` | `tool.call_id` |
|
|
117
|
+
| `gen_ai.tool.call.arguments` | `tool.parameters` |
|
|
118
|
+
| `gen_ai.conversation.id` | `session.id` |
|
|
119
|
+
|
|
120
|
+
### Message Format Transformation
|
|
121
|
+
|
|
122
|
+
MS Agent Framework messages:
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"role": "user",
|
|
126
|
+
"parts": [
|
|
127
|
+
{"type": "text", "content": "Hello"}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Transformed to OpenInference flattened format:
|
|
133
|
+
```
|
|
134
|
+
llm.input_messages.0.message.role = "user"
|
|
135
|
+
llm.input_messages.0.message.content = "Hello"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Debug Mode
|
|
139
|
+
|
|
140
|
+
Enable debug mode to log transformation details:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
processor = AgentFrameworkToOpenInferenceProcessor(debug=True)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Requirements
|
|
147
|
+
|
|
148
|
+
- Python >= 3.10, < 3.15
|
|
149
|
+
- opentelemetry-api >= 1.39.0
|
|
150
|
+
- opentelemetry-sdk >= 1.39.0
|
|
151
|
+
- openinference-semantic-conventions >= 0.1.25
|
|
152
|
+
- agent-framework >= 1.0.0b260130 (optional, install with `[instruments]` extra)
|
|
153
|
+
|
|
154
|
+
## Important Notes
|
|
155
|
+
|
|
156
|
+
### Agent Framework API Stability
|
|
157
|
+
|
|
158
|
+
Microsoft Agent Framework is in active beta development. API changes between versions are possible:
|
|
159
|
+
- This instrumentation is tested against `agent-framework==1.0.0b260130`
|
|
160
|
+
- The `-latest` test variant tracks breaking changes in new releases
|
|
161
|
+
- If you encounter API compatibility issues, pin to the tested version:
|
|
162
|
+
```bash
|
|
163
|
+
pip install agent-framework==1.0.0b260130
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Sensitive Data
|
|
167
|
+
|
|
168
|
+
Set `enable_sensitive_data=True` when calling `enable_instrumentation()` to capture message content in traces. This is required for full observability but may include PII.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
### Running Tests
|
|
173
|
+
|
|
174
|
+
Tests use VCR cassettes to replay recorded API interactions:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Run with recorded cassettes (no API key needed)
|
|
178
|
+
pytest tests/test_processor.py -v
|
|
179
|
+
|
|
180
|
+
# Re-record cassettes (requires OPENAI_API_KEY)
|
|
181
|
+
export OPENAI_API_KEY=your_key
|
|
182
|
+
rm -rf tests/cassettes/
|
|
183
|
+
pytest tests/test_processor.py -v --record-mode=rewrite
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
Apache-2.0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openinference-instrumentation-agent-framework"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "OpenInference span processor for Microsoft Agent Framework - transforms native OpenTelemetry spans to OpenInference format"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10, <3.15"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "OpenInference Authors", email = "oss@arize.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"opentelemetry-api>=1.39.0",
|
|
29
|
+
"opentelemetry-sdk>=1.39.0",
|
|
30
|
+
"opentelemetry-semantic-conventions>=0.52b0",
|
|
31
|
+
"openinference-semantic-conventions>=0.1.25",
|
|
32
|
+
"typing-extensions",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
instruments = [
|
|
37
|
+
"agent-framework >= 1.0.0b260130",
|
|
38
|
+
]
|
|
39
|
+
test = [
|
|
40
|
+
"pytest",
|
|
41
|
+
"pytest-asyncio",
|
|
42
|
+
"pytest-recording",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/Arize-ai/openinference/tree/main/python/instrumentation/openinference-instrumentation-agent-framework"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
path = "src/openinference/instrumentation/agent_framework/__init__.py"
|
|
50
|
+
pattern = '__version__ = "(?P<version>[^"]+)"'
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = [
|
|
54
|
+
"/src",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/openinference"]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
asyncio_mode = "auto"
|
|
62
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
63
|
+
testpaths = [
|
|
64
|
+
"tests",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
strict = true
|
|
69
|
+
explicit_package_bases = true
|
|
70
|
+
exclude = [
|
|
71
|
+
"examples",
|
|
72
|
+
"dist",
|
|
73
|
+
"sdist",
|
|
74
|
+
"tests",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[[tool.mypy.overrides]]
|
|
78
|
+
ignore_missing_imports = true
|
|
79
|
+
module = [
|
|
80
|
+
"agent_framework.*",
|
|
81
|
+
"openinference.instrumentation.agent_framework.*",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
line-length = 100
|
|
86
|
+
target-version = "py310"
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.per-file-ignores]
|
|
89
|
+
"*.ipynb" = ["E402", "E501"]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint]
|
|
92
|
+
select = ["E", "F", "W", "I"]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.isort]
|
|
95
|
+
force-single-line = false
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OpenInference instrumentation for Microsoft Agent Framework.
|
|
2
|
+
|
|
3
|
+
This package provides a SpanProcessor that transforms Microsoft Agent Framework's
|
|
4
|
+
native OpenTelemetry spans (using GenAI semantic conventions) to OpenInference format
|
|
5
|
+
for compatibility with OpenInference-compliant backends like Arize Phoenix.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
```python
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from agent_framework.observability import configure_otel_providers
|
|
12
|
+
from openinference.instrumentation.agent_framework import (
|
|
13
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Configure MS Agent Framework's native telemetry
|
|
17
|
+
# Set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT env var for Phoenix endpoint
|
|
18
|
+
configure_otel_providers(enable_sensitive_data=True)
|
|
19
|
+
|
|
20
|
+
# Add OpenInference processor to transform spans
|
|
21
|
+
tracer_provider = trace.get_tracer_provider()
|
|
22
|
+
if isinstance(tracer_provider, TracerProvider):
|
|
23
|
+
tracer_provider.add_span_processor(
|
|
24
|
+
AgentFrameworkToOpenInferenceProcessor()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Use framework normally - spans will be transformed automatically
|
|
28
|
+
from agent_framework.openai import OpenAIChatClient
|
|
29
|
+
|
|
30
|
+
client = OpenAIChatClient(model_id="gpt-4o-mini")
|
|
31
|
+
agent = client.create_agent(name="Assistant")
|
|
32
|
+
response = await agent.run("Hello!")
|
|
33
|
+
```
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
from openinference.instrumentation.agent_framework.processor import (
|
|
39
|
+
AgentFrameworkToOpenInferenceProcessor,
|
|
40
|
+
)
|
|
41
|
+
from openinference.instrumentation.agent_framework.utils import is_openinference_span
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AgentFrameworkToOpenInferenceProcessor",
|
|
45
|
+
"is_openinference_span",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Microsoft Agent Framework to OpenInference Span Processor.
|
|
2
|
+
|
|
3
|
+
This module provides a span processor that converts Microsoft Agent Framework's native
|
|
4
|
+
OpenTelemetry spans (using GenAI semantic conventions) to OpenInference format for
|
|
5
|
+
compatibility with OpenInference-compliant backends like Arize Phoenix.
|
|
6
|
+
|
|
7
|
+
The processor transforms:
|
|
8
|
+
- GenAI attributes (gen_ai.*) to OpenInference attributes (llm.*, tool.*, etc.)
|
|
9
|
+
- Span names to OpenInference span kinds (AGENT, CHAIN, TOOL, LLM)
|
|
10
|
+
- Message structures to OpenInference flattened format
|
|
11
|
+
- Token usage attributes to OpenInference format
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
|
|
18
|
+
from opentelemetry.trace import Status, StatusCode
|
|
19
|
+
|
|
20
|
+
from openinference.instrumentation.agent_framework import __version__
|
|
21
|
+
from openinference.instrumentation.agent_framework.semantic_conventions import get_attributes
|
|
22
|
+
from openinference.instrumentation.agent_framework.utils import SpanFilter, should_export_span
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentFrameworkToOpenInferenceProcessor(SpanProcessor):
|
|
28
|
+
"""
|
|
29
|
+
SpanProcessor that converts Microsoft Agent Framework telemetry attributes
|
|
30
|
+
to OpenInference format for compatibility with OpenInference-compliant backends.
|
|
31
|
+
|
|
32
|
+
This processor intercepts spans on completion and transforms their attributes
|
|
33
|
+
from the GenAI semantic conventions used by Microsoft Agent Framework to the
|
|
34
|
+
OpenInference semantic conventions.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
```python
|
|
38
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
39
|
+
from openinference.instrumentation.agent_framework import (
|
|
40
|
+
AgentFrameworkToOpenInferenceProcessor
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
provider = TracerProvider()
|
|
44
|
+
provider.add_span_processor(
|
|
45
|
+
AgentFrameworkToOpenInferenceProcessor(debug=False)
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, debug: bool = False, span_filter: Optional[SpanFilter] = None) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Initialize the processor.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
debug: Whether to log debug information about transformations
|
|
56
|
+
span_filter: Optional filter function to determine if a span should be processed
|
|
57
|
+
"""
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.debug = debug
|
|
60
|
+
self._span_filter = span_filter
|
|
61
|
+
|
|
62
|
+
def on_start(self, span: ReadableSpan, parent_context: Any = None) -> None:
|
|
63
|
+
"""Called when a span is started. No-op for this processor."""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Called when a span ends. Transform the span attributes from Microsoft Agent
|
|
69
|
+
Framework GenAI format to OpenInference format.
|
|
70
|
+
"""
|
|
71
|
+
if not hasattr(span, "_attributes") or not span._attributes:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# Get span context information
|
|
76
|
+
span_id = span.get_span_context().span_id # type: ignore[no-untyped-call]
|
|
77
|
+
|
|
78
|
+
# Get OpenInference attributes from the transformation function
|
|
79
|
+
openinference_attributes_iter = get_attributes(
|
|
80
|
+
dict(span._attributes), span.name, span_id
|
|
81
|
+
)
|
|
82
|
+
openinference_attributes = dict(openinference_attributes_iter)
|
|
83
|
+
|
|
84
|
+
# Merge with original attributes to preserve GenAI attributes that weren't transformed
|
|
85
|
+
span._attributes = {**span.attributes, **openinference_attributes} # type: ignore[dict-item]
|
|
86
|
+
|
|
87
|
+
# MS Agent Framework only sets ERROR status, not OK - set OK for successful spans
|
|
88
|
+
if not span.status.status_code == StatusCode.ERROR:
|
|
89
|
+
span._status = Status(status_code=StatusCode.OK)
|
|
90
|
+
|
|
91
|
+
# Determine if the span should be exported
|
|
92
|
+
if should_export_span(span, self._span_filter):
|
|
93
|
+
super().on_end(span)
|
|
94
|
+
|
|
95
|
+
if self.debug:
|
|
96
|
+
logger.info(
|
|
97
|
+
"span_name=<%s>, trans_attrs=<%d> | transformed span",
|
|
98
|
+
span.name,
|
|
99
|
+
len(openinference_attributes),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
span._status = Status(status_code=StatusCode.ERROR, description=str(e))
|
|
104
|
+
logger.exception(e)
|
|
105
|
+
logger.warning(f"Error processing span in AgentFrameworkToOpenInferenceProcessor: {e}")
|
|
106
|
+
|
|
107
|
+
def shutdown(self) -> None:
|
|
108
|
+
"""Shutdown the processor."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def force_flush(self, timeout_millis: Optional[int] = None) -> bool:
|
|
112
|
+
"""Force flush any pending data."""
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def get_processor_info(self) -> Dict[str, Any]:
|
|
116
|
+
"""Get information about this processor's capabilities."""
|
|
117
|
+
return {
|
|
118
|
+
"processor_name": "AgentFrameworkToOpenInferenceProcessor",
|
|
119
|
+
"version": __version__,
|
|
120
|
+
"debug_enabled": self.debug,
|
|
121
|
+
"supported_span_kinds": ["LLM", "AGENT", "CHAIN", "TOOL"],
|
|
122
|
+
"supported_operations": [
|
|
123
|
+
"chat",
|
|
124
|
+
"execute_tool",
|
|
125
|
+
"invoke_agent",
|
|
126
|
+
"create_agent",
|
|
127
|
+
"workflow.run",
|
|
128
|
+
"executor.process",
|
|
129
|
+
],
|
|
130
|
+
"features": [
|
|
131
|
+
"Message extraction and transformation",
|
|
132
|
+
"Token usage mapping",
|
|
133
|
+
"Tool call processing",
|
|
134
|
+
"Graph node hierarchy mapping",
|
|
135
|
+
"Workflow/executor span support",
|
|
136
|
+
"Invocation parameters mapping",
|
|
137
|
+
],
|
|
138
|
+
}
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Semantic conventions for Microsoft Agent Framework telemetry.
|
|
2
|
+
|
|
3
|
+
This module defines attribute constants used by Microsoft Agent Framework's
|
|
4
|
+
OpenTelemetry instrumentation and provides transformation functions to convert
|
|
5
|
+
GenAI attributes to OpenInference format.
|
|
6
|
+
|
|
7
|
+
Reference: agent_framework/observability.py OtelAttr enum
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
logger.addHandler(logging.NullHandler())
|
|
16
|
+
|
|
17
|
+
# Operation names
|
|
18
|
+
OPERATION = "gen_ai.operation.name"
|
|
19
|
+
CHAT_COMPLETION_OPERATION = "chat"
|
|
20
|
+
TOOL_EXECUTION_OPERATION = "execute_tool"
|
|
21
|
+
AGENT_INVOKE_OPERATION = "invoke_agent"
|
|
22
|
+
|
|
23
|
+
# Provider and system
|
|
24
|
+
PROVIDER_NAME = "gen_ai.provider.name"
|
|
25
|
+
|
|
26
|
+
# Request attributes
|
|
27
|
+
LLM_REQUEST_MODEL = "gen_ai.request.model"
|
|
28
|
+
LLM_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
|
|
29
|
+
LLM_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
|
|
30
|
+
LLM_REQUEST_TOP_P = "gen_ai.request.top_p"
|
|
31
|
+
|
|
32
|
+
# Response attributes
|
|
33
|
+
LLM_RESPONSE_MODEL = "gen_ai.response.model"
|
|
34
|
+
FINISH_REASONS = "gen_ai.response.finish_reasons"
|
|
35
|
+
RESPONSE_ID = "gen_ai.response.id"
|
|
36
|
+
|
|
37
|
+
# Usage attributes
|
|
38
|
+
INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
39
|
+
OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
40
|
+
|
|
41
|
+
# Tool attributes
|
|
42
|
+
TOOL_CALL_ID = "gen_ai.tool.call.id"
|
|
43
|
+
TOOL_DESCRIPTION = "gen_ai.tool.description"
|
|
44
|
+
TOOL_NAME = "gen_ai.tool.name"
|
|
45
|
+
TOOL_DEFINITIONS = "gen_ai.tool.definitions"
|
|
46
|
+
TOOL_ARGUMENTS = "gen_ai.tool.call.arguments"
|
|
47
|
+
TOOL_RESULT = "gen_ai.tool.call.result"
|
|
48
|
+
|
|
49
|
+
# Agent attributes
|
|
50
|
+
AGENT_ID = "gen_ai.agent.id"
|
|
51
|
+
AGENT_NAME = "gen_ai.agent.name"
|
|
52
|
+
AGENT_DESCRIPTION = "gen_ai.agent.description"
|
|
53
|
+
CONVERSATION_ID = "gen_ai.conversation.id"
|
|
54
|
+
|
|
55
|
+
# Message attributes
|
|
56
|
+
INPUT_MESSAGES = "gen_ai.input.messages"
|
|
57
|
+
OUTPUT_MESSAGES = "gen_ai.output.messages"
|
|
58
|
+
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
|
59
|
+
|
|
60
|
+
# Workflow attributes
|
|
61
|
+
WORKFLOW_ID = "workflow.id"
|
|
62
|
+
WORKFLOW_NAME = "workflow.name"
|
|
63
|
+
WORKFLOW_RUN_SPAN = "workflow.run"
|
|
64
|
+
|
|
65
|
+
# Executor attributes
|
|
66
|
+
EXECUTOR_ID = "executor.id"
|
|
67
|
+
EXECUTOR_TYPE = "executor.type"
|
|
68
|
+
EXECUTOR_PROCESS_SPAN = "executor.process"
|
|
69
|
+
|
|
70
|
+
# Edge group attributes
|
|
71
|
+
EDGE_GROUP_TYPE = "edge_group.type"
|
|
72
|
+
EDGE_GROUP_ID = "edge_group.id"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def safe_json_dumps(obj: Any) -> str:
|
|
76
|
+
"""Safely serialize an object to JSON string.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
obj: Object to serialize
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
JSON string representation
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return json.dumps(obj, default=str, ensure_ascii=False)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return str(obj)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_attributes(
|
|
91
|
+
attrs: Dict[str, Any], span_name: str, span_id: int
|
|
92
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
93
|
+
"""
|
|
94
|
+
Extract OpenInference attributes from Microsoft Agent Framework GenAI attributes.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
attrs: Original span attributes with GenAI semantic conventions
|
|
98
|
+
span_name: The span name
|
|
99
|
+
span_id: The span context ID
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Iterator of (key, value) pairs for OpenInference attributes
|
|
103
|
+
"""
|
|
104
|
+
# Determine span kind first as it affects other attribute extraction
|
|
105
|
+
span_kind = _determine_span_kind(span_name, attrs)
|
|
106
|
+
yield "openinference.span.kind", span_kind
|
|
107
|
+
|
|
108
|
+
# Extract graph node attributes for visualization hierarchy
|
|
109
|
+
yield from _extract_graph_node_attributes(span_id, attrs, span_kind)
|
|
110
|
+
|
|
111
|
+
# Extract model and provider info
|
|
112
|
+
yield from _extract_model_info(attrs)
|
|
113
|
+
|
|
114
|
+
# Extract messages (input and output)
|
|
115
|
+
input_messages, output_messages = _extract_messages(attrs)
|
|
116
|
+
|
|
117
|
+
# Extract token usage
|
|
118
|
+
yield from _extract_token_usage(attrs)
|
|
119
|
+
|
|
120
|
+
# Extract span-kind specific attributes
|
|
121
|
+
if span_kind in ["LLM", "AGENT"]:
|
|
122
|
+
yield from _extract_llm_agent_attributes(attrs, input_messages, output_messages, span_kind)
|
|
123
|
+
elif span_kind == "TOOL":
|
|
124
|
+
yield from _extract_tool_attributes(attrs)
|
|
125
|
+
elif span_kind == "CHAIN":
|
|
126
|
+
yield from _extract_chain_attributes(attrs, input_messages, output_messages)
|
|
127
|
+
|
|
128
|
+
# Extract session and invocation parameters
|
|
129
|
+
yield from _extract_session_info(attrs)
|
|
130
|
+
yield from _extract_invocation_parameters(attrs)
|
|
131
|
+
|
|
132
|
+
# Add remaining attributes as metadata
|
|
133
|
+
yield from _extract_metadata(attrs)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _determine_span_kind(span_name: str, attrs: Dict[str, Any]) -> str:
|
|
137
|
+
"""Determine the OpenInference span kind based on MS Agent Framework operation."""
|
|
138
|
+
operation = attrs.get(OPERATION, "")
|
|
139
|
+
|
|
140
|
+
if operation == CHAT_COMPLETION_OPERATION:
|
|
141
|
+
return "LLM"
|
|
142
|
+
elif operation == TOOL_EXECUTION_OPERATION:
|
|
143
|
+
return "TOOL"
|
|
144
|
+
elif operation == AGENT_INVOKE_OPERATION:
|
|
145
|
+
return "AGENT"
|
|
146
|
+
|
|
147
|
+
if span_name.startswith("chat "):
|
|
148
|
+
return "LLM"
|
|
149
|
+
elif span_name.startswith("execute_tool "):
|
|
150
|
+
return "TOOL"
|
|
151
|
+
elif span_name.startswith("invoke_agent "):
|
|
152
|
+
return "AGENT"
|
|
153
|
+
elif span_name.startswith(WORKFLOW_RUN_SPAN):
|
|
154
|
+
return "CHAIN"
|
|
155
|
+
elif span_name.startswith(EXECUTOR_PROCESS_SPAN):
|
|
156
|
+
return "CHAIN"
|
|
157
|
+
|
|
158
|
+
if attrs.get(AGENT_NAME) or attrs.get(AGENT_ID):
|
|
159
|
+
return "AGENT"
|
|
160
|
+
if attrs.get(WORKFLOW_ID) or attrs.get(WORKFLOW_NAME):
|
|
161
|
+
return "CHAIN"
|
|
162
|
+
if attrs.get(EXECUTOR_ID) or attrs.get(EXECUTOR_TYPE):
|
|
163
|
+
return "CHAIN"
|
|
164
|
+
|
|
165
|
+
return "CHAIN"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _extract_graph_node_attributes(
|
|
169
|
+
span_id: int, attrs: Dict[str, Any], span_kind: str
|
|
170
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
171
|
+
"""Set graph node attributes for visualization hierarchy."""
|
|
172
|
+
if span_kind == "AGENT":
|
|
173
|
+
agent_id = attrs.get(AGENT_ID, span_id)
|
|
174
|
+
yield "graph.node.id", f"agent_{agent_id}"
|
|
175
|
+
if agent_name := attrs.get(AGENT_NAME):
|
|
176
|
+
yield "graph.node.name", agent_name
|
|
177
|
+
|
|
178
|
+
elif span_kind == "LLM":
|
|
179
|
+
yield "graph.node.id", f"llm_{span_id}"
|
|
180
|
+
if agent_id := attrs.get(AGENT_ID):
|
|
181
|
+
yield "graph.node.parent_id", f"agent_{agent_id}"
|
|
182
|
+
|
|
183
|
+
elif span_kind == "TOOL":
|
|
184
|
+
tool_name = attrs.get(TOOL_NAME, "unknown_tool")
|
|
185
|
+
yield "graph.node.id", f"tool_{tool_name}_{span_id}"
|
|
186
|
+
yield "graph.node.name", tool_name
|
|
187
|
+
|
|
188
|
+
elif span_kind == "CHAIN":
|
|
189
|
+
if workflow_id := attrs.get(WORKFLOW_ID):
|
|
190
|
+
yield "graph.node.id", f"workflow_{workflow_id}"
|
|
191
|
+
if workflow_name := attrs.get(WORKFLOW_NAME):
|
|
192
|
+
yield "graph.node.name", workflow_name
|
|
193
|
+
elif executor_id := attrs.get(EXECUTOR_ID):
|
|
194
|
+
yield "graph.node.id", f"executor_{executor_id}"
|
|
195
|
+
if executor_type := attrs.get(EXECUTOR_TYPE):
|
|
196
|
+
yield "graph.node.name", executor_type
|
|
197
|
+
elif edge_group_id := attrs.get(EDGE_GROUP_ID):
|
|
198
|
+
yield "graph.node.id", f"edge_group_{edge_group_id}"
|
|
199
|
+
if edge_group_type := attrs.get(EDGE_GROUP_TYPE):
|
|
200
|
+
yield "graph.node.name", edge_group_type
|
|
201
|
+
else:
|
|
202
|
+
yield "graph.node.id", f"chain_{span_id}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _extract_model_info(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
206
|
+
"""Map model and provider information to OpenInference format."""
|
|
207
|
+
model_name = attrs.get(LLM_REQUEST_MODEL) or attrs.get(LLM_RESPONSE_MODEL)
|
|
208
|
+
if model_name:
|
|
209
|
+
yield "llm.model_name", model_name
|
|
210
|
+
|
|
211
|
+
if provider := attrs.get(PROVIDER_NAME):
|
|
212
|
+
yield "llm.provider", provider
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_messages(attrs: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
216
|
+
"""Extract input and output messages from attributes."""
|
|
217
|
+
input_messages: List[Dict[str, Any]] = []
|
|
218
|
+
output_messages: List[Dict[str, Any]] = []
|
|
219
|
+
|
|
220
|
+
if input_msgs_raw := attrs.get(INPUT_MESSAGES):
|
|
221
|
+
input_messages = _parse_messages(input_msgs_raw)
|
|
222
|
+
if output_msgs_raw := attrs.get(OUTPUT_MESSAGES):
|
|
223
|
+
output_messages = _parse_messages(output_msgs_raw)
|
|
224
|
+
|
|
225
|
+
return input_messages, output_messages
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_messages(messages_raw: Any) -> List[Dict[str, Any]]:
|
|
229
|
+
"""Parse messages from MS Agent Framework format."""
|
|
230
|
+
messages: List[Dict[str, Any]] = []
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
if isinstance(messages_raw, str):
|
|
234
|
+
messages_data = json.loads(messages_raw)
|
|
235
|
+
else:
|
|
236
|
+
messages_data = messages_raw
|
|
237
|
+
|
|
238
|
+
if not isinstance(messages_data, list):
|
|
239
|
+
return messages
|
|
240
|
+
|
|
241
|
+
for msg in messages_data:
|
|
242
|
+
if not isinstance(msg, dict):
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
parsed_msg = _parse_single_message(msg)
|
|
246
|
+
if parsed_msg:
|
|
247
|
+
messages.append(parsed_msg)
|
|
248
|
+
|
|
249
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
250
|
+
logger.debug(f"Failed to parse messages: {e}")
|
|
251
|
+
|
|
252
|
+
return messages
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_single_message(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
256
|
+
"""Parse a single message from MS Agent Framework format to OpenInference format."""
|
|
257
|
+
role = msg.get("role", "user")
|
|
258
|
+
parts = msg.get("parts") or []
|
|
259
|
+
|
|
260
|
+
result: Dict[str, Any] = {"message.role": role}
|
|
261
|
+
|
|
262
|
+
text_content: List[str] = []
|
|
263
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
264
|
+
|
|
265
|
+
for part in parts:
|
|
266
|
+
if not isinstance(part, dict):
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
part_type = part.get("type", "")
|
|
270
|
+
|
|
271
|
+
if part_type == "text":
|
|
272
|
+
if content := part.get("content"):
|
|
273
|
+
text_content.append(str(content))
|
|
274
|
+
|
|
275
|
+
elif part_type == "reasoning":
|
|
276
|
+
if content := part.get("content"):
|
|
277
|
+
text_content.append(str(content))
|
|
278
|
+
|
|
279
|
+
elif part_type == "tool_call":
|
|
280
|
+
tool_call = {
|
|
281
|
+
"tool_call.id": part.get("id", ""),
|
|
282
|
+
"tool_call.function.name": part.get("name", ""),
|
|
283
|
+
"tool_call.function.arguments": safe_json_dumps(part.get("arguments", {})),
|
|
284
|
+
}
|
|
285
|
+
tool_calls.append(tool_call)
|
|
286
|
+
|
|
287
|
+
elif part_type == "tool_call_response":
|
|
288
|
+
result["message.role"] = "tool"
|
|
289
|
+
result["message.tool_call_id"] = part.get("id", "")
|
|
290
|
+
response = part.get("response", {})
|
|
291
|
+
if isinstance(response, dict):
|
|
292
|
+
text_content.append(safe_json_dumps(response))
|
|
293
|
+
else:
|
|
294
|
+
text_content.append(str(response))
|
|
295
|
+
|
|
296
|
+
if text_content:
|
|
297
|
+
result["message.content"] = " ".join(text_content)
|
|
298
|
+
if tool_calls:
|
|
299
|
+
result["message.tool_calls"] = tool_calls
|
|
300
|
+
|
|
301
|
+
if "message.content" not in result and "message.tool_calls" not in result:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _extract_token_usage(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
308
|
+
"""Map token usage from GenAI format to OpenInference format."""
|
|
309
|
+
input_tokens = attrs.get(INPUT_TOKENS)
|
|
310
|
+
if input_tokens is not None:
|
|
311
|
+
yield "llm.token_count.prompt", input_tokens
|
|
312
|
+
output_tokens = attrs.get(OUTPUT_TOKENS)
|
|
313
|
+
if output_tokens is not None:
|
|
314
|
+
yield "llm.token_count.completion", output_tokens
|
|
315
|
+
if input_tokens is not None and output_tokens is not None:
|
|
316
|
+
yield "llm.token_count.total", input_tokens + output_tokens
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _extract_llm_agent_attributes(
|
|
320
|
+
attrs: Dict[str, Any],
|
|
321
|
+
input_messages: List[Dict[str, Any]],
|
|
322
|
+
output_messages: List[Dict[str, Any]],
|
|
323
|
+
span_kind: str,
|
|
324
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
325
|
+
"""Handle LLM and AGENT span attributes."""
|
|
326
|
+
if input_messages:
|
|
327
|
+
yield "llm.input_messages", safe_json_dumps(input_messages)
|
|
328
|
+
yield from _flatten_messages(input_messages, "llm.input_messages")
|
|
329
|
+
|
|
330
|
+
if output_messages:
|
|
331
|
+
yield "llm.output_messages", safe_json_dumps(output_messages)
|
|
332
|
+
yield from _flatten_messages(output_messages, "llm.output_messages")
|
|
333
|
+
|
|
334
|
+
yield from _create_input_output_values(attrs, input_messages, output_messages, span_kind)
|
|
335
|
+
|
|
336
|
+
if attrs.get(SYSTEM_INSTRUCTIONS) or attrs.get(AGENT_NAME):
|
|
337
|
+
yield "llm.system", "microsoft.agent_framework"
|
|
338
|
+
|
|
339
|
+
if tool_defs := attrs.get(TOOL_DEFINITIONS):
|
|
340
|
+
yield from _map_tools(tool_defs)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _flatten_messages(messages: List[Dict[str, Any]], key_prefix: str) -> Iterator[Tuple[str, Any]]:
|
|
344
|
+
"""Flatten messages to dotted attribute notation for OpenInference."""
|
|
345
|
+
for idx, msg in enumerate(messages):
|
|
346
|
+
for key, value in msg.items():
|
|
347
|
+
clean_key = key.replace("message.", "") if key.startswith("message.") else key
|
|
348
|
+
dotted_key = f"{key_prefix}.{idx}.message.{clean_key}"
|
|
349
|
+
|
|
350
|
+
if clean_key == "tool_calls" and isinstance(value, list):
|
|
351
|
+
for tool_idx, tool_call in enumerate(value):
|
|
352
|
+
if isinstance(tool_call, dict):
|
|
353
|
+
for tool_key, tool_val in tool_call.items():
|
|
354
|
+
tool_dotted_key = (
|
|
355
|
+
f"{key_prefix}.{idx}.message.tool_calls.{tool_idx}.{tool_key}"
|
|
356
|
+
)
|
|
357
|
+
yield tool_dotted_key, _serialize_value(tool_val)
|
|
358
|
+
else:
|
|
359
|
+
yield dotted_key, _serialize_value(value)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _create_input_output_values(
|
|
363
|
+
attrs: Dict[str, Any],
|
|
364
|
+
input_messages: List[Dict[str, Any]],
|
|
365
|
+
output_messages: List[Dict[str, Any]],
|
|
366
|
+
span_kind: str,
|
|
367
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
368
|
+
"""Create input.value and output.value attributes."""
|
|
369
|
+
model_name = attrs.get(LLM_REQUEST_MODEL, "unknown")
|
|
370
|
+
|
|
371
|
+
if span_kind in ["LLM", "AGENT"]:
|
|
372
|
+
if input_messages:
|
|
373
|
+
if len(input_messages) == 1 and input_messages[0].get("message.role") == "user":
|
|
374
|
+
yield "input.value", input_messages[0].get("message.content", "")
|
|
375
|
+
yield "input.mime_type", "text/plain"
|
|
376
|
+
else:
|
|
377
|
+
input_structure = {"messages": input_messages, "model": model_name}
|
|
378
|
+
yield "input.value", safe_json_dumps(input_structure)
|
|
379
|
+
yield "input.mime_type", "application/json"
|
|
380
|
+
|
|
381
|
+
if output_messages:
|
|
382
|
+
last_message = output_messages[-1]
|
|
383
|
+
content = last_message.get("message.content", "")
|
|
384
|
+
|
|
385
|
+
if span_kind == "LLM":
|
|
386
|
+
finish_reasons = attrs.get(FINISH_REASONS)
|
|
387
|
+
finish_reason = "stop"
|
|
388
|
+
if finish_reasons:
|
|
389
|
+
try:
|
|
390
|
+
reasons = (
|
|
391
|
+
json.loads(finish_reasons)
|
|
392
|
+
if isinstance(finish_reasons, str)
|
|
393
|
+
else finish_reasons
|
|
394
|
+
)
|
|
395
|
+
if isinstance(reasons, list) and reasons:
|
|
396
|
+
finish_reason = reasons[0]
|
|
397
|
+
except (json.JSONDecodeError, TypeError):
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
# Get token counts if available
|
|
401
|
+
completion_tokens = attrs.get(OUTPUT_TOKENS)
|
|
402
|
+
prompt_tokens = attrs.get(INPUT_TOKENS)
|
|
403
|
+
total_tokens = None
|
|
404
|
+
if completion_tokens is not None and prompt_tokens is not None:
|
|
405
|
+
total_tokens = completion_tokens + prompt_tokens
|
|
406
|
+
|
|
407
|
+
output_structure = {
|
|
408
|
+
"choices": [
|
|
409
|
+
{
|
|
410
|
+
"finish_reason": finish_reason,
|
|
411
|
+
"index": 0,
|
|
412
|
+
"message": {
|
|
413
|
+
"content": content,
|
|
414
|
+
"role": last_message.get("message.role", "assistant"),
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
],
|
|
418
|
+
"model": model_name,
|
|
419
|
+
"usage": {
|
|
420
|
+
"completion_tokens": completion_tokens,
|
|
421
|
+
"prompt_tokens": prompt_tokens,
|
|
422
|
+
"total_tokens": total_tokens,
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
yield "output.value", safe_json_dumps(output_structure)
|
|
426
|
+
yield "output.mime_type", "application/json"
|
|
427
|
+
else:
|
|
428
|
+
yield "output.value", content
|
|
429
|
+
yield "output.mime_type", "text/plain"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _extract_tool_attributes(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
433
|
+
"""Handle TOOL span attributes."""
|
|
434
|
+
if tool_name := attrs.get(TOOL_NAME):
|
|
435
|
+
yield "tool.name", tool_name
|
|
436
|
+
if tool_call_id := attrs.get(TOOL_CALL_ID):
|
|
437
|
+
yield "tool.call_id", tool_call_id
|
|
438
|
+
if tool_desc := attrs.get(TOOL_DESCRIPTION):
|
|
439
|
+
yield "tool.description", tool_desc
|
|
440
|
+
|
|
441
|
+
if tool_args := attrs.get(TOOL_ARGUMENTS):
|
|
442
|
+
if isinstance(tool_args, str):
|
|
443
|
+
yield "tool.parameters", tool_args
|
|
444
|
+
yield "input.value", tool_args
|
|
445
|
+
else:
|
|
446
|
+
yield "tool.parameters", safe_json_dumps(tool_args)
|
|
447
|
+
yield "input.value", safe_json_dumps(tool_args)
|
|
448
|
+
yield "input.mime_type", "application/json"
|
|
449
|
+
|
|
450
|
+
if tool_result := attrs.get(TOOL_RESULT):
|
|
451
|
+
if isinstance(tool_result, str):
|
|
452
|
+
yield "output.value", tool_result
|
|
453
|
+
else:
|
|
454
|
+
yield "output.value", safe_json_dumps(tool_result)
|
|
455
|
+
yield "output.mime_type", "text/plain"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _extract_chain_attributes(
|
|
459
|
+
attrs: Dict[str, Any],
|
|
460
|
+
input_messages: List[Dict[str, Any]],
|
|
461
|
+
output_messages: List[Dict[str, Any]],
|
|
462
|
+
) -> Iterator[Tuple[str, Any]]:
|
|
463
|
+
"""Handle CHAIN span attributes (workflows, executors)."""
|
|
464
|
+
if input_messages:
|
|
465
|
+
for msg in input_messages:
|
|
466
|
+
if msg.get("message.role") == "user":
|
|
467
|
+
if content := msg.get("message.content"):
|
|
468
|
+
yield "input.value", content
|
|
469
|
+
yield "input.mime_type", "text/plain"
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
if output_messages:
|
|
473
|
+
for msg in reversed(output_messages):
|
|
474
|
+
if msg.get("message.role") == "assistant":
|
|
475
|
+
if content := msg.get("message.content"):
|
|
476
|
+
yield "output.value", content
|
|
477
|
+
yield "output.mime_type", "text/plain"
|
|
478
|
+
break
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _extract_session_info(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
482
|
+
"""Map session and conversation info."""
|
|
483
|
+
if conversation_id := attrs.get(CONVERSATION_ID):
|
|
484
|
+
yield "session.id", conversation_id
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _extract_invocation_parameters(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
488
|
+
"""Map invocation parameters to OpenInference format."""
|
|
489
|
+
params: Dict[str, Any] = {}
|
|
490
|
+
|
|
491
|
+
param_mappings = {
|
|
492
|
+
LLM_REQUEST_MAX_TOKENS: "max_tokens",
|
|
493
|
+
LLM_REQUEST_TEMPERATURE: "temperature",
|
|
494
|
+
LLM_REQUEST_TOP_P: "top_p",
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for attr_key, param_key in param_mappings.items():
|
|
498
|
+
if attr_key in attrs:
|
|
499
|
+
params[param_key] = attrs[attr_key]
|
|
500
|
+
|
|
501
|
+
if params:
|
|
502
|
+
yield "llm.invocation_parameters", safe_json_dumps(params)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _map_tools(tools_data: Any) -> Iterator[Tuple[str, Any]]:
|
|
506
|
+
"""Map tool definitions to OpenInference format."""
|
|
507
|
+
try:
|
|
508
|
+
if isinstance(tools_data, str):
|
|
509
|
+
tools_data = json.loads(tools_data)
|
|
510
|
+
|
|
511
|
+
if not isinstance(tools_data, list):
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
for idx, tool in enumerate(tools_data):
|
|
515
|
+
if isinstance(tool, dict):
|
|
516
|
+
if name := tool.get("name"):
|
|
517
|
+
yield f"llm.tools.{idx}.tool.name", name
|
|
518
|
+
if desc := tool.get("description"):
|
|
519
|
+
yield f"llm.tools.{idx}.tool.description", desc
|
|
520
|
+
# Handle function schema
|
|
521
|
+
if "function" in tool:
|
|
522
|
+
func = tool["function"]
|
|
523
|
+
if isinstance(func, dict):
|
|
524
|
+
if fname := func.get("name"):
|
|
525
|
+
yield f"llm.tools.{idx}.tool.name", fname
|
|
526
|
+
if fdesc := func.get("description"):
|
|
527
|
+
yield f"llm.tools.{idx}.tool.description", fdesc
|
|
528
|
+
if params := func.get("parameters"):
|
|
529
|
+
yield f"llm.tools.{idx}.tool.json_schema", safe_json_dumps(params)
|
|
530
|
+
elif params := tool.get("parameters"):
|
|
531
|
+
yield f"llm.tools.{idx}.tool.json_schema", safe_json_dumps(params)
|
|
532
|
+
elif input_schema := tool.get("input_schema"):
|
|
533
|
+
yield f"llm.tools.{idx}.tool.json_schema", safe_json_dumps(input_schema)
|
|
534
|
+
|
|
535
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
536
|
+
logger.debug(f"Failed to parse tools: {e}")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _extract_metadata(attrs: Dict[str, Any]) -> Iterator[Tuple[str, Any]]:
|
|
540
|
+
"""Add remaining attributes as metadata."""
|
|
541
|
+
metadata: Dict[str, Any] = {}
|
|
542
|
+
skip_keys = {
|
|
543
|
+
OPERATION,
|
|
544
|
+
PROVIDER_NAME,
|
|
545
|
+
LLM_REQUEST_MODEL,
|
|
546
|
+
LLM_RESPONSE_MODEL,
|
|
547
|
+
INPUT_TOKENS,
|
|
548
|
+
OUTPUT_TOKENS,
|
|
549
|
+
INPUT_MESSAGES,
|
|
550
|
+
OUTPUT_MESSAGES,
|
|
551
|
+
TOOL_NAME,
|
|
552
|
+
TOOL_CALL_ID,
|
|
553
|
+
TOOL_ARGUMENTS,
|
|
554
|
+
TOOL_RESULT,
|
|
555
|
+
TOOL_DESCRIPTION,
|
|
556
|
+
TOOL_DEFINITIONS,
|
|
557
|
+
AGENT_ID,
|
|
558
|
+
AGENT_NAME,
|
|
559
|
+
AGENT_DESCRIPTION,
|
|
560
|
+
CONVERSATION_ID,
|
|
561
|
+
FINISH_REASONS,
|
|
562
|
+
RESPONSE_ID,
|
|
563
|
+
SYSTEM_INSTRUCTIONS,
|
|
564
|
+
LLM_REQUEST_MAX_TOKENS,
|
|
565
|
+
LLM_REQUEST_TEMPERATURE,
|
|
566
|
+
LLM_REQUEST_TOP_P,
|
|
567
|
+
WORKFLOW_ID,
|
|
568
|
+
WORKFLOW_NAME,
|
|
569
|
+
EXECUTOR_ID,
|
|
570
|
+
EXECUTOR_TYPE,
|
|
571
|
+
EDGE_GROUP_ID,
|
|
572
|
+
EDGE_GROUP_TYPE,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for key, value in attrs.items():
|
|
576
|
+
if key not in skip_keys:
|
|
577
|
+
metadata[key] = _serialize_value(value)
|
|
578
|
+
|
|
579
|
+
if metadata:
|
|
580
|
+
yield "metadata", safe_json_dumps(metadata)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _serialize_value(value: Any) -> Any:
|
|
584
|
+
"""Serialize a value for span attributes."""
|
|
585
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
586
|
+
return value
|
|
587
|
+
return safe_json_dumps(value)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Callable, Optional
|
|
2
|
+
|
|
3
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
4
|
+
|
|
5
|
+
from openinference.semconv.trace import SpanAttributes
|
|
6
|
+
|
|
7
|
+
# Define types for span filtering
|
|
8
|
+
SpanFilter = Callable[[ReadableSpan], bool]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_openinference_span(span: ReadableSpan) -> bool:
|
|
12
|
+
"""Check if a span is an OpenInference span."""
|
|
13
|
+
if span.attributes is None:
|
|
14
|
+
return False
|
|
15
|
+
return SpanAttributes.OPENINFERENCE_SPAN_KIND in span.attributes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def should_export_span(span: ReadableSpan, span_filter: Optional[SpanFilter] = None) -> bool:
|
|
19
|
+
"""Determine if a span should be exported based on a filter."""
|
|
20
|
+
if span_filter is None:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
return span_filter(span)
|