traceAI-openai 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,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: traceAI-openai
3
+ Version: 0.1.0
4
+ Summary: OpenTelemetry instrumentation for OpenAI
5
+ Author: Future AGI
6
+ Author-email: no-reply@futureagi.com
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: fi-instrumentation-otel
15
+ Requires-Dist: openai (>=1.57.1,<2.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+
File without changes
@@ -0,0 +1,18 @@
1
+ [tool.poetry]
2
+ name = "traceAI-openai"
3
+ version = "0.1.0"
4
+ description = "OpenTelemetry instrumentation for OpenAI"
5
+ authors = ["Future AGI <no-reply@futureagi.com>"]
6
+ readme = "README.md"
7
+ packages = [
8
+ { include = "traceai_openai" }
9
+ ]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = ">=3.9,<4.0"
13
+ fi-instrumentation-otel = "*"
14
+ openai = "^1.57.1"
15
+
16
+ [build-system]
17
+ requires = ["poetry-core"]
18
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,60 @@
1
+ import logging
2
+ from importlib import import_module
3
+ from typing import Any, Collection
4
+
5
+ from fi_instrumentation import FITracer, TraceConfig
6
+ from opentelemetry import trace as trace_api
7
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore
8
+ from traceai_openai._request import _AsyncRequest, _Request
9
+ from traceai_openai.package import _instruments
10
+ from traceai_openai.version import __version__
11
+ from wrapt import wrap_function_wrapper
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logger.addHandler(logging.NullHandler())
15
+
16
+ _MODULE = "openai"
17
+
18
+
19
+ class OpenAIInstrumentor(BaseInstrumentor): # type: ignore
20
+ """
21
+ An instrumentor for openai
22
+ """
23
+
24
+ __slots__ = (
25
+ "_original_request",
26
+ "_original_async_request",
27
+ )
28
+
29
+ def instrumentation_dependencies(self) -> Collection[str]:
30
+ return _instruments
31
+
32
+ def _instrument(self, **kwargs: Any) -> None:
33
+ if not (tracer_provider := kwargs.get("tracer_provider")):
34
+ tracer_provider = trace_api.get_tracer_provider()
35
+ if not (config := kwargs.get("config")):
36
+ config = TraceConfig()
37
+ else:
38
+ assert isinstance(config, TraceConfig)
39
+ tracer = FITracer(
40
+ trace_api.get_tracer(__name__, __version__, tracer_provider),
41
+ config=config,
42
+ )
43
+ openai = import_module(_MODULE)
44
+ self._original_request = openai.OpenAI.request
45
+ self._original_async_request = openai.AsyncOpenAI.request
46
+ wrap_function_wrapper(
47
+ module=_MODULE,
48
+ name="OpenAI.request",
49
+ wrapper=_Request(tracer=tracer, openai=openai),
50
+ )
51
+ wrap_function_wrapper(
52
+ module=_MODULE,
53
+ name="AsyncOpenAI.request",
54
+ wrapper=_AsyncRequest(tracer=tracer, openai=openai),
55
+ )
56
+
57
+ def _uninstrument(self, **kwargs: Any) -> None:
58
+ openai = import_module(_MODULE)
59
+ openai.OpenAI.request = self._original_request
60
+ openai.AsyncOpenAI.request = self._original_async_request
@@ -0,0 +1,491 @@
1
+ import logging
2
+ from abc import ABC
3
+ from contextlib import contextmanager
4
+ from itertools import chain
5
+ from types import ModuleType
6
+ from typing import Any, Awaitable, Callable, Iterable, Iterator, Mapping, Tuple
7
+
8
+ from fi_instrumentation import get_attributes_from_context
9
+ from fi_instrumentation.fi_types import (
10
+ FiLLMProviderValues,
11
+ FiLLMSystemValues,
12
+ FiSpanKindValues,
13
+ SpanAttributes,
14
+ )
15
+ from openai._legacy_response import LegacyAPIResponse
16
+ from openai.types.chat.chat_completion import ChatCompletion
17
+ from openai.types.create_embedding_response import CreateEmbeddingResponse
18
+ from opentelemetry import context as context_api
19
+ from opentelemetry import trace as trace_api
20
+ from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY
21
+ from opentelemetry.trace import INVALID_SPAN
22
+ from opentelemetry.util.types import AttributeValue
23
+ from traceai_openai._request_attributes_extractor import _RequestAttributesExtractor
24
+ from traceai_openai._response_accumulator import (
25
+ _ChatCompletionAccumulator,
26
+ _CompletionAccumulator,
27
+ )
28
+ from traceai_openai._response_attributes_extractor import _ResponseAttributesExtractor
29
+ from traceai_openai._span_io_handler import add_io_to_span_attributes
30
+ from traceai_openai._stream import _AsyncStream, _ResponseAccumulator, _Stream
31
+ from traceai_openai._utils import (
32
+ _as_input_attributes,
33
+ _as_output_attributes,
34
+ _finish_tracing,
35
+ _io_value_and_type,
36
+ safe_json_dumps,
37
+ )
38
+ from traceai_openai._with_span import _WithSpan
39
+ from typing_extensions import TypeAlias
40
+
41
+ __all__ = (
42
+ "_Request",
43
+ "_AsyncRequest",
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+ logger.addHandler(logging.NullHandler())
48
+
49
+
50
+ class _WithTracer(ABC):
51
+ def __init__(self, tracer: trace_api.Tracer, *args: Any, **kwargs: Any) -> None:
52
+ super().__init__(*args, **kwargs)
53
+ self._tracer = tracer
54
+
55
+ @contextmanager
56
+ def _start_as_current_span(
57
+ self,
58
+ span_name: str,
59
+ attributes: Iterable[Tuple[str, AttributeValue]],
60
+ context_attributes: Iterable[Tuple[str, AttributeValue]],
61
+ extra_attributes: Iterable[Tuple[str, AttributeValue]],
62
+ ) -> Iterator[_WithSpan]:
63
+ # Because OTEL has a default limit of 128 attributes, we split our attributes into
64
+ # two tiers, where the addition of "extra_attributes" is deferred until the end
65
+ # and only after the "attributes" are added.
66
+ try:
67
+ span = self._tracer.start_span(name=span_name, attributes=dict(attributes))
68
+ except Exception:
69
+ logger.exception("Failed to start span")
70
+ span = INVALID_SPAN
71
+ with trace_api.use_span(
72
+ span,
73
+ end_on_exit=False,
74
+ record_exception=False,
75
+ set_status_on_exception=False,
76
+ ) as span:
77
+ yield _WithSpan(
78
+ span=span,
79
+ context_attributes=dict(context_attributes),
80
+ extra_attributes=dict(extra_attributes),
81
+ )
82
+
83
+
84
+ _RequestParameters: TypeAlias = Mapping[str, Any]
85
+
86
+
87
+ class _WithOpenAI(ABC):
88
+ __slots__ = (
89
+ "_openai",
90
+ "_stream_types",
91
+ "_request_attributes_extractor",
92
+ "_response_attributes_extractor",
93
+ "_response_accumulator_factories",
94
+ )
95
+
96
+ def __init__(self, openai: ModuleType, *args: Any, **kwargs: Any) -> None:
97
+ super().__init__(*args, **kwargs)
98
+ self._openai = openai
99
+ self._stream_types = (openai.Stream, openai.AsyncStream)
100
+ self._request_attributes_extractor = _RequestAttributesExtractor(openai=openai)
101
+ self._response_attributes_extractor = _ResponseAttributesExtractor(
102
+ openai=openai
103
+ )
104
+ self._response_accumulator_factories: Mapping[
105
+ type, Callable[[_RequestParameters], _ResponseAccumulator]
106
+ ] = {
107
+ openai.types.Completion: lambda request_parameters: _CompletionAccumulator(
108
+ request_parameters=request_parameters,
109
+ completion_type=openai.types.Completion,
110
+ response_attributes_extractor=self._response_attributes_extractor,
111
+ ),
112
+ openai.types.chat.ChatCompletion: lambda request_parameters: _ChatCompletionAccumulator(
113
+ request_parameters=request_parameters,
114
+ chat_completion_type=openai.types.chat.ChatCompletion,
115
+ response_attributes_extractor=self._response_attributes_extractor,
116
+ ),
117
+ }
118
+
119
+ def _get_span_kind(self, cast_to: type) -> str:
120
+ return (
121
+ FiSpanKindValues.EMBEDDING.value
122
+ if cast_to is self._openai.types.CreateEmbeddingResponse
123
+ else FiSpanKindValues.LLM.value
124
+ )
125
+
126
+ def _get_attributes_from_instance(
127
+ self, instance: Any
128
+ ) -> Iterator[Tuple[str, AttributeValue]]:
129
+ if (
130
+ not (base_url := getattr(instance, "base_url", None))
131
+ or not (host := getattr(base_url, "host", None))
132
+ or not isinstance(host, str)
133
+ ):
134
+ return
135
+ if host.endswith("api.openai.com"):
136
+ yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.OPENAI.value
137
+ elif host.endswith("openai.azure.com"):
138
+ yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.AZURE.value
139
+ elif host.endswith("googleapis.com"):
140
+ yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.GOOGLE.value
141
+
142
+ def _get_attributes_from_request(
143
+ self,
144
+ cast_to: type,
145
+ request_parameters: Mapping[str, Any],
146
+ ) -> Iterator[Tuple[str, AttributeValue]]:
147
+ yield SpanAttributes.FI_SPAN_KIND, self._get_span_kind(cast_to=cast_to)
148
+ yield SpanAttributes.LLM_SYSTEM, FiLLMSystemValues.OPENAI.value
149
+ try:
150
+ yield from _as_input_attributes(
151
+ _io_value_and_type(request_parameters),
152
+ )
153
+ except Exception:
154
+ logger.exception(
155
+ f"Failed to get input attributes from request parameters of "
156
+ f"type {type(request_parameters)}"
157
+ )
158
+
159
+ def _get_extra_attributes_from_request(
160
+ self,
161
+ cast_to: type,
162
+ request_parameters: Mapping[str, Any],
163
+ ) -> Iterator[Tuple[str, AttributeValue]]:
164
+ # Secondary attributes should be added after input and output to ensure
165
+ # that input and output are not dropped if there are too many attributes.
166
+ try:
167
+ yield from self._request_attributes_extractor.get_attributes_from_request(
168
+ cast_to=cast_to,
169
+ request_parameters=request_parameters,
170
+ )
171
+ except Exception:
172
+ logger.exception(
173
+ f"Failed to get extra attributes from request options of "
174
+ f"type {type(request_parameters)}"
175
+ )
176
+
177
+ def _is_streaming(self, response: Any) -> bool:
178
+ return isinstance(response, self._stream_types)
179
+
180
+ def _is_async_stream(self, response: Any) -> bool:
181
+ """Check if the response is an asynchronous stream."""
182
+ return hasattr(response, "__aiter__") and callable(response.__aiter__)
183
+
184
+ def _finalize_response(
185
+ self,
186
+ response: Any,
187
+ with_span: _WithSpan,
188
+ cast_to: type,
189
+ request_parameters: Mapping[str, Any],
190
+ ) -> Any:
191
+ """
192
+ Monkey-patch the response object to trace the stream, or finish tracing if the response is
193
+ not a stream.
194
+ """
195
+ if hasattr(response, "parse") and callable(response.parse):
196
+ try:
197
+ response.parse()
198
+ except Exception:
199
+ logger.exception(f"Failed to parse response of type {type(response)}")
200
+
201
+ if self._is_streaming(response):
202
+ # Determine if the response is asynchronous
203
+ if self._is_async_stream(response):
204
+ stream_wrapper = _AsyncStream
205
+ else:
206
+ stream_wrapper = _Stream
207
+
208
+ # Get the response accumulator if available
209
+ response_accumulator_factory = self._response_accumulator_factories.get(
210
+ cast_to
211
+ )
212
+ response_accumulator = (
213
+ response_accumulator_factory(request_parameters)
214
+ if response_accumulator_factory
215
+ else None
216
+ )
217
+
218
+ # Wrap the response with the appropriate stream wrapper
219
+ wrapped_stream = stream_wrapper(
220
+ stream=response,
221
+ with_span=with_span,
222
+ response_accumulator=response_accumulator,
223
+ )
224
+
225
+ return wrapped_stream
226
+
227
+ else:
228
+ _finish_tracing(
229
+ status=trace_api.Status(status_code=trace_api.StatusCode.OK),
230
+ with_span=with_span,
231
+ has_attributes=_ResponseAttributes(
232
+ request_parameters=request_parameters,
233
+ response=response,
234
+ response_attributes_extractor=self._response_attributes_extractor,
235
+ ),
236
+ )
237
+ return response
238
+
239
+ def response_to_dict(self, response: Any) -> Any:
240
+ if hasattr(response, "to_dict") and callable(response.to_dict):
241
+ return response.to_dict()
242
+ elif isinstance(response, dict):
243
+ return response
244
+ elif hasattr(response, "__dict__"):
245
+ return response.__dict__
246
+ else:
247
+ return str(response)
248
+
249
+
250
+ class _Request(_WithTracer, _WithOpenAI):
251
+ def __call__(
252
+ self,
253
+ wrapped: Callable[..., Any],
254
+ instance: Any,
255
+ args: Tuple[type, Any],
256
+ kwargs: Mapping[str, Any],
257
+ ) -> Any:
258
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
259
+ return wrapped(*args, **kwargs)
260
+ try:
261
+ cast_to, request_parameters = _parse_request_args(args)
262
+ # Extract input data more robustly
263
+ input_data = (
264
+ request_parameters.get("messages")
265
+ or request_parameters.get("prompt")
266
+ or request_parameters.get("input")
267
+ )
268
+ llm_tools = request_parameters.get("tools")
269
+ span_name: str = cast_to.__name__.split(".")[-1]
270
+ except Exception:
271
+ logger.exception("Failed to parse request args")
272
+ return wrapped(*args, **kwargs)
273
+
274
+ with self._start_as_current_span(
275
+ span_name=span_name,
276
+ attributes=chain(
277
+ self._get_attributes_from_instance(instance),
278
+ self._get_attributes_from_request(
279
+ cast_to=cast_to,
280
+ request_parameters=request_parameters,
281
+ ),
282
+ ),
283
+ context_attributes=get_attributes_from_context(),
284
+ extra_attributes=self._get_extra_attributes_from_request(
285
+ cast_to=cast_to,
286
+ request_parameters=request_parameters,
287
+ ),
288
+ ) as with_span:
289
+ # Add input data to span attributes before the request
290
+ with_span.set_attribute(
291
+ SpanAttributes.RAW_INPUT, safe_json_dumps(request_parameters)
292
+ )
293
+ with_span.set_attribute(
294
+ SpanAttributes.LLM_TOOLS, safe_json_dumps(llm_tools)
295
+ )
296
+ add_io_to_span_attributes(with_span, input_data, None)
297
+ try:
298
+ response = wrapped(*args, **kwargs)
299
+
300
+ if isinstance(response, CreateEmbeddingResponse):
301
+ embedding = response.to_dict()
302
+ with_span.set_attribute(
303
+ SpanAttributes.EMBEDDING_EMBEDDINGS, safe_json_dumps(embedding)
304
+ )
305
+
306
+ # Add output data to span attributes after getting response
307
+ if not self._is_streaming(response):
308
+ with_span.set_attribute(
309
+ SpanAttributes.RAW_OUTPUT, _get_raw_output(response)
310
+ )
311
+ add_io_to_span_attributes(
312
+ with_span,
313
+ None,
314
+ response,
315
+ is_streaming=self._is_streaming(response),
316
+ )
317
+ return self._finalize_response(
318
+ response=response,
319
+ with_span=with_span,
320
+ cast_to=cast_to,
321
+ request_parameters=request_parameters,
322
+ )
323
+ except Exception as e:
324
+ status = trace_api.Status(
325
+ status_code=trace_api.StatusCode.ERROR,
326
+ description=f"{type(e).__name__}: {e}",
327
+ )
328
+ with_span.record_exception(e)
329
+ with_span.finish_tracing(status=status)
330
+ raise
331
+
332
+
333
+ class _AsyncRequest(_WithTracer, _WithOpenAI):
334
+ async def __call__(
335
+ self,
336
+ wrapped: Callable[..., Awaitable[Any]],
337
+ instance: Any,
338
+ args: Tuple[type, Any],
339
+ kwargs: Mapping[str, Any],
340
+ ) -> Any:
341
+ if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
342
+ return await wrapped(*args, **kwargs)
343
+ try:
344
+ cast_to, request_parameters = _parse_request_args(args)
345
+ # Extract input data more robustly
346
+ input_data = request_parameters.get(
347
+ "messages", request_parameters.get("prompt")
348
+ )
349
+ llm_tools = request_parameters.get("tools")
350
+
351
+ span_name: str = cast_to.__name__.split(".")[-1]
352
+ except Exception:
353
+ logger.exception("Failed to parse request args")
354
+ return await wrapped(*args, **kwargs)
355
+ with self._start_as_current_span(
356
+ span_name=span_name,
357
+ attributes=chain(
358
+ self._get_attributes_from_instance(instance),
359
+ self._get_attributes_from_request(
360
+ cast_to=cast_to,
361
+ request_parameters=request_parameters,
362
+ ),
363
+ ),
364
+ context_attributes=get_attributes_from_context(),
365
+ extra_attributes=self._get_extra_attributes_from_request(
366
+ cast_to=cast_to,
367
+ request_parameters=request_parameters,
368
+ ),
369
+ ) as with_span:
370
+ # Add input data to span attributes before the request
371
+ with_span.set_attribute(
372
+ SpanAttributes.RAW_INPUT, safe_json_dumps(request_parameters)
373
+ )
374
+ with_span.set_attribute(
375
+ SpanAttributes.LLM_TOOLS, safe_json_dumps(llm_tools)
376
+ )
377
+ add_io_to_span_attributes(with_span, input_data, None)
378
+ try:
379
+ response = await wrapped(*args, **kwargs)
380
+
381
+ if isinstance(response, CreateEmbeddingResponse):
382
+ embedding = response.to_dict()
383
+ with_span.set_attribute(
384
+ SpanAttributes.EMBEDDING_EMBEDDINGS, safe_json_dumps(embedding)
385
+ )
386
+
387
+ if not self._is_streaming(response):
388
+ with_span.set_attribute(
389
+ SpanAttributes.RAW_OUTPUT, _get_raw_output(response)
390
+ )
391
+
392
+ add_io_to_span_attributes(
393
+ with_span, None, response, is_streaming=self._is_streaming(response)
394
+ )
395
+ except Exception as exception:
396
+ with_span.record_exception(exception)
397
+ status = trace_api.Status(
398
+ status_code=trace_api.StatusCode.ERROR,
399
+ # Follow the format in OTEL SDK for description, see:
400
+ # https://github.com/open-telemetry/opentelemetry-python/blob/2b9dcfc5d853d1c10176937a6bcaade54cda1a31/opentelemetry-api/src/opentelemetry/trace/__init__.py#L588 # noqa E501
401
+ description=f"{type(exception).__name__}: {exception}",
402
+ )
403
+ with_span.finish_tracing(status=status)
404
+ raise
405
+ try:
406
+ response = self._finalize_response(
407
+ response=response,
408
+ with_span=with_span,
409
+ cast_to=cast_to,
410
+ request_parameters=request_parameters,
411
+ )
412
+ except Exception:
413
+ logger.exception(
414
+ f"Failed to finalize response of type {type(response)}"
415
+ )
416
+ with_span.finish_tracing()
417
+ return response
418
+
419
+
420
+ def _parse_request_args(args: Tuple[type, Any]) -> Tuple[type, Mapping[str, Any]]:
421
+ # We don't use `signature(request).bind()` because `request` could have been monkey-patched
422
+ # (incorrectly) by others and the signature at runtime may not match the original.
423
+ # The targeted signature of `request` is here:
424
+ # https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_base_client.py#L846-L847 # noqa: E501
425
+ cast_to: type = args[0]
426
+ request_parameters: Mapping[str, Any] = (
427
+ json_data
428
+ # See https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_models.py#L427 # noqa: E501
429
+ if hasattr(args[1], "json_data")
430
+ and isinstance(json_data := args[1].json_data, Mapping)
431
+ else {}
432
+ )
433
+ # FIXME: Because request parameters is just a Mapping, it can contain any value as long as it
434
+ # serializes correctly in an HTTP request body. For example, Enum values may be present if a
435
+ # third-party library puts them there. Enums can turn into their intended string values via
436
+ # `json.dumps` when the final HTTP request body is serialized, but can pose problems when we
437
+ # try to extract attributes. However, this round-trip seems expensive, so we opted to treat
438
+ # only the Enums that we know about: e.g. message role sometimes can be an Enum, so we will
439
+ # convert it only when it's encountered.
440
+ # try:
441
+ # request_parameters = json.loads(json.dumps(request_parameters))
442
+ # except Exception:
443
+ # pass
444
+ return cast_to, request_parameters
445
+
446
+
447
+ class _ResponseAttributes:
448
+ __slots__ = ("_response", "_request_parameters", "_response_attributes_extractor")
449
+
450
+ def __init__(
451
+ self,
452
+ response: Any,
453
+ request_parameters: Mapping[str, Any],
454
+ response_attributes_extractor: _ResponseAttributesExtractor,
455
+ ) -> None:
456
+ if hasattr(response, "parse") and callable(response.parse):
457
+ # E.g. see https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_base_client.py#L518 # noqa: E501
458
+ try:
459
+ response = response.parse()
460
+ except Exception:
461
+ logger.exception(f"Failed to parse response of type {type(response)}")
462
+ self._request_parameters = request_parameters
463
+ self._response = response
464
+ self._response_attributes_extractor = response_attributes_extractor
465
+
466
+ def get_attributes(self) -> Iterator[Tuple[str, AttributeValue]]:
467
+ yield from _as_output_attributes(
468
+ _io_value_and_type(self._response),
469
+ )
470
+
471
+ def get_extra_attributes(self) -> Iterator[Tuple[str, AttributeValue]]:
472
+ yield from self._response_attributes_extractor.get_attributes_from_response(
473
+ response=self._response,
474
+ request_parameters=self._request_parameters,
475
+ )
476
+
477
+
478
+ def _get_raw_output(response):
479
+ if isinstance(response, ChatCompletion):
480
+ return safe_json_dumps(response.to_dict())
481
+ elif isinstance(response, LegacyAPIResponse):
482
+ parsed_response = response.parse()
483
+ if hasattr(parsed_response, "dict") and callable(parsed_response.dict):
484
+ parsed_response = parsed_response.dict()
485
+ return safe_json_dumps(parsed_response)
486
+ else:
487
+ return safe_json_dumps(str(parsed_response))
488
+ elif hasattr(response, "to_dict") and callable(response.to_dict):
489
+ return safe_json_dumps(response.to_dict())
490
+ else:
491
+ return safe_json_dumps(str(response))