Add metrics to the Python OpenAI instrumentation (#3180)
This commit is contained in:
		
							parent
							
								
									07c97eac38
								
							
						
					
					
						commit
						a716949d1c
					
				|  | @ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | ||||||
| - Add example to `opentelemetry-instrumentation-openai-v2` | - Add example to `opentelemetry-instrumentation-openai-v2` | ||||||
|   ([#3006](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3006)) |   ([#3006](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3006)) | ||||||
| - Support for `AsyncOpenAI/AsyncCompletions` ([#2984](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2984)) | - Support for `AsyncOpenAI/AsyncCompletions` ([#2984](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2984)) | ||||||
|  | - Add metrics ([#3180](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3180)) | ||||||
| 
 | 
 | ||||||
| ## Version 2.0b0 (2024-11-08) | ## Version 2.0b0 (2024-11-08) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,7 +7,8 @@ OpenTelemetry OpenAI Instrumentation | ||||||
|    :target: https://pypi.org/project/opentelemetry-instrumentation-openai-v2/ |    :target: https://pypi.org/project/opentelemetry-instrumentation-openai-v2/ | ||||||
| 
 | 
 | ||||||
| This library allows tracing LLM requests and logging of messages made by the | This library allows tracing LLM requests and logging of messages made by the | ||||||
| `OpenAI Python API library <https://pypi.org/project/openai/>`_. | `OpenAI Python API library <https://pypi.org/project/openai/>`_. It also captures | ||||||
|  | the duration of the operations and the number of tokens used as metrics. | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| Installation | Installation | ||||||
|  | @ -74,6 +75,48 @@ To uninstrument clients, call the uninstrument method: | ||||||
|     # Uninstrument all clients |     # Uninstrument all clients | ||||||
|     OpenAIInstrumentor().uninstrument() |     OpenAIInstrumentor().uninstrument() | ||||||
| 
 | 
 | ||||||
|  | Bucket Boundaries | ||||||
|  | ----------------- | ||||||
|  | 
 | ||||||
|  | This section describes the explicit bucket boundaries for metrics such as token usage and operation duration, and guides users to create Views to implement them according to the semantic conventions. | ||||||
|  | 
 | ||||||
|  | The bucket boundaries are defined as follows: | ||||||
|  | 
 | ||||||
|  | - For `gen_ai.client.token.usage`: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864] | ||||||
|  | - For `gen_ai.client.operation.duration`: [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92] | ||||||
|  | 
 | ||||||
|  | To implement these bucket boundaries, you can create Views in your OpenTelemetry SDK setup. Here is an example: | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     from opentelemetry.sdk.metrics import MeterProvider, View | ||||||
|  |     from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader | ||||||
|  |     from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter | ||||||
|  |     from opentelemetry.sdk.metrics.aggregation import ExplicitBucketHistogramAggregation | ||||||
|  | 
 | ||||||
|  |     views = [ | ||||||
|  |         View( | ||||||
|  |             instrument_name="gen_ai.client.token.usage", | ||||||
|  |             aggregation=ExplicitBucketHistogramAggregation([1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]), | ||||||
|  |         ), | ||||||
|  |         View( | ||||||
|  |             instrument_name="gen_ai.client.operation.duration", | ||||||
|  |             aggregation=ExplicitBucketHistogramAggregation([0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     metric_exporter = OTLPMetricExporter(endpoint="http://localhost:4317") | ||||||
|  |     metric_reader = PeriodicExportingMetricReader(metric_exporter) | ||||||
|  |     provider = MeterProvider( | ||||||
|  |         metric_readers=[metric_reader], | ||||||
|  |         views=views | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     from opentelemetry.sdk.metrics import set_meter_provider | ||||||
|  |     set_meter_provider(provider) | ||||||
|  | 
 | ||||||
|  | For more details, refer to the `OpenTelemetry GenAI Metrics documentation <https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/>`_. | ||||||
|  | 
 | ||||||
| References | References | ||||||
| ---------- | ---------- | ||||||
| * `OpenTelemetry OpenAI Instrumentation <https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/openai/openai.html>`_ | * `OpenTelemetry OpenAI Instrumentation <https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/openai/openai.html>`_ | ||||||
|  |  | ||||||
|  | @ -49,13 +49,18 @@ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor | ||||||
| from opentelemetry.instrumentation.openai_v2.package import _instruments | from opentelemetry.instrumentation.openai_v2.package import _instruments | ||||||
| from opentelemetry.instrumentation.openai_v2.utils import is_content_enabled | from opentelemetry.instrumentation.openai_v2.utils import is_content_enabled | ||||||
| from opentelemetry.instrumentation.utils import unwrap | from opentelemetry.instrumentation.utils import unwrap | ||||||
|  | from opentelemetry.metrics import get_meter | ||||||
| from opentelemetry.semconv.schemas import Schemas | from opentelemetry.semconv.schemas import Schemas | ||||||
| from opentelemetry.trace import get_tracer | from opentelemetry.trace import get_tracer | ||||||
| 
 | 
 | ||||||
|  | from .instruments import Instruments | ||||||
| from .patch import async_chat_completions_create, chat_completions_create | from .patch import async_chat_completions_create, chat_completions_create | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class OpenAIInstrumentor(BaseInstrumentor): | class OpenAIInstrumentor(BaseInstrumentor): | ||||||
|  |     def __init__(self): | ||||||
|  |         self._meter = None | ||||||
|  | 
 | ||||||
|     def instrumentation_dependencies(self) -> Collection[str]: |     def instrumentation_dependencies(self) -> Collection[str]: | ||||||
|         return _instruments |         return _instruments | ||||||
| 
 | 
 | ||||||
|  | @ -75,12 +80,21 @@ class OpenAIInstrumentor(BaseInstrumentor): | ||||||
|             schema_url=Schemas.V1_28_0.value, |             schema_url=Schemas.V1_28_0.value, | ||||||
|             event_logger_provider=event_logger_provider, |             event_logger_provider=event_logger_provider, | ||||||
|         ) |         ) | ||||||
|  |         meter_provider = kwargs.get("meter_provider") | ||||||
|  |         self._meter = get_meter( | ||||||
|  |             __name__, | ||||||
|  |             "", | ||||||
|  |             meter_provider, | ||||||
|  |             schema_url=Schemas.V1_28_0.value, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         instruments = Instruments(self._meter) | ||||||
| 
 | 
 | ||||||
|         wrap_function_wrapper( |         wrap_function_wrapper( | ||||||
|             module="openai.resources.chat.completions", |             module="openai.resources.chat.completions", | ||||||
|             name="Completions.create", |             name="Completions.create", | ||||||
|             wrapper=chat_completions_create( |             wrapper=chat_completions_create( | ||||||
|                 tracer, event_logger, is_content_enabled() |                 tracer, event_logger, instruments, is_content_enabled() | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  | @ -88,7 +102,7 @@ class OpenAIInstrumentor(BaseInstrumentor): | ||||||
|             module="openai.resources.chat.completions", |             module="openai.resources.chat.completions", | ||||||
|             name="AsyncCompletions.create", |             name="AsyncCompletions.create", | ||||||
|             wrapper=async_chat_completions_create( |             wrapper=async_chat_completions_create( | ||||||
|                 tracer, event_logger, is_content_enabled() |                 tracer, event_logger, instruments, is_content_enabled() | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | from opentelemetry.semconv._incubating.metrics import gen_ai_metrics | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Instruments: | ||||||
|  |     def __init__(self, meter): | ||||||
|  |         self.operation_duration_histogram = ( | ||||||
|  |             gen_ai_metrics.create_gen_ai_client_operation_duration(meter) | ||||||
|  |         ) | ||||||
|  |         self.token_usage_histogram = ( | ||||||
|  |             gen_ai_metrics.create_gen_ai_client_token_usage(meter) | ||||||
|  |         ) | ||||||
|  | @ -13,6 +13,7 @@ | ||||||
| # limitations under the License. | # limitations under the License. | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | from timeit import default_timer | ||||||
| from typing import Optional | from typing import Optional | ||||||
| 
 | 
 | ||||||
| from openai import Stream | from openai import Stream | ||||||
|  | @ -21,8 +22,12 @@ from opentelemetry._events import Event, EventLogger | ||||||
| from opentelemetry.semconv._incubating.attributes import ( | from opentelemetry.semconv._incubating.attributes import ( | ||||||
|     gen_ai_attributes as GenAIAttributes, |     gen_ai_attributes as GenAIAttributes, | ||||||
| ) | ) | ||||||
|  | from opentelemetry.semconv._incubating.attributes import ( | ||||||
|  |     server_attributes as ServerAttributes, | ||||||
|  | ) | ||||||
| from opentelemetry.trace import Span, SpanKind, Tracer | from opentelemetry.trace import Span, SpanKind, Tracer | ||||||
| 
 | 
 | ||||||
|  | from .instruments import Instruments | ||||||
| from .utils import ( | from .utils import ( | ||||||
|     choice_to_event, |     choice_to_event, | ||||||
|     get_llm_request_attributes, |     get_llm_request_attributes, | ||||||
|  | @ -34,7 +39,10 @@ from .utils import ( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def chat_completions_create( | def chat_completions_create( | ||||||
|     tracer: Tracer, event_logger: EventLogger, capture_content: bool |     tracer: Tracer, | ||||||
|  |     event_logger: EventLogger, | ||||||
|  |     instruments: Instruments, | ||||||
|  |     capture_content: bool, | ||||||
| ): | ): | ||||||
|     """Wrap the `create` method of the `ChatCompletion` class to trace it.""" |     """Wrap the `create` method of the `ChatCompletion` class to trace it.""" | ||||||
| 
 | 
 | ||||||
|  | @ -54,6 +62,9 @@ def chat_completions_create( | ||||||
|                         message_to_event(message, capture_content) |                         message_to_event(message, capture_content) | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|  |             start = default_timer() | ||||||
|  |             result = None | ||||||
|  |             error_type = None | ||||||
|             try: |             try: | ||||||
|                 result = wrapped(*args, **kwargs) |                 result = wrapped(*args, **kwargs) | ||||||
|                 if is_streaming(kwargs): |                 if is_streaming(kwargs): | ||||||
|  | @ -69,14 +80,27 @@ def chat_completions_create( | ||||||
|                 return result |                 return result | ||||||
| 
 | 
 | ||||||
|             except Exception as error: |             except Exception as error: | ||||||
|  |                 error_type = type(error).__qualname__ | ||||||
|                 handle_span_exception(span, error) |                 handle_span_exception(span, error) | ||||||
|                 raise |                 raise | ||||||
|  |             finally: | ||||||
|  |                 duration = max((default_timer() - start), 0) | ||||||
|  |                 _record_metrics( | ||||||
|  |                     instruments, | ||||||
|  |                     duration, | ||||||
|  |                     result, | ||||||
|  |                     span_attributes, | ||||||
|  |                     error_type, | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|     return traced_method |     return traced_method | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def async_chat_completions_create( | def async_chat_completions_create( | ||||||
|     tracer: Tracer, event_logger: EventLogger, capture_content: bool |     tracer: Tracer, | ||||||
|  |     event_logger: EventLogger, | ||||||
|  |     instruments: Instruments, | ||||||
|  |     capture_content: bool, | ||||||
| ): | ): | ||||||
|     """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" |     """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" | ||||||
| 
 | 
 | ||||||
|  | @ -96,6 +120,9 @@ def async_chat_completions_create( | ||||||
|                         message_to_event(message, capture_content) |                         message_to_event(message, capture_content) | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|  |             start = default_timer() | ||||||
|  |             result = None | ||||||
|  |             error_type = None | ||||||
|             try: |             try: | ||||||
|                 result = await wrapped(*args, **kwargs) |                 result = await wrapped(*args, **kwargs) | ||||||
|                 if is_streaming(kwargs): |                 if is_streaming(kwargs): | ||||||
|  | @ -111,12 +138,88 @@ def async_chat_completions_create( | ||||||
|                 return result |                 return result | ||||||
| 
 | 
 | ||||||
|             except Exception as error: |             except Exception as error: | ||||||
|  |                 error_type = type(error).__qualname__ | ||||||
|                 handle_span_exception(span, error) |                 handle_span_exception(span, error) | ||||||
|                 raise |                 raise | ||||||
|  |             finally: | ||||||
|  |                 duration = max((default_timer() - start), 0) | ||||||
|  |                 _record_metrics( | ||||||
|  |                     instruments, | ||||||
|  |                     duration, | ||||||
|  |                     result, | ||||||
|  |                     span_attributes, | ||||||
|  |                     error_type, | ||||||
|  |                 ) | ||||||
| 
 | 
 | ||||||
|     return traced_method |     return traced_method | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def _record_metrics( | ||||||
|  |     instruments: Instruments, | ||||||
|  |     duration: float, | ||||||
|  |     result, | ||||||
|  |     span_attributes: dict, | ||||||
|  |     error_type: Optional[str], | ||||||
|  | ): | ||||||
|  |     common_attributes = { | ||||||
|  |         GenAIAttributes.GEN_AI_OPERATION_NAME: GenAIAttributes.GenAiOperationNameValues.CHAT.value, | ||||||
|  |         GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value, | ||||||
|  |         GenAIAttributes.GEN_AI_REQUEST_MODEL: span_attributes[ | ||||||
|  |             GenAIAttributes.GEN_AI_REQUEST_MODEL | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if error_type: | ||||||
|  |         common_attributes["error.type"] = error_type | ||||||
|  | 
 | ||||||
|  |     if result and getattr(result, "model", None): | ||||||
|  |         common_attributes[GenAIAttributes.GEN_AI_RESPONSE_MODEL] = result.model | ||||||
|  | 
 | ||||||
|  |     if result and getattr(result, "service_tier", None): | ||||||
|  |         common_attributes[ | ||||||
|  |             GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER | ||||||
|  |         ] = result.service_tier | ||||||
|  | 
 | ||||||
|  |     if result and getattr(result, "system_fingerprint", None): | ||||||
|  |         common_attributes["gen_ai.openai.response.system_fingerprint"] = ( | ||||||
|  |             result.system_fingerprint | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     if ServerAttributes.SERVER_ADDRESS in span_attributes: | ||||||
|  |         common_attributes[ServerAttributes.SERVER_ADDRESS] = span_attributes[ | ||||||
|  |             ServerAttributes.SERVER_ADDRESS | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |     if ServerAttributes.SERVER_PORT in span_attributes: | ||||||
|  |         common_attributes[ServerAttributes.SERVER_PORT] = span_attributes[ | ||||||
|  |             ServerAttributes.SERVER_PORT | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |     instruments.operation_duration_histogram.record( | ||||||
|  |         duration, | ||||||
|  |         attributes=common_attributes, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if result and getattr(result, "usage", None): | ||||||
|  |         input_attributes = { | ||||||
|  |             **common_attributes, | ||||||
|  |             GenAIAttributes.GEN_AI_TOKEN_TYPE: GenAIAttributes.GenAiTokenTypeValues.INPUT.value, | ||||||
|  |         } | ||||||
|  |         instruments.token_usage_histogram.record( | ||||||
|  |             result.usage.prompt_tokens, | ||||||
|  |             attributes=input_attributes, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         completion_attributes = { | ||||||
|  |             **common_attributes, | ||||||
|  |             GenAIAttributes.GEN_AI_TOKEN_TYPE: GenAIAttributes.GenAiTokenTypeValues.COMPLETION.value, | ||||||
|  |         } | ||||||
|  |         instruments.token_usage_histogram.record( | ||||||
|  |             result.usage.completion_tokens, | ||||||
|  |             attributes=completion_attributes, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def _set_response_attributes( | def _set_response_attributes( | ||||||
|     span, result, event_logger: EventLogger, capture_content: bool |     span, result, event_logger: EventLogger, capture_content: bool | ||||||
| ): | ): | ||||||
|  |  | ||||||
|  | @ -0,0 +1,133 @@ | ||||||
|  | interactions: | ||||||
|  | - request: | ||||||
|  |     body: |- | ||||||
|  |       { | ||||||
|  |         "messages": [ | ||||||
|  |           { | ||||||
|  |             "role": "user", | ||||||
|  |             "content": "Say this is a test" | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "model": "gpt-4o-mini", | ||||||
|  |         "stream": false | ||||||
|  |       } | ||||||
|  |     headers: | ||||||
|  |       accept: | ||||||
|  |       - application/json | ||||||
|  |       accept-encoding: | ||||||
|  |       - gzip, deflate | ||||||
|  |       authorization: | ||||||
|  |       - Bearer test_openai_api_key | ||||||
|  |       connection: | ||||||
|  |       - keep-alive | ||||||
|  |       content-length: | ||||||
|  |       - '106' | ||||||
|  |       content-type: | ||||||
|  |       - application/json | ||||||
|  |       host: | ||||||
|  |       - api.openai.com | ||||||
|  |       user-agent: | ||||||
|  |       - AsyncOpenAI/Python 1.26.0 | ||||||
|  |       x-stainless-arch: | ||||||
|  |       - arm64 | ||||||
|  |       x-stainless-async: | ||||||
|  |       - async:asyncio | ||||||
|  |       x-stainless-lang: | ||||||
|  |       - python | ||||||
|  |       x-stainless-os: | ||||||
|  |       - MacOS | ||||||
|  |       x-stainless-package-version: | ||||||
|  |       - 1.26.0 | ||||||
|  |       x-stainless-runtime: | ||||||
|  |       - CPython | ||||||
|  |       x-stainless-runtime-version: | ||||||
|  |       - 3.12.5 | ||||||
|  |     method: POST | ||||||
|  |     uri: https://api.openai.com/v1/chat/completions | ||||||
|  |   response: | ||||||
|  |     body: | ||||||
|  |       string: |- | ||||||
|  |         { | ||||||
|  |           "id": "chatcmpl-ASv9R2E7Yhb2e7bj4Xl0qm9s3J42Y", | ||||||
|  |           "object": "chat.completion", | ||||||
|  |           "created": 1731456237, | ||||||
|  |           "model": "gpt-4o-mini-2024-07-18", | ||||||
|  |           "choices": [ | ||||||
|  |             { | ||||||
|  |               "index": 0, | ||||||
|  |               "message": { | ||||||
|  |                 "role": "assistant", | ||||||
|  |                 "content": "This is a test. How can I assist you further?", | ||||||
|  |                 "refusal": null | ||||||
|  |               }, | ||||||
|  |               "logprobs": null, | ||||||
|  |               "finish_reason": "stop" | ||||||
|  |             } | ||||||
|  |           ], | ||||||
|  |           "service_tier": "default", | ||||||
|  |           "usage": { | ||||||
|  |             "prompt_tokens": 12, | ||||||
|  |             "completion_tokens": 12, | ||||||
|  |             "total_tokens": 24, | ||||||
|  |             "prompt_tokens_details": { | ||||||
|  |               "cached_tokens": 0, | ||||||
|  |               "audio_tokens": 0 | ||||||
|  |             }, | ||||||
|  |             "completion_tokens_details": { | ||||||
|  |               "reasoning_tokens": 0, | ||||||
|  |               "audio_tokens": 0, | ||||||
|  |               "accepted_prediction_tokens": 0, | ||||||
|  |               "rejected_prediction_tokens": 0 | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           "system_fingerprint": "fp_0ba0d124f1" | ||||||
|  |         } | ||||||
|  |     headers: | ||||||
|  |       CF-Cache-Status: | ||||||
|  |       - DYNAMIC | ||||||
|  |       CF-RAY: | ||||||
|  |       - 8e1a80679a8311a6-MRS | ||||||
|  |       Connection: | ||||||
|  |       - keep-alive | ||||||
|  |       Content-Type: | ||||||
|  |       - application/json | ||||||
|  |       Date: | ||||||
|  |       - Wed, 13 Nov 2024 00:03:58 GMT | ||||||
|  |       Server: | ||||||
|  |       - cloudflare | ||||||
|  |       Set-Cookie: test_set_cookie | ||||||
|  |       Transfer-Encoding: | ||||||
|  |       - chunked | ||||||
|  |       X-Content-Type-Options: | ||||||
|  |       - nosniff | ||||||
|  |       access-control-expose-headers: | ||||||
|  |       - X-Request-ID | ||||||
|  |       alt-svc: | ||||||
|  |       - h3=":443"; ma=86400 | ||||||
|  |       content-length: | ||||||
|  |       - '796' | ||||||
|  |       openai-organization: test_openai_org_id | ||||||
|  |       openai-processing-ms: | ||||||
|  |       - '359' | ||||||
|  |       openai-version: | ||||||
|  |       - '2020-10-01' | ||||||
|  |       strict-transport-security: | ||||||
|  |       - max-age=31536000; includeSubDomains; preload | ||||||
|  |       x-ratelimit-limit-requests: | ||||||
|  |       - '30000' | ||||||
|  |       x-ratelimit-limit-tokens: | ||||||
|  |       - '150000000' | ||||||
|  |       x-ratelimit-remaining-requests: | ||||||
|  |       - '29999' | ||||||
|  |       x-ratelimit-remaining-tokens: | ||||||
|  |       - '149999978' | ||||||
|  |       x-ratelimit-reset-requests: | ||||||
|  |       - 2ms | ||||||
|  |       x-ratelimit-reset-tokens: | ||||||
|  |       - 0s | ||||||
|  |       x-request-id: | ||||||
|  |       - req_41ea134c1fc450d4ca4cf8d0c6a7c53a | ||||||
|  |     status: | ||||||
|  |       code: 200 | ||||||
|  |       message: OK | ||||||
|  | version: 1 | ||||||
|  | @ -0,0 +1,135 @@ | ||||||
|  | interactions: | ||||||
|  | - request: | ||||||
|  |     body: |- | ||||||
|  |       { | ||||||
|  |         "messages": [ | ||||||
|  |           { | ||||||
|  |             "role": "user", | ||||||
|  |             "content": "Say this is a test" | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "model": "gpt-4o-mini", | ||||||
|  |         "stream": false | ||||||
|  |       } | ||||||
|  |     headers: | ||||||
|  |       accept: | ||||||
|  |       - application/json | ||||||
|  |       accept-encoding: | ||||||
|  |       - gzip, deflate | ||||||
|  |       authorization: | ||||||
|  |       - Bearer test_openai_api_key | ||||||
|  |       connection: | ||||||
|  |       - keep-alive | ||||||
|  |       content-length: | ||||||
|  |       - '106' | ||||||
|  |       content-type: | ||||||
|  |       - application/json | ||||||
|  |       host: | ||||||
|  |       - api.openai.com | ||||||
|  |       user-agent: | ||||||
|  |       - OpenAI/Python 1.54.3 | ||||||
|  |       x-stainless-arch: | ||||||
|  |       - arm64 | ||||||
|  |       x-stainless-async: | ||||||
|  |       - 'false' | ||||||
|  |       x-stainless-lang: | ||||||
|  |       - python | ||||||
|  |       x-stainless-os: | ||||||
|  |       - MacOS | ||||||
|  |       x-stainless-package-version: | ||||||
|  |       - 1.54.3 | ||||||
|  |       x-stainless-retry-count: | ||||||
|  |       - '0' | ||||||
|  |       x-stainless-runtime: | ||||||
|  |       - CPython | ||||||
|  |       x-stainless-runtime-version: | ||||||
|  |       - 3.12.6 | ||||||
|  |     method: POST | ||||||
|  |     uri: https://api.openai.com/v1/chat/completions | ||||||
|  |   response: | ||||||
|  |     body: | ||||||
|  |       string: |- | ||||||
|  |         { | ||||||
|  |           "id": "chatcmpl-ASYMQRl3A3DXL9FWCK9tnGRcKIO7q", | ||||||
|  |           "object": "chat.completion", | ||||||
|  |           "created": 1731368630, | ||||||
|  |           "model": "gpt-4o-mini-2024-07-18", | ||||||
|  |           "choices": [ | ||||||
|  |             { | ||||||
|  |               "index": 0, | ||||||
|  |               "message": { | ||||||
|  |                 "role": "assistant", | ||||||
|  |                 "content": "This is a test.", | ||||||
|  |                 "refusal": null | ||||||
|  |               }, | ||||||
|  |               "logprobs": null, | ||||||
|  |               "finish_reason": "stop" | ||||||
|  |             } | ||||||
|  |           ], | ||||||
|  |           "service_tier": "default", | ||||||
|  |           "usage": { | ||||||
|  |             "prompt_tokens": 12, | ||||||
|  |             "completion_tokens": 5, | ||||||
|  |             "total_tokens": 17, | ||||||
|  |             "prompt_tokens_details": { | ||||||
|  |               "cached_tokens": 0, | ||||||
|  |               "audio_tokens": 0 | ||||||
|  |             }, | ||||||
|  |             "completion_tokens_details": { | ||||||
|  |               "reasoning_tokens": 0, | ||||||
|  |               "audio_tokens": 0, | ||||||
|  |               "accepted_prediction_tokens": 0, | ||||||
|  |               "rejected_prediction_tokens": 0 | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           "system_fingerprint": "fp_0ba0d124f1" | ||||||
|  |         } | ||||||
|  |     headers: | ||||||
|  |       CF-Cache-Status: | ||||||
|  |       - DYNAMIC | ||||||
|  |       CF-RAY: | ||||||
|  |       - 8e122593ff368bc8-SIN | ||||||
|  |       Connection: | ||||||
|  |       - keep-alive | ||||||
|  |       Content-Type: | ||||||
|  |       - application/json | ||||||
|  |       Date: | ||||||
|  |       - Mon, 11 Nov 2024 23:43:50 GMT | ||||||
|  |       Server: | ||||||
|  |       - cloudflare | ||||||
|  |       Set-Cookie: test_set_cookie | ||||||
|  |       Transfer-Encoding: | ||||||
|  |       - chunked | ||||||
|  |       X-Content-Type-Options: | ||||||
|  |       - nosniff | ||||||
|  |       access-control-expose-headers: | ||||||
|  |       - X-Request-ID | ||||||
|  |       alt-svc: | ||||||
|  |       - h3=":443"; ma=86400 | ||||||
|  |       content-length: | ||||||
|  |       - '765' | ||||||
|  |       openai-organization: test_openai_org_id | ||||||
|  |       openai-processing-ms: | ||||||
|  |       - '287' | ||||||
|  |       openai-version: | ||||||
|  |       - '2020-10-01' | ||||||
|  |       strict-transport-security: | ||||||
|  |       - max-age=31536000; includeSubDomains; preload | ||||||
|  |       x-ratelimit-limit-requests: | ||||||
|  |       - '10000' | ||||||
|  |       x-ratelimit-limit-tokens: | ||||||
|  |       - '200000' | ||||||
|  |       x-ratelimit-remaining-requests: | ||||||
|  |       - '9999' | ||||||
|  |       x-ratelimit-remaining-tokens: | ||||||
|  |       - '199977' | ||||||
|  |       x-ratelimit-reset-requests: | ||||||
|  |       - 8.64s | ||||||
|  |       x-ratelimit-reset-tokens: | ||||||
|  |       - 6ms | ||||||
|  |       x-request-id: | ||||||
|  |       - req_58cff97afd0e7c0bba910ccf0b044a6f | ||||||
|  |     status: | ||||||
|  |       code: 200 | ||||||
|  |       message: OK | ||||||
|  | version: 1 | ||||||
|  | @ -17,6 +17,17 @@ from opentelemetry.sdk._logs.export import ( | ||||||
|     InMemoryLogExporter, |     InMemoryLogExporter, | ||||||
|     SimpleLogRecordProcessor, |     SimpleLogRecordProcessor, | ||||||
| ) | ) | ||||||
|  | from opentelemetry.sdk.metrics import ( | ||||||
|  |     Histogram, | ||||||
|  |     MeterProvider, | ||||||
|  | ) | ||||||
|  | from opentelemetry.sdk.metrics.export import ( | ||||||
|  |     InMemoryMetricReader, | ||||||
|  | ) | ||||||
|  | from opentelemetry.sdk.metrics.view import ( | ||||||
|  |     ExplicitBucketHistogramAggregation, | ||||||
|  |     View, | ||||||
|  | ) | ||||||
| from opentelemetry.sdk.trace import TracerProvider | from opentelemetry.sdk.trace import TracerProvider | ||||||
| from opentelemetry.sdk.trace.export import SimpleSpanProcessor | from opentelemetry.sdk.trace.export import SimpleSpanProcessor | ||||||
| from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( | from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( | ||||||
|  | @ -36,6 +47,12 @@ def fixture_log_exporter(): | ||||||
|     yield exporter |     yield exporter | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.fixture(scope="function", name="metric_reader") | ||||||
|  | def fixture_metric_reader(): | ||||||
|  |     exporter = InMemoryMetricReader() | ||||||
|  |     yield exporter | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @pytest.fixture(scope="function", name="tracer_provider") | @pytest.fixture(scope="function", name="tracer_provider") | ||||||
| def fixture_tracer_provider(span_exporter): | def fixture_tracer_provider(span_exporter): | ||||||
|     provider = TracerProvider() |     provider = TracerProvider() | ||||||
|  | @ -52,6 +69,62 @@ def fixture_event_logger_provider(log_exporter): | ||||||
|     return event_logger_provider |     return event_logger_provider | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.fixture(scope="function", name="meter_provider") | ||||||
|  | def fixture_meter_provider(metric_reader): | ||||||
|  |     token_usage_histogram_view = View( | ||||||
|  |         instrument_type=Histogram, | ||||||
|  |         instrument_name="gen_ai.client.token.usage", | ||||||
|  |         aggregation=ExplicitBucketHistogramAggregation( | ||||||
|  |             boundaries=[ | ||||||
|  |                 1, | ||||||
|  |                 4, | ||||||
|  |                 16, | ||||||
|  |                 64, | ||||||
|  |                 256, | ||||||
|  |                 1024, | ||||||
|  |                 4096, | ||||||
|  |                 16384, | ||||||
|  |                 65536, | ||||||
|  |                 262144, | ||||||
|  |                 1048576, | ||||||
|  |                 4194304, | ||||||
|  |                 16777216, | ||||||
|  |                 67108864, | ||||||
|  |             ] | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     duration_histogram_view = View( | ||||||
|  |         instrument_type=Histogram, | ||||||
|  |         instrument_name="gen_ai.client.operation.duration", | ||||||
|  |         aggregation=ExplicitBucketHistogramAggregation( | ||||||
|  |             boundaries=[ | ||||||
|  |                 0.01, | ||||||
|  |                 0.02, | ||||||
|  |                 0.04, | ||||||
|  |                 0.08, | ||||||
|  |                 0.16, | ||||||
|  |                 0.32, | ||||||
|  |                 0.64, | ||||||
|  |                 1.28, | ||||||
|  |                 2.56, | ||||||
|  |                 5.12, | ||||||
|  |                 10.24, | ||||||
|  |                 20.48, | ||||||
|  |                 40.96, | ||||||
|  |                 81.92, | ||||||
|  |             ] | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     meter_provider = MeterProvider( | ||||||
|  |         metric_readers=[metric_reader], | ||||||
|  |         views=[token_usage_histogram_view, duration_histogram_view], | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     return meter_provider | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @pytest.fixture(autouse=True) | @pytest.fixture(autouse=True) | ||||||
| def environment(): | def environment(): | ||||||
|     if not os.getenv("OPENAI_API_KEY"): |     if not os.getenv("OPENAI_API_KEY"): | ||||||
|  | @ -83,7 +156,9 @@ def vcr_config(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope="function") | @pytest.fixture(scope="function") | ||||||
| def instrument_no_content(tracer_provider, event_logger_provider): | def instrument_no_content( | ||||||
|  |     tracer_provider, event_logger_provider, meter_provider | ||||||
|  | ): | ||||||
|     os.environ.update( |     os.environ.update( | ||||||
|         {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "False"} |         {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "False"} | ||||||
|     ) |     ) | ||||||
|  | @ -92,6 +167,7 @@ def instrument_no_content(tracer_provider, event_logger_provider): | ||||||
|     instrumentor.instrument( |     instrumentor.instrument( | ||||||
|         tracer_provider=tracer_provider, |         tracer_provider=tracer_provider, | ||||||
|         event_logger_provider=event_logger_provider, |         event_logger_provider=event_logger_provider, | ||||||
|  |         meter_provider=meter_provider, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     yield instrumentor |     yield instrumentor | ||||||
|  | @ -100,7 +176,9 @@ def instrument_no_content(tracer_provider, event_logger_provider): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(scope="function") | @pytest.fixture(scope="function") | ||||||
| def instrument_with_content(tracer_provider, event_logger_provider): | def instrument_with_content( | ||||||
|  |     tracer_provider, event_logger_provider, meter_provider | ||||||
|  | ): | ||||||
|     os.environ.update( |     os.environ.update( | ||||||
|         {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "True"} |         {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "True"} | ||||||
|     ) |     ) | ||||||
|  | @ -108,6 +186,7 @@ def instrument_with_content(tracer_provider, event_logger_provider): | ||||||
|     instrumentor.instrument( |     instrumentor.instrument( | ||||||
|         tracer_provider=tracer_provider, |         tracer_provider=tracer_provider, | ||||||
|         event_logger_provider=event_logger_provider, |         event_logger_provider=event_logger_provider, | ||||||
|  |         meter_provider=meter_provider, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     yield instrumentor |     yield instrumentor | ||||||
|  |  | ||||||
|  | @ -32,6 +32,7 @@ from opentelemetry.semconv._incubating.attributes import ( | ||||||
| from opentelemetry.semconv._incubating.attributes import ( | from opentelemetry.semconv._incubating.attributes import ( | ||||||
|     server_attributes as ServerAttributes, |     server_attributes as ServerAttributes, | ||||||
| ) | ) | ||||||
|  | from opentelemetry.semconv._incubating.metrics import gen_ai_metrics | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.vcr() | @pytest.mark.vcr() | ||||||
|  | @ -94,7 +95,9 @@ def test_chat_completion_no_content( | ||||||
|     assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) |     assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_chat_completion_bad_endpoint(span_exporter, instrument_no_content): | def test_chat_completion_bad_endpoint( | ||||||
|  |     span_exporter, metric_reader, instrument_no_content | ||||||
|  | ): | ||||||
|     llm_model_value = "gpt-4o-mini" |     llm_model_value = "gpt-4o-mini" | ||||||
|     messages_value = [{"role": "user", "content": "Say this is a test"}] |     messages_value = [{"role": "user", "content": "Say this is a test"}] | ||||||
| 
 | 
 | ||||||
|  | @ -116,10 +119,31 @@ def test_chat_completion_bad_endpoint(span_exporter, instrument_no_content): | ||||||
|         "APIConnectionError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] |         "APIConnectionError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     metrics = metric_reader.get_metrics_data().resource_metrics | ||||||
|  |     assert len(metrics) == 1 | ||||||
|  | 
 | ||||||
|  |     metric_data = metrics[0].scope_metrics[0].metrics | ||||||
|  |     duration_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_OPERATION_DURATION | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert duration_metric is not None | ||||||
|  |     assert duration_metric.data.data_points[0].sum > 0 | ||||||
|  |     assert ( | ||||||
|  |         duration_metric.data.data_points[0].attributes[ | ||||||
|  |             ErrorAttributes.ERROR_TYPE | ||||||
|  |         ] | ||||||
|  |         == "APIConnectionError" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.vcr() | @pytest.mark.vcr() | ||||||
| def test_chat_completion_404( | def test_chat_completion_404( | ||||||
|     span_exporter, openai_client, instrument_no_content |     span_exporter, openai_client, metric_reader, instrument_no_content | ||||||
| ): | ): | ||||||
|     llm_model_value = "this-model-does-not-exist" |     llm_model_value = "this-model-does-not-exist" | ||||||
|     messages_value = [{"role": "user", "content": "Say this is a test"}] |     messages_value = [{"role": "user", "content": "Say this is a test"}] | ||||||
|  | @ -135,6 +159,27 @@ def test_chat_completion_404( | ||||||
|     assert_all_attributes(spans[0], llm_model_value) |     assert_all_attributes(spans[0], llm_model_value) | ||||||
|     assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] |     assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] | ||||||
| 
 | 
 | ||||||
|  |     metrics = metric_reader.get_metrics_data().resource_metrics | ||||||
|  |     assert len(metrics) == 1 | ||||||
|  | 
 | ||||||
|  |     metric_data = metrics[0].scope_metrics[0].metrics | ||||||
|  |     duration_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_OPERATION_DURATION | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert duration_metric is not None | ||||||
|  |     assert duration_metric.data.data_points[0].sum > 0 | ||||||
|  |     assert ( | ||||||
|  |         duration_metric.data.data_points[0].attributes[ | ||||||
|  |             ErrorAttributes.ERROR_TYPE | ||||||
|  |         ] | ||||||
|  |         == "NotFoundError" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.vcr() | @pytest.mark.vcr() | ||||||
| def test_chat_completion_extra_params( | def test_chat_completion_extra_params( | ||||||
|  |  | ||||||
|  | @ -0,0 +1,190 @@ | ||||||
|  | import pytest | ||||||
|  | 
 | ||||||
|  | from opentelemetry.semconv._incubating.attributes import ( | ||||||
|  |     gen_ai_attributes as GenAIAttributes, | ||||||
|  | ) | ||||||
|  | from opentelemetry.semconv._incubating.attributes import ( | ||||||
|  |     server_attributes as ServerAttributes, | ||||||
|  | ) | ||||||
|  | from opentelemetry.semconv._incubating.metrics import gen_ai_metrics | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def assert_all_metric_attributes(data_point): | ||||||
|  |     assert GenAIAttributes.GEN_AI_OPERATION_NAME in data_point.attributes | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] | ||||||
|  |         == GenAIAttributes.GenAiOperationNameValues.CHAT.value | ||||||
|  |     ) | ||||||
|  |     assert GenAIAttributes.GEN_AI_SYSTEM in data_point.attributes | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[GenAIAttributes.GEN_AI_SYSTEM] | ||||||
|  |         == GenAIAttributes.GenAiSystemValues.OPENAI.value | ||||||
|  |     ) | ||||||
|  |     assert GenAIAttributes.GEN_AI_REQUEST_MODEL in data_point.attributes | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] | ||||||
|  |         == "gpt-4o-mini" | ||||||
|  |     ) | ||||||
|  |     assert GenAIAttributes.GEN_AI_RESPONSE_MODEL in data_point.attributes | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[GenAIAttributes.GEN_AI_RESPONSE_MODEL] | ||||||
|  |         == "gpt-4o-mini-2024-07-18" | ||||||
|  |     ) | ||||||
|  |     assert "gen_ai.openai.response.system_fingerprint" in data_point.attributes | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes["gen_ai.openai.response.system_fingerprint"] | ||||||
|  |         == "fp_0ba0d124f1" | ||||||
|  |     ) | ||||||
|  |     assert ( | ||||||
|  |         GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER | ||||||
|  |         in data_point.attributes | ||||||
|  |     ) | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[ | ||||||
|  |             GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER | ||||||
|  |         ] | ||||||
|  |         == "default" | ||||||
|  |     ) | ||||||
|  |     assert ( | ||||||
|  |         data_point.attributes[ServerAttributes.SERVER_ADDRESS] | ||||||
|  |         == "api.openai.com" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.vcr() | ||||||
|  | def test_chat_completion_metrics( | ||||||
|  |     metric_reader, openai_client, instrument_with_content | ||||||
|  | ): | ||||||
|  |     llm_model_value = "gpt-4o-mini" | ||||||
|  |     messages_value = [{"role": "user", "content": "Say this is a test"}] | ||||||
|  | 
 | ||||||
|  |     openai_client.chat.completions.create( | ||||||
|  |         messages=messages_value, model=llm_model_value, stream=False | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     metrics = metric_reader.get_metrics_data().resource_metrics | ||||||
|  |     assert len(metrics) == 1 | ||||||
|  | 
 | ||||||
|  |     metric_data = metrics[0].scope_metrics[0].metrics | ||||||
|  |     assert len(metric_data) == 2 | ||||||
|  | 
 | ||||||
|  |     duration_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_OPERATION_DURATION | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert duration_metric is not None | ||||||
|  |     assert duration_metric.data.data_points[0].sum > 0 | ||||||
|  |     assert_all_metric_attributes(duration_metric.data.data_points[0]) | ||||||
|  | 
 | ||||||
|  |     token_usage_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_TOKEN_USAGE | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert token_usage_metric is not None | ||||||
|  | 
 | ||||||
|  |     input_token_usage = next( | ||||||
|  |         ( | ||||||
|  |             d | ||||||
|  |             for d in token_usage_metric.data.data_points | ||||||
|  |             if d.attributes[GenAIAttributes.GEN_AI_TOKEN_TYPE] | ||||||
|  |             == GenAIAttributes.GenAiTokenTypeValues.INPUT.value | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert input_token_usage is not None | ||||||
|  |     assert input_token_usage.sum == 12 | ||||||
|  |     # assert against buckets [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864] | ||||||
|  |     assert input_token_usage.bucket_counts[2] == 1 | ||||||
|  |     assert_all_metric_attributes(input_token_usage) | ||||||
|  | 
 | ||||||
|  |     output_token_usage = next( | ||||||
|  |         ( | ||||||
|  |             d | ||||||
|  |             for d in token_usage_metric.data.data_points | ||||||
|  |             if d.attributes[GenAIAttributes.GEN_AI_TOKEN_TYPE] | ||||||
|  |             == GenAIAttributes.GenAiTokenTypeValues.COMPLETION.value | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert output_token_usage is not None | ||||||
|  |     assert output_token_usage.sum == 5 | ||||||
|  |     # assert against buckets [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864] | ||||||
|  |     assert output_token_usage.bucket_counts[2] == 1 | ||||||
|  |     assert_all_metric_attributes(output_token_usage) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.vcr() | ||||||
|  | @pytest.mark.asyncio() | ||||||
|  | async def test_async_chat_completion_metrics( | ||||||
|  |     metric_reader, async_openai_client, instrument_with_content | ||||||
|  | ): | ||||||
|  |     llm_model_value = "gpt-4o-mini" | ||||||
|  |     messages_value = [{"role": "user", "content": "Say this is a test"}] | ||||||
|  | 
 | ||||||
|  |     await async_openai_client.chat.completions.create( | ||||||
|  |         messages=messages_value, model=llm_model_value, stream=False | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     metrics = metric_reader.get_metrics_data().resource_metrics | ||||||
|  |     assert len(metrics) == 1 | ||||||
|  | 
 | ||||||
|  |     metric_data = metrics[0].scope_metrics[0].metrics | ||||||
|  |     assert len(metric_data) == 2 | ||||||
|  | 
 | ||||||
|  |     duration_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_OPERATION_DURATION | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert duration_metric is not None | ||||||
|  |     assert duration_metric.data.data_points[0].sum > 0 | ||||||
|  |     assert_all_metric_attributes(duration_metric.data.data_points[0]) | ||||||
|  | 
 | ||||||
|  |     token_usage_metric = next( | ||||||
|  |         ( | ||||||
|  |             m | ||||||
|  |             for m in metric_data | ||||||
|  |             if m.name == gen_ai_metrics.GEN_AI_CLIENT_TOKEN_USAGE | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  |     assert token_usage_metric is not None | ||||||
|  | 
 | ||||||
|  |     input_token_usage = next( | ||||||
|  |         ( | ||||||
|  |             d | ||||||
|  |             for d in token_usage_metric.data.data_points | ||||||
|  |             if d.attributes[GenAIAttributes.GEN_AI_TOKEN_TYPE] | ||||||
|  |             == GenAIAttributes.GenAiTokenTypeValues.INPUT.value | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert input_token_usage is not None | ||||||
|  |     assert input_token_usage.sum == 12 | ||||||
|  |     assert_all_metric_attributes(input_token_usage) | ||||||
|  | 
 | ||||||
|  |     output_token_usage = next( | ||||||
|  |         ( | ||||||
|  |             d | ||||||
|  |             for d in token_usage_metric.data.data_points | ||||||
|  |             if d.attributes[GenAIAttributes.GEN_AI_TOKEN_TYPE] | ||||||
|  |             == GenAIAttributes.GenAiTokenTypeValues.COMPLETION.value | ||||||
|  |         ), | ||||||
|  |         None, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert output_token_usage is not None | ||||||
|  |     assert output_token_usage.sum == 12 | ||||||
|  |     assert_all_metric_attributes(output_token_usage) | ||||||
		Loading…
	
		Reference in New Issue