|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import json |
3 | 4 | import sys |
4 | 5 | from pathlib import Path |
5 | 6 |
|
|
22 | 23 | OpenAIAgentsInstrumentor, |
23 | 24 | ) |
24 | 25 | from opentelemetry.sdk.trace import TracerProvider # noqa: E402 |
25 | | -from opentelemetry.sdk.trace.export import ( # noqa: E402 |
26 | | - InMemorySpanExporter, |
27 | | - SimpleSpanProcessor, |
28 | | -) |
| 26 | +from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402 |
| 27 | + |
| 28 | +try: # pragma: no cover - compatibility for older SDK versions |
| 29 | + from opentelemetry.sdk.trace.export import ( # type: ignore[attr-defined] # noqa: E402 |
| 30 | + InMemorySpanExporter, |
| 31 | + ) |
| 32 | +except ImportError: # pragma: no cover - fallback for newer SDK layout |
| 33 | + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 |
| 34 | + InMemorySpanExporter, |
| 35 | + ) |
29 | 36 | from opentelemetry.semconv._incubating.attributes import ( # noqa: E402 |
30 | 37 | gen_ai_attributes as GenAI, |
31 | 38 | ) |
|
34 | 41 | ) |
35 | 42 | from opentelemetry.trace import SpanKind # noqa: E402 |
36 | 43 |
|
| 44 | +GEN_AI_INPUT_MESSAGES = getattr( |
| 45 | + GenAI, "GEN_AI_INPUT_MESSAGES", "gen_ai.input.messages" |
| 46 | +) |
| 47 | +GEN_AI_OUTPUT_MESSAGES = getattr( |
| 48 | + GenAI, "GEN_AI_OUTPUT_MESSAGES", "gen_ai.output.messages" |
| 49 | +) |
| 50 | +GEN_AI_TOOL_CALL_ARGUMENTS = getattr( |
| 51 | + GenAI, "GEN_AI_TOOL_CALL_ARGUMENTS", "gen_ai.tool.call.arguments" |
| 52 | +) |
| 53 | +GEN_AI_TOOL_CALL_RESULT = getattr( |
| 54 | + GenAI, "GEN_AI_TOOL_CALL_RESULT", "gen_ai.tool.call.result" |
| 55 | +) |
37 | 56 |
|
38 | | -def _instrument_with_provider(): |
| 57 | + |
| 58 | +def _instrument_with_provider(**instrument_kwargs): |
39 | 59 | set_trace_processors([]) |
40 | 60 | provider = TracerProvider() |
41 | 61 | exporter = InMemorySpanExporter() |
42 | 62 | provider.add_span_processor(SimpleSpanProcessor(exporter)) |
43 | 63 |
|
44 | 64 | instrumentor = OpenAIAgentsInstrumentor() |
45 | | - instrumentor.instrument(tracer_provider=provider) |
| 65 | + instrumentor.instrument(tracer_provider=provider, **instrument_kwargs) |
46 | 66 |
|
47 | 67 | return instrumentor, exporter |
48 | 68 |
|
@@ -108,3 +128,145 @@ def test_function_span_records_tool_attributes(): |
108 | 128 | finally: |
109 | 129 | instrumentor.uninstrument() |
110 | 130 | exporter.clear() |
| 131 | + |
| 132 | + |
| 133 | +def test_generation_span_captures_messages_by_default(): |
| 134 | + instrumentor, exporter = _instrument_with_provider() |
| 135 | + |
| 136 | + try: |
| 137 | + with trace("workflow"): |
| 138 | + with generation_span( |
| 139 | + input=[{"role": "user", "content": "hi"}], |
| 140 | + output=[{"role": "assistant", "content": "hello"}], |
| 141 | + model="gpt-4o-mini", |
| 142 | + ): |
| 143 | + pass |
| 144 | + |
| 145 | + spans = exporter.get_finished_spans() |
| 146 | + client_span = next( |
| 147 | + span for span in spans if span.kind is SpanKind.CLIENT |
| 148 | + ) |
| 149 | + |
| 150 | + prompt = json.loads(client_span.attributes[GEN_AI_INPUT_MESSAGES]) |
| 151 | + completion = json.loads(client_span.attributes[GEN_AI_OUTPUT_MESSAGES]) |
| 152 | + |
| 153 | + assert prompt == [ |
| 154 | + { |
| 155 | + "role": "user", |
| 156 | + "parts": [{"type": "text", "content": "hi"}], |
| 157 | + } |
| 158 | + ] |
| 159 | + assert completion == [ |
| 160 | + { |
| 161 | + "role": "assistant", |
| 162 | + "parts": [{"type": "text", "content": "hello"}], |
| 163 | + } |
| 164 | + ] |
| 165 | + |
| 166 | + event_names = {event.name for event in client_span.events} |
| 167 | + assert "gen_ai.input" in event_names |
| 168 | + assert "gen_ai.output" in event_names |
| 169 | + |
| 170 | + input_event = next( |
| 171 | + event |
| 172 | + for event in client_span.events |
| 173 | + if event.name == "gen_ai.input" |
| 174 | + ) |
| 175 | + output_event = next( |
| 176 | + event |
| 177 | + for event in client_span.events |
| 178 | + if event.name == "gen_ai.output" |
| 179 | + ) |
| 180 | + |
| 181 | + assert ( |
| 182 | + json.loads(input_event.attributes[GEN_AI_INPUT_MESSAGES]) == prompt |
| 183 | + ) |
| 184 | + assert ( |
| 185 | + json.loads(output_event.attributes[GEN_AI_OUTPUT_MESSAGES]) |
| 186 | + == completion |
| 187 | + ) |
| 188 | + finally: |
| 189 | + instrumentor.uninstrument() |
| 190 | + exporter.clear() |
| 191 | + |
| 192 | + |
| 193 | +def test_capture_mode_can_be_disabled(): |
| 194 | + instrumentor, exporter = _instrument_with_provider( |
| 195 | + capture_message_content="no_content" |
| 196 | + ) |
| 197 | + |
| 198 | + try: |
| 199 | + with trace("workflow"): |
| 200 | + with generation_span( |
| 201 | + input=[{"role": "user", "content": "hi"}], |
| 202 | + output=[{"role": "assistant", "content": "hello"}], |
| 203 | + model="gpt-4o-mini", |
| 204 | + ): |
| 205 | + pass |
| 206 | + |
| 207 | + spans = exporter.get_finished_spans() |
| 208 | + client_span = next( |
| 209 | + span for span in spans if span.kind is SpanKind.CLIENT |
| 210 | + ) |
| 211 | + |
| 212 | + assert GEN_AI_INPUT_MESSAGES not in client_span.attributes |
| 213 | + assert GEN_AI_OUTPUT_MESSAGES not in client_span.attributes |
| 214 | + for event in client_span.events: |
| 215 | + assert GEN_AI_INPUT_MESSAGES not in event.attributes |
| 216 | + assert GEN_AI_OUTPUT_MESSAGES not in event.attributes |
| 217 | + finally: |
| 218 | + instrumentor.uninstrument() |
| 219 | + exporter.clear() |
| 220 | + |
| 221 | + |
| 222 | +def test_function_span_captures_tool_payload(): |
| 223 | + instrumentor, exporter = _instrument_with_provider() |
| 224 | + |
| 225 | + try: |
| 226 | + with trace("workflow"): |
| 227 | + with function_span( |
| 228 | + name="fetch_weather", |
| 229 | + input={"city": "Paris"}, |
| 230 | + output={"forecast": "sunny"}, |
| 231 | + ): |
| 232 | + pass |
| 233 | + |
| 234 | + spans = exporter.get_finished_spans() |
| 235 | + tool_span = next( |
| 236 | + span for span in spans if span.kind is SpanKind.INTERNAL |
| 237 | + ) |
| 238 | + |
| 239 | + arguments = json.loads( |
| 240 | + tool_span.attributes[GEN_AI_TOOL_CALL_ARGUMENTS] |
| 241 | + ) |
| 242 | + result = json.loads(tool_span.attributes[GEN_AI_TOOL_CALL_RESULT]) |
| 243 | + |
| 244 | + assert arguments == {"city": "Paris"} |
| 245 | + assert result == {"forecast": "sunny"} |
| 246 | + |
| 247 | + event_names = {event.name for event in tool_span.events} |
| 248 | + assert "gen_ai.tool.arguments" in event_names |
| 249 | + assert "gen_ai.tool.result" in event_names |
| 250 | + |
| 251 | + args_event = next( |
| 252 | + event |
| 253 | + for event in tool_span.events |
| 254 | + if event.name == "gen_ai.tool.arguments" |
| 255 | + ) |
| 256 | + result_event = next( |
| 257 | + event |
| 258 | + for event in tool_span.events |
| 259 | + if event.name == "gen_ai.tool.result" |
| 260 | + ) |
| 261 | + |
| 262 | + assert ( |
| 263 | + json.loads(args_event.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]) |
| 264 | + == arguments |
| 265 | + ) |
| 266 | + assert ( |
| 267 | + json.loads(result_event.attributes[GEN_AI_TOOL_CALL_RESULT]) |
| 268 | + == result |
| 269 | + ) |
| 270 | + finally: |
| 271 | + instrumentor.uninstrument() |
| 272 | + exporter.clear() |
0 commit comments