diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md index ae53e3c60b..3a84296aa5 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md @@ -8,7 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - Fix `AttributeError` when handling `LegacyAPIResponse` (from `with_raw_response`) - ([#4002](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4002)) + ([#4017](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4017)) +- Add support for chat completions choice count and stop sequences span attributes + ([#4028](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4028)) ## Version 2.2b0 (2025-11-25) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py index 4b58759e22..7a0ef158a6 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py @@ -194,6 +194,8 @@ def get_llm_request_attributes( client_instance, operation_name=GenAIAttributes.GenAiOperationNameValues.CHAT.value, ): + # pylint: disable=too-many-branches + attributes = { GenAIAttributes.GEN_AI_OPERATION_NAME: operation_name, GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value, @@ -222,6 +224,20 @@ def get_llm_request_attributes( } ) + if (choice_count := kwargs.get("n")) is not None: + # Only add non default, meaningful values + if isinstance(choice_count, int) and choice_count != 1: + attributes[GenAIAttributes.GEN_AI_REQUEST_CHOICE_COUNT] = ( + choice_count + ) + + if (stop_sequences := kwargs.get("stop")) is not None: + if isinstance(stop_sequences, str): + stop_sequences = [stop_sequences] + attributes[GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES] = ( + stop_sequences + ) + if (response_format := kwargs.get("response_format")) is not None: # response_format may be string or object with a string in the `type` key if isinstance(response_format, Mapping): diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_handle_stop_sequences_as_string.yaml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_handle_stop_sequences_as_string.yaml new file mode 100644 index 0000000000..27a7866e4d --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_handle_stop_sequences_as_string.yaml @@ -0,0 +1,144 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "user", + "content": "Say this is a test" + } + ], + "model": "gpt-4o-mini", + "stop": "stop" + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '105' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 1.109.1 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 1.109.1 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.12 + authorization: + - Bearer test_openai_api_key + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-Clubs1bbZwGUeDKpnPUWDMEhSbquh", + "object": "chat.completion", + "created": 1765535060, + "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, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "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 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_11f3029f6b" + } + headers: + CF-RAY: + - 9acc82e96fde4bf3-MXP + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 12 Dec 2025 10:24:20 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '852' + openai-organization: test_openai_org_id + openai-processing-ms: + - '500' + openai-project: + - proj_Pf1eM5R55Z35wBy4rt8PxAGq + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '826' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '10000000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '9999993' + x-ratelimit-reset-requests: + - 6ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_993163a7581641b7b9aee6e03ef4ca3a + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_n_1_is_not_reported.yaml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_n_1_is_not_reported.yaml new file mode 100644 index 0000000000..2cc137f4b3 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_chat_completion_n_1_is_not_reported.yaml @@ -0,0 +1,144 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "user", + "content": "Say this is a test" + } + ], + "model": "gpt-4o-mini", + "n": 1 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '97' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 1.109.1 + X-Stainless-Arch: + - x64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - Linux + X-Stainless-Package-Version: + - 1.109.1 + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.12 + authorization: + - Bearer test_openai_api_key + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-ClubqNLub25QPdqxjOslny04PLCYZ", + "object": "chat.completion", + "created": 1765535058, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "This is a test. How can I assist you today?", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "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 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_11f3029f6b" + } + headers: + CF-RAY: + - 9acc82ddddd4edba-MXP + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Fri, 12 Dec 2025 10:24:19 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '850' + openai-organization: test_openai_org_id + openai-processing-ms: + - '405' + openai-project: + - proj_Pf1eM5R55Z35wBy4rt8PxAGq + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '529' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '10000000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '9999993' + x-ratelimit-reset-requests: + - 6ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_2b15771f24a4465fb5fc43b797f39e04 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_completions.py index 36eec591df..2ab4b9977f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_completions.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_completions.py @@ -11,7 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# pylint: disable=too-many-locals + +# pylint: disable=too-many-locals,too-many-lines import logging @@ -252,6 +253,7 @@ def test_chat_completion_extra_params( messages_value = [{"role": "user", "content": "Say this is a test"}] response = openai_client.chat.completions.create( + n=2, messages=messages_value, model=llm_model_value, seed=42, @@ -260,6 +262,7 @@ def test_chat_completion_extra_params( stream=False, extra_body={"service_tier": "default"}, response_format={"type": "text"}, + stop=["full", "stop"], ) spans = span_exporter.get_finished_spans() @@ -290,6 +293,68 @@ def test_chat_completion_extra_params( ] == "text" ) + assert spans[0].attributes[ + GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES + ] == ("full", "stop") + assert ( + spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_CHOICE_COUNT] == 2 + ) + + +@pytest.mark.vcr() +def test_chat_completion_n_1_is_not_reported( + span_exporter, openai_client, instrument_no_content +): + llm_model_value = "gpt-4o-mini" + messages_value = [{"role": "user", "content": "Say this is a test"}] + + response = openai_client.chat.completions.create( + n=1, + messages=messages_value, + model=llm_model_value, + ) + + spans = span_exporter.get_finished_spans() + assert_all_attributes( + spans[0], + llm_model_value, + response.id, + response.model, + response.usage.prompt_tokens, + response.usage.completion_tokens, + response_service_tier=getattr(response, "service_tier", None), + ) + assert ( + GenAIAttributes.GEN_AI_REQUEST_CHOICE_COUNT not in spans[0].attributes + ) + + +@pytest.mark.vcr() +def test_chat_completion_handle_stop_sequences_as_string( + span_exporter, openai_client, instrument_no_content +): + llm_model_value = "gpt-4o-mini" + messages_value = [{"role": "user", "content": "Say this is a test"}] + + response = openai_client.chat.completions.create( + messages=messages_value, + model=llm_model_value, + stop="stop", + ) + + spans = span_exporter.get_finished_spans() + assert_all_attributes( + spans[0], + llm_model_value, + response.id, + response.model, + response.usage.prompt_tokens, + response.usage.completion_tokens, + response_service_tier=getattr(response, "service_tier", None), + ) + assert spans[0].attributes[ + GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES + ] == ("stop",) @pytest.mark.vcr() @@ -313,6 +378,10 @@ def test_chat_completion_multiple_choices( response.usage.completion_tokens, ) + assert ( + spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_CHOICE_COUNT] == 2 + ) + logs = log_exporter.get_finished_logs() assert len(logs) == 3 # 1 user message + 2 choice messages