From f8656f07c5b90e5b80590e620be475428de7406c Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Feb 2026 05:39:27 -0800 Subject: [PATCH 01/41] Add embedding invocation type --- .../src/opentelemetry/util/genai/handler.py | 76 +++++++++- .../opentelemetry/util/genai/span_utils.py | 113 +++++++++++++++ .../src/opentelemetry/util/genai/types.py | 55 +++++++ .../tests/test_utils.py | 136 +++++++++++++++++- 4 files changed, 377 insertions(+), 3 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 54e626deaa..5bb37e3e33 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -82,9 +82,15 @@ from opentelemetry.util.genai.span_utils import ( _apply_error_attributes, _apply_llm_finish_attributes, + _apply_embedding_finish_attributes, _maybe_emit_llm_event, + _maybe_emit_embedding_event, +) +from opentelemetry.util.genai.types import ( + Error, + LLMInvocation, + EmbeddingInvocation, ) -from opentelemetry.util.genai.types import Error, LLMInvocation from opentelemetry.util.genai.version import __version__ @@ -131,6 +137,18 @@ def _record_llm_metrics( error_type=error_type, ) + def _record_embedding_metrics( + self, + invocation: EmbeddingInvocation, + span: Span | None = None, + *, + error_type: str | None = None, + ) -> None: + # Metrics recorder currently supports LLMInvocation fields only. + # Keep embedding metrics as a no-op until dedicated embedding + # metric support is added. + return + def start_llm( self, invocation: LLMInvocation, @@ -208,6 +226,62 @@ def llm( raise self.stop_llm(invocation) + def start_embedding( + self, invocation: EmbeddingInvocation + ) -> EmbeddingInvocation: + """Start an embedding invocation and create a pending span entry.""" + + span = self._tracer.start_span( + name=f"{invocation.operation_name} {invocation.request_model}", + kind=SpanKind.CLIENT, + ) + # Record a monotonic start timestamp (seconds) for duration + # calculation using timeit.default_timer. + invocation.monotonic_start_s = timeit.default_timer() + invocation.span = span + invocation.context_token = otel_context.attach( + set_span_in_context(span) + ) + return invocation + + def stop_embedding( + self, invocation: EmbeddingInvocation + ) -> EmbeddingInvocation: + """Finalize an embedding invocation successfully and end its span.""" + if invocation.context_token is None or invocation.span is None: + # TODO: Provide feedback that this invocation was not started + return invocation + + span = invocation.span + _apply_embedding_finish_attributes(span, invocation) + self._record_embedding_metrics(invocation, span) + _maybe_emit_embedding_event(self._logger, span, invocation) + # Detach context and end span + otel_context.detach(invocation.context_token) + span.end() + return invocation + + def fail_embedding( + self, invocation: EmbeddingInvocation, error: Error + ) -> EmbeddingInvocation: + """Fail an embedding invocation and end its span with error status.""" + if invocation.context_token is None or invocation.span is None: + # TODO: Provide feedback that this invocation was not started + return invocation + + span = invocation.span + _apply_embedding_finish_attributes(invocation.span, invocation) + _apply_error_attributes(invocation.span, error) + error_type = getattr(error.type, "__qualname__", None) + self._record_embedding_metrics( + invocation, span, error_type=error_type + ) + _maybe_emit_embedding_event(self._logger, span, invocation, error) + # Detach context and end span + otel_context.detach(invocation.context_token) + span.end() + return invocation + def get_telemetry_handler( tracer_provider: TracerProvider | None = None, diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 889994436f..7bb7484151 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -35,6 +35,7 @@ Error, InputMessage, LLMInvocation, + EmbeddingInvocation, MessagePart, OutputMessage, ) @@ -68,11 +69,35 @@ def _get_llm_common_attributes( } +def _get_embedding_common_attributes( + invocation: EmbeddingInvocation, +) -> dict[str, Any]: + """Get common Embedding attributes shared by finish() and error() paths. + + Returns a dictionary of attributes. + """ + optional_attrs = ( + (server_attributes.SERVER_ADDRESS, invocation.server_address), + (server_attributes.SERVER_PORT, invocation.server_port), + ) + + return { + GenAI.GEN_AI_OPERATION_NAME: invocation.operation_name, + GenAI.GEN_AI_PROVIDER_NAME: invocation.provider, + **{key: value for key, value in optional_attrs if value is not None}, + } + + def _get_llm_span_name(invocation: LLMInvocation) -> str: """Get the span name for an LLM invocation.""" return f"{invocation.operation_name} {invocation.request_model}".strip() +def _get_embedding_span_name(invocation: EmbeddingInvocation) -> str: + """Get the span name for an Embedding invocation.""" + return f"{invocation.operation_name} {invocation.request_model}".strip() + + def _get_llm_messages_attributes_for_span( input_messages: list[InputMessage], output_messages: list[OutputMessage], @@ -192,6 +217,44 @@ def _maybe_emit_llm_event( logger.emit(event) +def _maybe_emit_embedding_event( + logger: Logger | None, + span: Span, + invocation: EmbeddingInvocation, + error: Error | None = None, +) -> None: + """Emit a gen_ai.client.inference.operation.details event to the logger. + + This function creates a LogRecord event following the semantic convention + for gen_ai.client.inference.operation.details as specified in the GenAI + event semantic conventions. + + For more details, see the semantic convention documentation: + https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-events.md#event-eventgen_aiclientinferenceoperationdetails + """ + if not is_experimental_mode() or not should_emit_event() or logger is None: + return + + # Build event attributes by reusing the attribute getter functions + attributes: dict[str, Any] = {} + attributes.update(_get_embedding_common_attributes(invocation)) + attributes.update(_get_embedding_request_attributes(invocation)) + attributes.update(_get_embedding_response_attributes(invocation)) + + # Add error.type if operation ended in error + if error is not None: + attributes[error_attributes.ERROR_TYPE] = error.type.__qualname__ + + # Create and emit the event + context = set_span_in_context(span, get_current()) + event = LogRecord( + event_name="gen_ai.client.embedding.operation.details", + attributes=attributes, + context=context, + ) + logger.emit(event) + + def _apply_llm_finish_attributes( span: Span, invocation: LLMInvocation ) -> None: @@ -218,6 +281,26 @@ def _apply_llm_finish_attributes( span.set_attributes(attributes) +def _apply_embedding_finish_attributes( + span: Span, invocation: EmbeddingInvocation +) -> None: + """Apply attributes common to embedding finish() paths.""" + # Update span name + span.update_name(_get_embedding_span_name(invocation)) + + # Build all attributes by reusing the attribute getter functions + attributes: dict[str, Any] = {} + attributes.update(_get_embedding_common_attributes(invocation)) + attributes.update(_get_embedding_request_attributes(invocation)) + attributes.update(_get_embedding_response_attributes(invocation)) + + attributes.update(invocation.attributes) + + # Set all attributes on the span + if attributes: + span.set_attributes(attributes) + + def _apply_error_attributes(span: Span, error: Error) -> None: """Apply status and error attributes common to error() paths.""" span.set_status(Status(StatusCode.ERROR, error.message)) @@ -244,6 +327,19 @@ def _get_llm_request_attributes( return {key: value for key, value in optional_attrs if value is not None} +def _get_embedding_request_attributes( + invocation: EmbeddingInvocation, +) -> dict[str, Any]: + """Get GenAI request semantic convention attributes.""" + optional_attrs = ( + (GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model), + (GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT, invocation.dimension_count), + (GenAI.GEN_AI_REQUEST_ENCODING_FORMATS, invocation.encoding_formats), + ) + + return {key: value for key, value in optional_attrs if value is not None} + + def _get_llm_response_attributes( invocation: LLMInvocation, ) -> dict[str, Any]: @@ -279,6 +375,17 @@ def _get_llm_response_attributes( return {key: value for key, value in optional_attrs if value is not None} +def _get_embedding_response_attributes( + invocation: EmbeddingInvocation, +) -> dict[str, Any]: + """Get GenAI response semantic convention attributes.""" + optional_attrs = ( + (GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens), + ) + + return {key: value for key, value in optional_attrs if value is not None} + + __all__ = [ "_apply_llm_finish_attributes", "_apply_error_attributes", @@ -287,4 +394,10 @@ def _get_llm_response_attributes( "_get_llm_response_attributes", "_get_llm_span_name", "_maybe_emit_llm_event", + "_apply_embedding_finish_attributes", + "_get_embedding_common_attributes", + "_get_embedding_request_attributes", + "_get_embedding_response_attributes", + "_get_embedding_span_name", + "_maybe_emit_embedding_event", ] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 045a65b372..b7b43ae5e0 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -256,6 +256,61 @@ class LLMInvocation(GenAIInvocation): monotonic_start_s: float | None = None +@dataclass +class EmbeddingInvocation(GenAIInvocation): + """ + Represents a single embedding model invocation. When creating an + EmbeddingInvocation object, only update the data attributes. The span + and context_token attributes are set by the TelemetryHandler. + """ + + operation_name: str = field( + default=GenAI.GenAiOperationNameValues.EMBEDDINGS.value, + metadata={"semconv": GenAI.GEN_AI_OPERATION_NAME}, + ) + provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock + + request_model: str | None = field( + default=None, + metadata={"semconv": GenAI.GEN_AI_REQUEST_MODEL}, + ) + + server_address: str | None = None + server_port: int | None = None + error_type: str | None = None + + # encoding_formats can be multi-value -> combinational cardinality risk. + # Keep on spans/events only. + encoding_formats: list[str] = field( + default_factory=list, + metadata={"semconv": GenAI.GEN_AI_REQUEST_ENCODING_FORMATS}, + ) + + input_tokens: int | None = field( + default=None, + metadata={"semconv": GenAI.GEN_AI_USAGE_INPUT_TOKENS}, + ) + dimension_count: int | None = None + + attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) + """ + Additional attributes to set on spans and/or events. These attributes + will not be set on metrics. + """ + + metric_attributes: dict[str, Any] = field( + default_factory=_new_str_any_dict + ) + """ + Additional attributes to set on metrics. Must be of a low cardinality. + These attributes will not be set on spans or events. + """ + # Monotonic start time in seconds (from timeit.default_timer) used + # for duration calculations to avoid mixing clock sources. This is + # populated by the TelemetryHandler when starting an invocation. + monotonic_start_s: float | None = None + + @dataclass class Error: message: str diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 851670026d..f103d05ebf 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -25,7 +25,7 @@ ) from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import ( - InMemoryLogRecordExporter, + InMemoryLogExporter, SimpleLogRecordProcessor, ) from opentelemetry.sdk.trace import ReadableSpan, TracerProvider @@ -49,6 +49,7 @@ from opentelemetry.util.genai.handler import get_telemetry_handler from opentelemetry.util.genai.types import ( ContentCapturingMode, + EmbeddingInvocation, Error, InputMessage, LLMInvocation, @@ -222,7 +223,7 @@ def setUp(self): tracer_provider.add_span_processor( SimpleSpanProcessor(self.span_exporter) ) - self.log_exporter = InMemoryLogRecordExporter() + self.log_exporter = InMemoryLogExporter() logger_provider = LoggerProvider() logger_provider.add_log_record_processor( SimpleLogRecordProcessor(self.log_exporter) @@ -861,6 +862,137 @@ def test_emits_llm_event_by_default_for_span_and_event(self): ) self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, log_record.attributes) + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_ONLY", + emit_event="", + ) + def test_embedding_manual_start_and_stop_creates_span(self): + invocation = EmbeddingInvocation( + request_model="embed-model", + provider="test-provider", + dimension_count=1536, + encoding_formats=["float"], + input_tokens=123, + server_address="custom.server.com", + server_port=42, + attributes={"custom_embed_attr": "value"}, + ) + + self.telemetry_handler.start_embedding(invocation) + assert invocation.span is not None + invocation.attributes.update({"extra_embed": "info"}) + invocation.metric_attributes = {"should not be on span": "value"} + self.telemetry_handler.stop_embedding(invocation) + + span = _get_single_span(self.span_exporter) + self.assertEqual(span.name, "embeddings embed-model") + self.assertEqual(span.kind, trace.SpanKind.CLIENT) + _assert_span_time_order(span) + + attrs = _get_span_attributes(span) + _assert_span_attributes( + attrs, + { + GenAI.GEN_AI_OPERATION_NAME: "embeddings", + GenAI.GEN_AI_REQUEST_MODEL: "embed-model", + GenAI.GEN_AI_PROVIDER_NAME: "test-provider", + GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT: 1536, + GenAI.GEN_AI_REQUEST_ENCODING_FORMATS: ("float",), + GenAI.GEN_AI_USAGE_INPUT_TOKENS: 123, + server_attributes.SERVER_ADDRESS: "custom.server.com", + server_attributes.SERVER_PORT: 42, + "custom_embed_attr": "value", + "extra_embed": "info", + }, + ) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="true", + ) + def test_emits_embedding_event(self): + invocation = EmbeddingInvocation( + request_model="event-embed-model", + provider="test-provider", + dimension_count=1024, + encoding_formats=["float"], + input_tokens=10, + server_address="event.server.com", + server_port=8443, + ) + + self.telemetry_handler.start_embedding(invocation) + self.telemetry_handler.stop_embedding(invocation) + + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + self.assertEqual( + log_record.event_name, "gen_ai.client.embedding.operation.details" + ) + + attrs = log_record.attributes + self.assertIsNotNone(attrs) + self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "embeddings") + self.assertEqual( + attrs[GenAI.GEN_AI_REQUEST_MODEL], "event-embed-model" + ) + self.assertEqual(attrs[GenAI.GEN_AI_PROVIDER_NAME], "test-provider") + self.assertEqual(attrs[GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT], 1024) + self.assertEqual( + _normalize_to_list(attrs[GenAI.GEN_AI_REQUEST_ENCODING_FORMATS]), + ["float"], + ) + self.assertEqual(attrs[GenAI.GEN_AI_USAGE_INPUT_TOKENS], 10) + self.assertEqual(attrs[server_attributes.SERVER_ADDRESS], "event.server.com") + self.assertEqual(attrs[server_attributes.SERVER_PORT], 8443) + + span = _get_single_span(self.span_exporter) + self.assertIsNotNone(log_record.trace_id) + self.assertIsNotNone(log_record.span_id) + self.assertIsNotNone(span.context) + self.assertEqual(log_record.trace_id, span.context.trace_id) + self.assertEqual(log_record.span_id, span.context.span_id) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="true", + ) + def test_emits_embedding_event_with_error(self): + class EmbeddingError(RuntimeError): + pass + + invocation = EmbeddingInvocation( + request_model="error-embed-model", + provider="test-provider", + input_tokens=9, + ) + self.telemetry_handler.start_embedding(invocation) + error = Error(message="embedding error", type=EmbeddingError) + self.telemetry_handler.fail_embedding(invocation, error) + + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + attrs = log_record.attributes + self.assertEqual( + attrs[error_attributes.ERROR_TYPE], EmbeddingError.__qualname__ + ) + self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "embeddings") + self.assertEqual( + attrs[GenAI.GEN_AI_REQUEST_MODEL], "error-embed-model" + ) + + span = _get_single_span(self.span_exporter) + self.assertIsNotNone(log_record.trace_id) + self.assertIsNotNone(log_record.span_id) + self.assertIsNotNone(span.context) + self.assertEqual(log_record.trace_id, span.context.trace_id) + self.assertEqual(log_record.span_id, span.context.span_id) + class AnyNonNone: def __eq__(self, other): From 69d55987255774a292acbb35313a08121386f299 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Feb 2026 09:06:13 -0800 Subject: [PATCH 02/41] pre-commit fix --- util/opentelemetry-util-genai/CHANGELOG.md | 4 + util/opentelemetry-util-genai/README.rst | 13 ++ .../src/opentelemetry/util/genai/handler.py | 11 +- .../src/opentelemetry/util/genai/types.py | 4 - .../tests/test_utils.py | 162 ++++++++---------- 5 files changed, 96 insertions(+), 98 deletions(-) diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index f64092a697..8ba4009f39 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3891](#3891)) - Add parent class genAI invocation ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3889](#3889)) +- Add `EmbeddingInvocation` span lifecycle support (`start_embedding`, + `stop_embedding`, `fail_embedding`), embedding span tests, and defer + embedding metrics emission (current no-op) to a follow-up PR. + ([#](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/)) ## Version 0.2b0 (2025-10-14) diff --git a/util/opentelemetry-util-genai/README.rst b/util/opentelemetry-util-genai/README.rst index bc4b348fcf..95b81b1da3 100644 --- a/util/opentelemetry-util-genai/README.rst +++ b/util/opentelemetry-util-genai/README.rst @@ -36,6 +36,19 @@ This package provides these span attributes: - `gen_ai.output.messages`: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]') - `gen_ai.system_instructions`: Str('[{"content": "You are a helpful assistant.", "type": "text"}]') (when system instruction is provided) +This package also supports embedding invocation spans via +`EmbeddingInvocation` and `TelemetryHandler.start_embedding/stop_embedding/fail_embedding`. +For embedding invocations, common attributes include: + +- `gen_ai.provider.name`: Str(openai) +- `gen_ai.operation.name`: Str(embeddings) +- `gen_ai.request.model`: Str(text-embedding-3-small) +- `gen_ai.embedding.dimension_count`: Int(1536) +- `gen_ai.request.encoding_formats`: Slice(["float"]) +- `gen_ai.usage.input_tokens`: Int(24) +- `server.address`: Str(api.openai.com) +- `server.port`: Int(443) + When `EVENT_ONLY` or `SPAN_AND_EVENT` mode is enabled and a LoggerProvider is configured, the package also emits `gen_ai.client.inference.operation.details` events with structured message content (as dictionaries instead of JSON strings). Note that when using `EVENT_ONLY` diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 5bb37e3e33..c740bffd1e 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -80,16 +80,15 @@ ) from opentelemetry.util.genai.metrics import InvocationMetricsRecorder from opentelemetry.util.genai.span_utils import ( + _apply_embedding_finish_attributes, _apply_error_attributes, _apply_llm_finish_attributes, - _apply_embedding_finish_attributes, _maybe_emit_llm_event, - _maybe_emit_embedding_event, ) from opentelemetry.util.genai.types import ( + EmbeddingInvocation, Error, LLMInvocation, - EmbeddingInvocation, ) from opentelemetry.util.genai.version import __version__ @@ -255,7 +254,6 @@ def stop_embedding( span = invocation.span _apply_embedding_finish_attributes(span, invocation) self._record_embedding_metrics(invocation, span) - _maybe_emit_embedding_event(self._logger, span, invocation) # Detach context and end span otel_context.detach(invocation.context_token) span.end() @@ -273,10 +271,7 @@ def fail_embedding( _apply_embedding_finish_attributes(invocation.span, invocation) _apply_error_attributes(invocation.span, error) error_type = getattr(error.type, "__qualname__", None) - self._record_embedding_metrics( - invocation, span, error_type=error_type - ) - _maybe_emit_embedding_event(self._logger, span, invocation, error) + self._record_embedding_metrics(invocation, span, error_type=error_type) # Detach context and end span otel_context.detach(invocation.context_token) span.end() diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index b7b43ae5e0..d9da889ade 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -305,10 +305,6 @@ class EmbeddingInvocation(GenAIInvocation): Additional attributes to set on metrics. Must be of a low cardinality. These attributes will not be set on spans or events. """ - # Monotonic start time in seconds (from timeit.default_timer) used - # for duration calculations to avoid mixing clock sources. This is - # populated by the TelemetryHandler when starting an invocation. - monotonic_start_s: float | None = None @dataclass diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index f103d05ebf..c76a6942a9 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -501,6 +501,82 @@ def test_parent_child_span_relationship(self): # Parent should not have a parent (root) assert parent_span.parent is None + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_ONLY", + emit_event="", + ) + def test_embedding_parent_child_span_relationship(self): + parent_invocation = EmbeddingInvocation( + request_model="embed-parent-model", + provider="test-provider", + input_tokens=10, + ) + child_invocation = EmbeddingInvocation( + request_model="embed-child-model", + provider="test-provider", + input_tokens=5, + ) + + self.telemetry_handler.start_embedding(parent_invocation) + assert parent_invocation.span is not None + self.telemetry_handler.start_embedding(child_invocation) + assert child_invocation.span is not None + self.telemetry_handler.stop_embedding(child_invocation) + self.telemetry_handler.stop_embedding(parent_invocation) + + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 2 + child_span = next( + s for s in spans if s.name == "embeddings embed-child-model" + ) + parent_span = next( + s for s in spans if s.name == "embeddings embed-parent-model" + ) + + assert child_span.context.trace_id == parent_span.context.trace_id + assert child_span.parent is not None + assert child_span.parent.span_id == parent_span.context.span_id + assert parent_span.parent is None + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_ONLY", + emit_event="", + ) + def test_llm_parent_embedding_child_span_relationship(self): + message = _create_input_message("hi") + chat_generation = _create_output_message("ok") + child_invocation = EmbeddingInvocation( + request_model="embed-child-model", + provider="test-provider", + input_tokens=3, + ) + + with self.telemetry_handler.llm() as parent_invocation: + for attr, value in { + "request_model": "parent-model", + "input_messages": [message], + "provider": "test-provider", + }.items(): + setattr(parent_invocation, attr, value) + self.telemetry_handler.start_embedding(child_invocation) + assert child_invocation.span is not None + self.telemetry_handler.stop_embedding(child_invocation) + parent_invocation.output_messages = [chat_generation] + + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 2 + child_span = next( + s for s in spans if s.name == "embeddings embed-child-model" + ) + parent_span = next(s for s in spans if s.name == "chat parent-model") + + assert child_span.context.trace_id == parent_span.context.trace_id + assert child_span.parent is not None + assert child_span.parent.span_id == parent_span.context.span_id + assert parent_span.parent is None + def test_llm_context_manager_error_path_records_error_status_and_attrs( self, ): @@ -907,92 +983,6 @@ def test_embedding_manual_start_and_stop_creates_span(self): }, ) - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="true", - ) - def test_emits_embedding_event(self): - invocation = EmbeddingInvocation( - request_model="event-embed-model", - provider="test-provider", - dimension_count=1024, - encoding_formats=["float"], - input_tokens=10, - server_address="event.server.com", - server_port=8443, - ) - - self.telemetry_handler.start_embedding(invocation) - self.telemetry_handler.stop_embedding(invocation) - - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - self.assertEqual( - log_record.event_name, "gen_ai.client.embedding.operation.details" - ) - - attrs = log_record.attributes - self.assertIsNotNone(attrs) - self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "embeddings") - self.assertEqual( - attrs[GenAI.GEN_AI_REQUEST_MODEL], "event-embed-model" - ) - self.assertEqual(attrs[GenAI.GEN_AI_PROVIDER_NAME], "test-provider") - self.assertEqual(attrs[GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT], 1024) - self.assertEqual( - _normalize_to_list(attrs[GenAI.GEN_AI_REQUEST_ENCODING_FORMATS]), - ["float"], - ) - self.assertEqual(attrs[GenAI.GEN_AI_USAGE_INPUT_TOKENS], 10) - self.assertEqual(attrs[server_attributes.SERVER_ADDRESS], "event.server.com") - self.assertEqual(attrs[server_attributes.SERVER_PORT], 8443) - - span = _get_single_span(self.span_exporter) - self.assertIsNotNone(log_record.trace_id) - self.assertIsNotNone(log_record.span_id) - self.assertIsNotNone(span.context) - self.assertEqual(log_record.trace_id, span.context.trace_id) - self.assertEqual(log_record.span_id, span.context.span_id) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="true", - ) - def test_emits_embedding_event_with_error(self): - class EmbeddingError(RuntimeError): - pass - - invocation = EmbeddingInvocation( - request_model="error-embed-model", - provider="test-provider", - input_tokens=9, - ) - self.telemetry_handler.start_embedding(invocation) - error = Error(message="embedding error", type=EmbeddingError) - self.telemetry_handler.fail_embedding(invocation, error) - - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - attrs = log_record.attributes - self.assertEqual( - attrs[error_attributes.ERROR_TYPE], EmbeddingError.__qualname__ - ) - self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "embeddings") - self.assertEqual( - attrs[GenAI.GEN_AI_REQUEST_MODEL], "error-embed-model" - ) - - span = _get_single_span(self.span_exporter) - self.assertIsNotNone(log_record.trace_id) - self.assertIsNotNone(log_record.span_id) - self.assertIsNotNone(span.context) - self.assertEqual(log_record.trace_id, span.context.trace_id) - self.assertEqual(log_record.span_id, span.context.span_id) - class AnyNonNone: def __eq__(self, other): From c202f8b23e22d78e87e05e33f221913baaf8b43a Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Feb 2026 13:30:06 -0800 Subject: [PATCH 03/41] Reverting unnecessary changes and dealing with comments --- util/opentelemetry-util-genai/CHANGELOG.md | 10 ++--- util/opentelemetry-util-genai/README.rst | 2 +- util/opentelemetry-util-genai/pyproject.toml | 9 ++-- .../src/opentelemetry/util/genai/handler.py | 3 -- .../opentelemetry/util/genai/span_utils.py | 43 +------------------ .../tests/test_utils.py | 2 +- 6 files changed, 11 insertions(+), 58 deletions(-) diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index 8ba4009f39..2b5851af2f 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -25,10 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3891](#3891)) - Add parent class genAI invocation ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3889](#3889)) -- Add `EmbeddingInvocation` span lifecycle support (`start_embedding`, - `stop_embedding`, `fail_embedding`), embedding span tests, and defer - embedding metrics emission (current no-op) to a follow-up PR. - ([#](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/)) +- Add EmbeddingInvocation span lifecycle support + ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4219](#4219)) ## Version 0.2b0 (2025-10-14) @@ -41,8 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Make inputs / outputs / system instructions optional params to `on_completion`, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3802](#3802)). - Use a SHA256 hash of the system instructions as it's upload filename, and check - if the file exists before re-uploading it, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3814](#3814)). - + if the file exists before re-uploading it, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3814](#3814)). ## Version 0.1b0 (2025-09-25) @@ -56,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3752](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3752)) ([#3759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3759)) ([#3763](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3763)) + - Add a utility to parse the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. Add `gen_ai_latest_experimental` as a new value to the Sem Conv stability flag ([#3716](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3716)). diff --git a/util/opentelemetry-util-genai/README.rst b/util/opentelemetry-util-genai/README.rst index 95b81b1da3..5e3cf82c26 100644 --- a/util/opentelemetry-util-genai/README.rst +++ b/util/opentelemetry-util-genai/README.rst @@ -43,7 +43,7 @@ For embedding invocations, common attributes include: - `gen_ai.provider.name`: Str(openai) - `gen_ai.operation.name`: Str(embeddings) - `gen_ai.request.model`: Str(text-embedding-3-small) -- `gen_ai.embedding.dimension_count`: Int(1536) +- `gen_ai.embeddings.dimension.count`: Int(1536) - `gen_ai.request.encoding_formats`: Slice(["float"]) - `gen_ai.usage.input_tokens`: Int(24) - `server.address`: Str(api.openai.com) diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index c9d4d388c1..7df45a66b7 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-instrumentation ~= 0.60b1", + "opentelemetry-semantic-conventions ~= 0.60b1", "opentelemetry-api>=1.31.0", ] @@ -46,10 +46,7 @@ Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" path = "src/opentelemetry/util/genai/version.py" [tool.hatch.build.targets.sdist] -include = [ - "/src", - "/tests", -] +include = ["/src", "/tests"] [tool.hatch.build.targets.wheel] packages = ["src/opentelemetry"] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index c740bffd1e..42692fcb98 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -234,9 +234,6 @@ def start_embedding( name=f"{invocation.operation_name} {invocation.request_model}", kind=SpanKind.CLIENT, ) - # Record a monotonic start timestamp (seconds) for duration - # calculation using timeit.default_timer. - invocation.monotonic_start_s = timeit.default_timer() invocation.span = span invocation.context_token = otel_context.attach( set_span_in_context(span) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 7bb7484151..70fb3243b7 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -32,10 +32,10 @@ from opentelemetry.trace.propagation import set_span_in_context from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.genai.types import ( + EmbeddingInvocation, Error, InputMessage, LLMInvocation, - EmbeddingInvocation, MessagePart, OutputMessage, ) @@ -217,44 +217,6 @@ def _maybe_emit_llm_event( logger.emit(event) -def _maybe_emit_embedding_event( - logger: Logger | None, - span: Span, - invocation: EmbeddingInvocation, - error: Error | None = None, -) -> None: - """Emit a gen_ai.client.inference.operation.details event to the logger. - - This function creates a LogRecord event following the semantic convention - for gen_ai.client.inference.operation.details as specified in the GenAI - event semantic conventions. - - For more details, see the semantic convention documentation: - https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-events.md#event-eventgen_aiclientinferenceoperationdetails - """ - if not is_experimental_mode() or not should_emit_event() or logger is None: - return - - # Build event attributes by reusing the attribute getter functions - attributes: dict[str, Any] = {} - attributes.update(_get_embedding_common_attributes(invocation)) - attributes.update(_get_embedding_request_attributes(invocation)) - attributes.update(_get_embedding_response_attributes(invocation)) - - # Add error.type if operation ended in error - if error is not None: - attributes[error_attributes.ERROR_TYPE] = error.type.__qualname__ - - # Create and emit the event - context = set_span_in_context(span, get_current()) - event = LogRecord( - event_name="gen_ai.client.embedding.operation.details", - attributes=attributes, - context=context, - ) - logger.emit(event) - - def _apply_llm_finish_attributes( span: Span, invocation: LLMInvocation ) -> None: @@ -333,7 +295,7 @@ def _get_embedding_request_attributes( """Get GenAI request semantic convention attributes.""" optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model), - (GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT, invocation.dimension_count), + (GenAI.GEN_AI_EMBEDDINGS_DIMENSION_COUNT, invocation.dimension_count), (GenAI.GEN_AI_REQUEST_ENCODING_FORMATS, invocation.encoding_formats), ) @@ -399,5 +361,4 @@ def _get_embedding_response_attributes( "_get_embedding_request_attributes", "_get_embedding_response_attributes", "_get_embedding_span_name", - "_maybe_emit_embedding_event", ] diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index c76a6942a9..cf951b3f0b 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -973,7 +973,7 @@ def test_embedding_manual_start_and_stop_creates_span(self): GenAI.GEN_AI_OPERATION_NAME: "embeddings", GenAI.GEN_AI_REQUEST_MODEL: "embed-model", GenAI.GEN_AI_PROVIDER_NAME: "test-provider", - GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT: 1536, + GenAI.GEN_AI_EMBEDDINGS_DIMENSION_COUNT: 1536, GenAI.GEN_AI_REQUEST_ENCODING_FORMATS: ("float",), GenAI.GEN_AI_USAGE_INPUT_TOKENS: 123, server_attributes.SERVER_ADDRESS: "custom.server.com", From a799410bff2f41d7e42da24d81d9e63693a7ef68 Mon Sep 17 00:00:00 2001 From: shuningc Date: Fri, 20 Feb 2026 07:50:34 -0800 Subject: [PATCH 04/41] Dealing with comments --- util/opentelemetry-util-genai/pyproject.toml | 4 +- .../src/opentelemetry/util/genai/handler.py | 54 ++++++++++++++----- .../src/opentelemetry/util/genai/types.py | 25 +++------ 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index 7df45a66b7..f6fdf4624d 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.60b1", - "opentelemetry-semantic-conventions ~= 0.60b1", + "opentelemetry-instrumentation ~= 0.61b0.dev", + "opentelemetry-semantic-conventions ~= 0.61b0.dev", "opentelemetry-api>=1.31.0", ] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 42692fcb98..974c1cb658 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -225,6 +225,30 @@ def llm( raise self.stop_llm(invocation) + @contextmanager + def embedding( + self, invocation: EmbeddingInvocation | None = None + ) -> Iterator[EmbeddingInvocation]: + """Context manager for Embedding invocations. + + Only set data attributes on the invocation object, do not modify the span or context. + + Starts the span on entry. On normal exit, finalizes the invocation and ends the span. + If an exception occurs inside the context, marks the span as error, ends it, and + re-raises the original exception. + """ + if invocation is None: + invocation = EmbeddingInvocation() + self.start_embedding(invocation) + try: + yield invocation + except Exception as exc: + self.fail_embedding( + invocation, Error(message=str(exc), type=type(exc)) + ) + raise + self.stop_embedding(invocation) + def start_embedding( self, invocation: EmbeddingInvocation ) -> EmbeddingInvocation: @@ -249,11 +273,13 @@ def stop_embedding( return invocation span = invocation.span - _apply_embedding_finish_attributes(span, invocation) - self._record_embedding_metrics(invocation, span) - # Detach context and end span - otel_context.detach(invocation.context_token) - span.end() + try: + _apply_embedding_finish_attributes(span, invocation) + self._record_embedding_metrics(invocation, span) + finally: + # Detach context and end span even if finishing fails + otel_context.detach(invocation.context_token) + span.end() return invocation def fail_embedding( @@ -265,13 +291,17 @@ def fail_embedding( return invocation span = invocation.span - _apply_embedding_finish_attributes(invocation.span, invocation) - _apply_error_attributes(invocation.span, error) - error_type = getattr(error.type, "__qualname__", None) - self._record_embedding_metrics(invocation, span, error_type=error_type) - # Detach context and end span - otel_context.detach(invocation.context_token) - span.end() + try: + _apply_embedding_finish_attributes(invocation.span, invocation) + _apply_error_attributes(invocation.span, error) + error_type = getattr(error.type, "__qualname__", None) + self._record_embedding_metrics( + invocation, span, error_type=error_type + ) + finally: + # Detach context and end span even if finishing fails + otel_context.detach(invocation.context_token) + span.end() return invocation diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index d9da889ade..047a14f971 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -264,32 +264,18 @@ class EmbeddingInvocation(GenAIInvocation): and context_token attributes are set by the TelemetryHandler. """ - operation_name: str = field( - default=GenAI.GenAiOperationNameValues.EMBEDDINGS.value, - metadata={"semconv": GenAI.GEN_AI_OPERATION_NAME}, - ) - provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock + operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value - request_model: str | None = field( - default=None, - metadata={"semconv": GenAI.GEN_AI_REQUEST_MODEL}, - ) + provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock + request_model: str | None = None server_address: str | None = None server_port: int | None = None - error_type: str | None = None # encoding_formats can be multi-value -> combinational cardinality risk. # Keep on spans/events only. - encoding_formats: list[str] = field( - default_factory=list, - metadata={"semconv": GenAI.GEN_AI_REQUEST_ENCODING_FORMATS}, - ) - - input_tokens: int | None = field( - default=None, - metadata={"semconv": GenAI.GEN_AI_USAGE_INPUT_TOKENS}, - ) + encoding_formats: list[str] | None = None + input_tokens: int | None = None dimension_count: int | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) @@ -305,6 +291,7 @@ class EmbeddingInvocation(GenAIInvocation): Additional attributes to set on metrics. Must be of a low cardinality. These attributes will not be set on spans or events. """ + monotonic_start_s: float | None = None @dataclass From e16f49f505552420cae8841e85f79679f2f4c256 Mon Sep 17 00:00:00 2001 From: shuningc Date: Wed, 25 Feb 2026 09:07:38 -0800 Subject: [PATCH 05/41] Refactoring to remove duplicate codes --- .../src/opentelemetry/util/genai/span_utils.py | 11 +++++++++-- .../src/opentelemetry/util/genai/types.py | 10 ++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 70fb3243b7..ef54a43b29 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -88,14 +88,21 @@ def _get_embedding_common_attributes( } +def _get_span_name( + invocation: LLMInvocation | EmbeddingInvocation, +) -> str: + """Get the span name for a GenAI invocation.""" + return f"{invocation.operation_name} {invocation.request_model}".strip() + + def _get_llm_span_name(invocation: LLMInvocation) -> str: """Get the span name for an LLM invocation.""" - return f"{invocation.operation_name} {invocation.request_model}".strip() + return _get_span_name(invocation) def _get_embedding_span_name(invocation: EmbeddingInvocation) -> str: """Get the span name for an Embedding invocation.""" - return f"{invocation.operation_name} {invocation.request_model}".strip() + return _get_span_name(invocation) def _get_llm_messages_attributes_for_span( diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 047a14f971..34c74abf2c 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -201,6 +201,10 @@ class GenAIInvocation: context_token: ContextToken | None = None span: Span | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) + # Monotonic start time in seconds (from timeit.default_timer) used + # for duration calculations to avoid mixing clock sources. This is + # populated by the TelemetryHandler when starting an invocation. + monotonic_start_s: float | None = None @dataclass @@ -250,10 +254,6 @@ class LLMInvocation(GenAIInvocation): seed: int | None = None server_address: str | None = None server_port: int | None = None - # Monotonic start time in seconds (from timeit.default_timer) used - # for duration calculations to avoid mixing clock sources. This is - # populated by the TelemetryHandler when starting an invocation. - monotonic_start_s: float | None = None @dataclass @@ -267,7 +267,6 @@ class EmbeddingInvocation(GenAIInvocation): operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock - request_model: str | None = None server_address: str | None = None server_port: int | None = None @@ -291,7 +290,6 @@ class EmbeddingInvocation(GenAIInvocation): Additional attributes to set on metrics. Must be of a low cardinality. These attributes will not be set on spans or events. """ - monotonic_start_s: float | None = None @dataclass From 3a4577ccff8432e97c03eaadbe4d36106111573b Mon Sep 17 00:00:00 2001 From: shuningc Date: Wed, 25 Feb 2026 10:31:00 -0800 Subject: [PATCH 06/41] Moving common attributes to GenAIInvocation --- .../src/opentelemetry/util/genai/span_utils.py | 3 ++- .../src/opentelemetry/util/genai/types.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index ef54a43b29..90310643e7 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -34,6 +34,7 @@ from opentelemetry.util.genai.types import ( EmbeddingInvocation, Error, + GenAIInvocation, InputMessage, LLMInvocation, MessagePart, @@ -89,7 +90,7 @@ def _get_embedding_common_attributes( def _get_span_name( - invocation: LLMInvocation | EmbeddingInvocation, + invocation: GenAIInvocation, ) -> str: """Get the span name for a GenAI invocation.""" return f"{invocation.operation_name} {invocation.request_model}".strip() diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 34c74abf2c..06467389f3 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -201,6 +201,8 @@ class GenAIInvocation: context_token: ContextToken | None = None span: Span | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) + request_model: str | None = None + operation_name: str | None = None # Monotonic start time in seconds (from timeit.default_timer) used # for duration calculations to avoid mixing clock sources. This is # populated by the TelemetryHandler when starting an invocation. @@ -215,9 +217,10 @@ class LLMInvocation(GenAIInvocation): set by the TelemetryHandler. """ - request_model: str | None = None - # Chat by default - operation_name: str = GenAI.GenAiOperationNameValues.CHAT.value + def __post_init__(self) -> None: + if self.operation_name is None: + self.operation_name = GenAI.GenAiOperationNameValues.CHAT.value + input_messages: list[InputMessage] = field( default_factory=_new_input_messages ) @@ -264,10 +267,13 @@ class EmbeddingInvocation(GenAIInvocation): and context_token attributes are set by the TelemetryHandler. """ - operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value + def __post_init__(self) -> None: + if self.operation_name is None: + self.operation_name = ( + GenAI.GenAiOperationNameValues.EMBEDDINGS.value + ) provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock - request_model: str | None = None server_address: str | None = None server_port: int | None = None From 8e388c0a45604c166ec4ba461a5ae2fc34b0cf70 Mon Sep 17 00:00:00 2001 From: shuningc Date: Mon, 2 Mar 2026 12:43:48 -0800 Subject: [PATCH 07/41] Apply suggestion from @xrmx Co-authored-by: Riccardo Magliocchetti --- util/opentelemetry-util-genai/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index 2b5851af2f..3e9cfdf664 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -53,7 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3752](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3752)) ([#3759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3759)) ([#3763](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3763)) - - Add a utility to parse the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. Add `gen_ai_latest_experimental` as a new value to the Sem Conv stability flag ([#3716](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3716)). From cafa8e32a744d771a28927d1a38f54a4698cd54e Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 26 Feb 2026 14:45:44 +0100 Subject: [PATCH 08/41] Require virtualenv<21 for tox -e generate (#4265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gen-requirements: stay with virtualenv < 21 No idea what's going on but looks like hatch / hatchling has issues with latest virtualenv 21: Environment `hatch-build` is incompatible: module 'virtualenv.discovery.builtin' has no attribute 'propose_interpreters' * Update gen-requirements.txt Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> --------- Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> --- gen-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gen-requirements.txt b/gen-requirements.txt index 2512362a93..203cd7b7b3 100644 --- a/gen-requirements.txt +++ b/gen-requirements.txt @@ -7,3 +7,5 @@ requests tomli tomli_w hatch +# TODO: stick with virtualenv < 21 until a new hatch release +virtualenv<21 From c969470e8a82069bc346d158f1a21bd572cfdab5 Mon Sep 17 00:00:00 2001 From: Dinmukhamed Mailibay <47117969+dinmukhamedm@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:33:15 +0000 Subject: [PATCH 09/41] fix(threading): attribute error when run is called w/o start (#4246) * fix(threading): attribute error when run is called w/o start * update changelog * don't initialize context on run, fix tests * address styling comments * remove no-op context attachment --- CHANGELOG.md | 2 + .../instrumentation/threading/__init__.py | 3 +- .../tests/test_threading.py | 58 +++++++++++++++++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbaae300f..3ede5cbf0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4175](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4175)) - `opentelemetry-docker-tests` Fix docker-tests assumption by Postgres-Sqlalchemy case about scope of metrics ([#4258](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4258)) +- `opentelemetry-instrumentation-threading`: fix AttributeError when Thread is run without starting + ([#4246](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4246)) ### Breaking changes diff --git a/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/__init__.py b/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/__init__.py index 6352197465..baf50ce970 100644 --- a/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/__init__.py @@ -147,7 +147,8 @@ def __wrap_threading_run( ) -> R: token = None try: - token = context.attach(instance._otel_context) + if hasattr(instance, "_otel_context"): + token = context.attach(instance._otel_context) return call_wrapped(*args, **kwargs) finally: if token is not None: diff --git a/instrumentation/opentelemetry-instrumentation-threading/tests/test_threading.py b/instrumentation/opentelemetry-instrumentation-threading/tests/test_threading.py index ad4bcaf019..3a06969b26 100644 --- a/instrumentation/opentelemetry-instrumentation-threading/tests/test_threading.py +++ b/instrumentation/opentelemetry-instrumentation-threading/tests/test_threading.py @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import threading from concurrent.futures import ( # pylint: disable=no-name-in-module; TODO #4199 + Future, ThreadPoolExecutor, ) from typing import List @@ -66,7 +69,7 @@ def test_trace_context_propagation_in_thread_pool_with_multiple_workers( executor = ThreadPoolExecutor(max_workers=max_workers) expected_span_contexts: List[trace.SpanContext] = [] - futures_list = [] + futures_list: List[Future[trace.SpanContext]] = [] for num in range(max_workers): with self._tracer.start_as_current_span(f"trace_{num}") as span: expected_span_context = span.get_span_context() @@ -125,15 +128,15 @@ def fake_func(self): def get_current_span_context_for_test() -> trace.SpanContext: return trace.get_current_span().get_span_context() - def print_square(self, num): + def print_square(self, num: int | float) -> int | float: with self._tracer.start_as_current_span("square"): return num * num - def print_cube(self, num): + def print_cube(self, num: int | float) -> int | float: with self._tracer.start_as_current_span("cube"): return num * num * num - def print_square_with_thread(self, num): + def print_square_with_thread(self, num: int | float) -> int | float: with self._tracer.start_as_current_span("square"): cube_thread = threading.Thread(target=self.print_cube, args=(10,)) @@ -141,7 +144,7 @@ def print_square_with_thread(self, num): cube_thread.join() return num * num - def calculate(self, num): + def calculate(self, num: int | float) -> None: with self._tracer.start_as_current_span("calculate"): square_thread = threading.Thread( target=self.print_square, args=(num,) @@ -294,3 +297,48 @@ def test_threadpool_with_valid_context_token(self, mock_detach: MagicMock): future = executor.submit(self.get_current_span_context_for_test) future.result() mock_detach.assert_called_once() + + def test_threading_run_without_start(self): + square_thread = threading.Thread(target=self.print_square, args=(10,)) + with self._tracer.start_as_current_span("root"): + square_thread.run() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + root_span = next(span for span in spans if span.name == "root") + self.assertIsNotNone(root_span) + self.assertIsNone(root_span.parent) + square_span = next(span for span in spans if span.name == "square") + self.assertIsNotNone(square_span) + self.assertIs(square_span.parent, root_span.get_span_context()) + + def test_threading_run_with_custom_run(self): + _tracer = self._tracer + + class ThreadWithCustomRun(threading.Thread): + def run(self): + # don't call super().run() on purpose + # Thread.run() cannot be called twice + with _tracer.start_as_current_span("square"): + pass + + square_thread = ThreadWithCustomRun( + target=self.print_square, args=(10,) + ) + with self._tracer.start_as_current_span("run_1"): + square_thread.run() + with self._tracer.start_as_current_span("run_2"): + square_thread.run() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 4) + run_1_span = next(span for span in spans if span.name == "run_1") + run_2_span = next(span for span in spans if span.name == "run_2") + square_spans = [span for span in spans if span.name == "square"] + square_spans.sort(key=lambda x: x.start_time or 0) + run_1_child_span = square_spans[0] + run_2_child_span = square_spans[1] + self.assertIs(run_1_child_span.parent, run_1_span.get_span_context()) + self.assertIs(run_2_child_span.parent, run_2_span.get_span_context()) + self.assertIsNone(run_1_span.parent) + self.assertIsNone(run_2_span.parent) From 41166d82cfa709de25d98a053a44300129111026 Mon Sep 17 00:00:00 2001 From: Rima-ag <55252887+Rima-ag@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:29:04 +0100 Subject: [PATCH 10/41] [google-genai] Test instrumentation on google-genai v1.64.0 (#4253) * [google-genai] Test instrumentation on google-genai v1.63.0 * Add package version upper bound * bump latest version for python 3.9 * fix README * bump google-auth version for python 3.9 * add package upper limit to pyproject.toml * [google-genai] Test instrumentation on google-genai v1.64.0 * fix upper case method in cassette not matching lower case one generated by aiohttp * fix async calls hanging in vcrpy * Remove package upper limit * fix import error in python 3.9 * fix google-genai module lower bound * Add link to vcr issue * Add comment about request method * Fix lint errors * Fix spellcheck --------- Co-authored-by: Aaron Abbott --- instrumentation-genai/README.md | 2 +- .../instrumentation/google_genai/package.py | 2 +- .../tests/generate_content/test_e2e.py | 53 +++++++++++++++++++ .../tests/requirements.latest.txt | 6 ++- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/instrumentation-genai/README.md b/instrumentation-genai/README.md index a6498da6a5..dbe51b523e 100644 --- a/instrumentation-genai/README.md +++ b/instrumentation-genai/README.md @@ -3,7 +3,7 @@ | --------------- | ------------------ | --------------- | -------------- | | [opentelemetry-instrumentation-anthropic](./opentelemetry-instrumentation-anthropic) | anthropic >= 0.16.0 | No | development | [opentelemetry-instrumentation-claude-agent-sdk](./opentelemetry-instrumentation-claude-agent-sdk) | claude-agent-sdk >= 0.1.14 | No | development -| [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.0.0 | No | development +| [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.32.0 | No | development | [opentelemetry-instrumentation-langchain](./opentelemetry-instrumentation-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-openai-agents-v2](./opentelemetry-instrumentation-openai-agents-v2) | openai-agents >= 0.3.3 | No | development | [opentelemetry-instrumentation-openai-v2](./opentelemetry-instrumentation-openai-v2) | openai >= 1.26.0 | Yes | development diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/package.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/package.py index 46a0504cce..184b8cd8cc 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/package.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/package.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -_instruments = ("google-genai >= 1.0.0",) +_instruments = ("google-genai >= 1.32.0",) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_e2e.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_e2e.py index 36802b65dc..47abf4b877 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_e2e.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/test_e2e.py @@ -40,6 +40,14 @@ from google.genai import types from vcr.record_mode import RecordMode +try: + # These modules are only supported in python >= 3.10 + from aiohttp.client_exceptions import ClientConnectionError + from vcr.stubs import aiohttp_stubs +except ImportError: + ClientConnectionError = None + aiohttp_stubs = None + from opentelemetry.instrumentation._semconv import ( OTEL_SEMCONV_STABILITY_OPT_IN, _OpenTelemetrySemanticConventionStability, @@ -135,6 +143,9 @@ def _redact_headers(headers): def _before_record_request(request): + # aiohttp reports the request method in lower case while it is recorded in the cassette in upper case. + if request.method: + request.method = request.method.upper() if request.headers: _redact_headers(request.headers) uri = request.uri @@ -316,6 +327,48 @@ def setup_vcr(vcr): return vcr +@pytest.fixture(name="patch_vcr_aiohttp_stream", scope="module", autouse=True) +def fixture_patch_vcr_aiohttp_stream(): + # Allows the async tests to not be stuck in infinite loop when streaming + # a VCR cassette with aiohttp stubs. + # https://github.com/kevin1024/vcrpy/issues/927 + if ClientConnectionError is None or aiohttp_stubs is None: + return + + class _ReplayMockStream(aiohttp_stubs.MockStream): + # Keep vcrpy's stream behavior, but ignore aiohttp's + # close-time ClientConnectionError("Connection closed") during + # cassette replay, where the full response is already buffered + # and this condition should be treated as normal EOF. + def set_exception(self, exc): + if isinstance(exc, ClientConnectionError) and exc.args == ( + "Connection closed", + ): + return + super().set_exception(exc) + + class _ReplayMockClientResponse(aiohttp_stubs.MockClientResponse): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._mock_content_stream = None + + @property + def content(self): + # vcrpy's aiohttp MockClientResponse.content creates a fresh stream object + # on every property access. google-genai async streaming repeatedly reads + # response.content.readline() and expects the same stream instance until EOF is + # reached. + if self._mock_content_stream is None: + body = self._body or b"" + stream = _ReplayMockStream() + stream.feed_data(body) + stream.feed_eof() + self._mock_content_stream = stream + return self._mock_content_stream + + aiohttp_stubs.MockClientResponse = _ReplayMockClientResponse + + @pytest.fixture(name="instrumentor") def fixture_instrumentor(): return GoogleGenAiSdkInstrumentor() diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt index 7c7d511649..32e582c830 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt @@ -40,8 +40,10 @@ pytest==7.4.4 pytest-asyncio==0.21.0 pytest-vcr==1.0.2 -google-auth==2.38.0 -google-genai==1.32.0 +google-auth==2.47.0 + +google-genai==1.47.0; python_version < "3.10" +google-genai==1.64.0; python_version >= "3.10" # Install locally from the folder. This path is relative to the # root directory, given invocation from "tox" at root level. From a026ef6fa583ccfb6babdb5f0b472d672de293ff Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 27 Feb 2026 09:54:02 +0100 Subject: [PATCH 11/41] confluent-kafka: add basic autoinstrumentation tests (#4266) * confluent-kafka: test auto-instrumentation code path * Add TODO to simpligy the instrumentation * silence pylint * Update __init__.py --- .../confluent_kafka/__init__.py | 3 + .../tests/test_instrumentation.py | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/__init__.py b/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/__init__.py index 6f080cc635..f4606bc4d9 100644 --- a/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/__init__.py @@ -277,6 +277,9 @@ def instrumentation_dependencies(self) -> Collection[str]: return _instruments def _instrument(self, **kwargs): + # TODO: should probably wrap methods directly instead of going through + # these classes. Hopefully it'll make the patching work if called after + # the original classes have already been imported, #4270 self._original_kafka_producer = confluent_kafka.Producer self._original_kafka_consumer = confluent_kafka.Consumer diff --git a/instrumentation/opentelemetry-instrumentation-confluent-kafka/tests/test_instrumentation.py b/instrumentation/opentelemetry-instrumentation-confluent-kafka/tests/test_instrumentation.py index 725f73cc2c..365ac333d9 100644 --- a/instrumentation/opentelemetry-instrumentation-confluent-kafka/tests/test_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-confluent-kafka/tests/test_instrumentation.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -# pylint: disable=no-name-in-module - -from confluent_kafka import Consumer, Producer +# pylint: disable=no-name-in-module,import-outside-toplevel from opentelemetry.instrumentation.confluent_kafka import ( + AutoInstrumentedConsumer, + AutoInstrumentedProducer, ConfluentKafkaInstrumentor, ProxiedConsumer, ProxiedProducer, @@ -41,6 +41,8 @@ class TestConfluentKafka(TestBase): def test_instrument_api(self) -> None: + from confluent_kafka import Consumer, Producer # noqa: PLC0415 + instrumentation = ConfluentKafkaInstrumentor() producer = Producer({"bootstrap.servers": "localhost:29092"}) @@ -51,16 +53,22 @@ def test_instrument_api(self) -> None: producer = instrumentation.uninstrument_producer(producer) self.assertEqual(producer.__class__, Producer) - producer = Producer({"bootstrap.servers": "localhost:29092"}) - producer = instrumentation.instrument_producer(producer) + consumer = Consumer( + { + "bootstrap.servers": "localhost:29092", + "group.id": "mygroup", + "auto.offset.reset": "earliest", + } + ) - self.assertEqual(producer.__class__, ProxiedProducer) + consumer = instrumentation.instrument_consumer(consumer) + self.assertEqual(consumer.__class__, ProxiedConsumer) - producer = instrumentation.uninstrument_producer(producer) - self.assertEqual(producer.__class__, Producer) + consumer = instrumentation.uninstrument_consumer(consumer) + self.assertEqual(consumer.__class__, Consumer) consumer = Consumer( - { + **{ "bootstrap.servers": "localhost:29092", "group.id": "mygroup", "auto.offset.reset": "earliest", @@ -73,7 +81,37 @@ def test_instrument_api(self) -> None: consumer = instrumentation.uninstrument_consumer(consumer) self.assertEqual(consumer.__class__, Consumer) + def test_instrument_api_with_instrument(self) -> None: + ConfluentKafkaInstrumentor().instrument() + + from confluent_kafka import Consumer, Producer # noqa: PLC0415 + + producer = Producer({"bootstrap.servers": "localhost:29092"}) + self.assertEqual(producer.__class__, AutoInstrumentedProducer) + + consumer = Consumer( + { + "bootstrap.servers": "localhost:29092", + "group.id": "mygroup", + "auto.offset.reset": "earliest", + } + ) + self.assertEqual(consumer.__class__, AutoInstrumentedConsumer) + + consumer = Consumer( + **{ + "bootstrap.servers": "localhost:29092", + "group.id": "mygroup", + "auto.offset.reset": "earliest", + } + ) + self.assertEqual(consumer.__class__, AutoInstrumentedConsumer) + + ConfluentKafkaInstrumentor().uninstrument() + def test_consumer_commit_method_exists(self) -> None: + from confluent_kafka import Consumer # noqa: PLC0415 + instrumentation = ConfluentKafkaInstrumentor() consumer = Consumer( From 1855b3a9ef9b881eb3ad094cf52606f69f8645f6 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 27 Feb 2026 10:01:33 +0100 Subject: [PATCH 12/41] instrumentation-genai: stop setting OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true (#4263) The openai-v2 examples predate (2024) the Jan 2025 decouple (f3b9e6ec96b7cc3717d5c8b529da7a8923810973 in core) of the log machinery setup from the LoggingHandler setup. So unless I'm missing something and you really want to see OpenTelemetry logs shipped from Python logging module usage in these genai examples I think we can drop these. We're moving the logging handler from the sdk to the opentelemetry-instrumentation-logging and deprecating that environment variable. --- .../examples/zero-code/.env | 3 --- .../examples/zero-code/README.rst | 2 -- .../examples/zero-code/.env.example | 3 --- .../examples/embeddings/.env | 3 --- .../examples/embeddings/README.rst | 3 +-- .../examples/zero-code/.env | 3 --- .../examples/zero-code/README.rst | 1 - .../examples/zero-code/.env | 5 +---- 8 files changed, 2 insertions(+), 21 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/.env index 1d5ae9c66e..20dbd56a31 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/.env @@ -7,9 +7,6 @@ ANTHROPIC_API_KEY=sk-ant-YOUR_API_KEY OTEL_SERVICE_NAME=opentelemetry-python-claude-agent-sdk -# Change to 'false' to disable collection of python logging logs -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true - # Uncomment if your OTLP endpoint doesn't support logs # OTEL_LOGS_EXPORTER=console diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/README.rst index ee2f0056cd..4ac2fb38f1 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/zero-code/README.rst @@ -15,8 +15,6 @@ interaction. Note: `.env <.env>`_ file configures additional environment variables: -- ``OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true`` configures - OpenTelemetry SDK to export logs and events. - ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` configures Claude Agent SDK instrumentation to capture prompt and completion contents on events. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example index 8f39668502..9e2aeb3023 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example @@ -7,8 +7,5 @@ OPENAI_API_KEY=sk-YOUR_API_KEY OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-zero-code -# Enable auto-instrumentation for logs if desired -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true - # Optionally override the agent name reported on spans # OTEL_GENAI_AGENT_NAME=Travel Concierge diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/.env index 8f2dd62b91..ab64ccd5f3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/.env @@ -12,9 +12,6 @@ OPENAI_API_KEY=sk-YOUR_API_KEY OTEL_SERVICE_NAME=opentelemetry-python-openai -# Change to 'false' to disable collection of python logging logs -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true - # Uncomment if your OTLP endpoint doesn't support logs # OTEL_LOGS_EXPORTER=console diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/README.rst index 66a7c58a79..d31a5c5675 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/embeddings/README.rst @@ -11,7 +11,6 @@ Metrics capture token usage and performance data. Note: ``.env`` file configures additional environment variables: -- ``OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true`` configures OpenTelemetry SDK to export logs and events. - ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` configures OpenAI instrumentation to capture content on events. - ``OTEL_LOGS_EXPORTER=otlp`` to specify exporter type. @@ -41,4 +40,4 @@ Run the example like this: dotenv run -- opentelemetry-instrument python main.py You should see embedding information printed while traces and metrics export to your -configured observability tool. \ No newline at end of file +configured observability tool. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env index 8f2dd62b91..ab64ccd5f3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env @@ -12,9 +12,6 @@ OPENAI_API_KEY=sk-YOUR_API_KEY OTEL_SERVICE_NAME=opentelemetry-python-openai -# Change to 'false' to disable collection of python logging logs -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true - # Uncomment if your OTLP endpoint doesn't support logs # OTEL_LOGS_EXPORTER=console diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst index 4332c0b7c0..1498fc75de 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst @@ -12,7 +12,6 @@ your OpenAI requests. Note: `.env <.env>`_ file configures additional environment variables: -- ``OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true`` configures OpenTelemetry SDK to export logs and events. - ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` configures OpenAI instrumentation to capture prompt and completion contents on events. - ``OTEL_LOGS_EXPORTER=otlp`` to specify exporter type. diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/zero-code/.env index f224ac248a..7ced5640c4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/zero-code/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/zero-code/.env @@ -4,9 +4,6 @@ OTEL_SERVICE_NAME=opentelemetry-python-vertexai -# Change to 'false' to disable collection of python logging logs -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true - # Uncomment if your OTLP endpoint doesn't support logs # OTEL_LOGS_EXPORTER=console @@ -25,4 +22,4 @@ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK = "upload" # Required if using a completion hook. The path to upload content to for example gs://my_bucket. -OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH = "gs://my_bucket" \ No newline at end of file +OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH = "gs://my_bucket" From 9989958c50d78900a8a6956839366d1c6c01dd5b Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 2 Mar 2026 09:31:27 +0100 Subject: [PATCH 13/41] Add Keith Decker to approvers (#4273) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2e6c540e88..8de305d091 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ For more information about the maintainer role, see the [community repository](h - [Emídio Neto](https://github.com/emdneto), Independent - [Héctor Hernández](https://github.com/hectorhdzg), Microsoft - [Jeremy Voss](https://github.com/jeremydvoss), Microsoft +- [Keith Decker](https://github.com/keith-decker), Cisco/Splunk - [Liudmila Molkova](https://github.com/lmolkova), Grafana Labs - [Lukas Hering](https://github.com/herin049), Capital One - [Owais Lone](https://github.com/owais), Splunk From 6ec13368ee5f9a378f03d9ec9fc551c87de255d0 Mon Sep 17 00:00:00 2001 From: Sri Kaaviya <107148069+srikaaviya@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:47:10 -0800 Subject: [PATCH 14/41] asyncio: fix environment variables not appearing in docs (#4261) * asyncio: fix environment variables not in docs Fix the docstrings in environment_variables.py so they are correctly picked up by Sphinx autodoc for Read the Docs. Previously the docstrings were placed above the variable assignments, which Sphinx cannot parse. Moved them below and added the required '.. envvar::' directives, consistent with how other packages (e.g. opentelemetry-instrumentation) document their environment variables. Fixes #4256 * Fix typo from 'determines' to 'determine' * Apply suggestion from @xrmx * asyncio: move environment_variables docs to __init__ module docstring for sphinx * asyncio: keep envvar docs in environment_variables.py with module-level docstring --------- Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 3 +++ docs/instrumentation/asyncio/asyncio.rst | 5 +++++ .../instrumentation/asyncio/__init__.py | 1 - .../asyncio/environment_variables.py | 18 +++++++++++------- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ede5cbf0a..3a1f8cd10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4139](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4139)) ### Fixed + +- `opentelemetry-instrumentation-asyncio`: Fix environment variables not appearing in Read the Docs documentation + ([#4256](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4256)) - `opentelemetry-instrumentation-mysql`: Refactor MySQL integration test mocks to use concrete DBAPI connection attributes, reducing noisy attribute type warnings. ([#4116](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4116)) - `opentelemetry-instrumentation-cassandra`: Use `_instruments_any` instead of `_instruments` for driver dependencies so that having either `cassandra-driver` or `scylla-driver` installed is sufficient diff --git a/docs/instrumentation/asyncio/asyncio.rst b/docs/instrumentation/asyncio/asyncio.rst index 4cbcc70f09..3aabdd8ec1 100644 --- a/docs/instrumentation/asyncio/asyncio.rst +++ b/docs/instrumentation/asyncio/asyncio.rst @@ -5,3 +5,8 @@ OpenTelemetry asyncio Instrumentation :members: :undoc-members: :show-inheritance: + +.. automodule:: opentelemetry.instrumentation.asyncio.environment_variables + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/__init__.py b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/__init__.py index c3624d438e..0ed4acbeac 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/__init__.py @@ -79,7 +79,6 @@ def func(): * asyncio.process.duration (seconds) - Duration of asyncio process * asyncio.process.count (count) - Number of asyncio process - API --- """ diff --git a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/environment_variables.py b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/environment_variables.py index 9f324d60f4..3217ed2e0a 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/environment_variables.py +++ b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/environment_variables.py @@ -13,23 +13,27 @@ # limitations under the License. """ -Enter the names of the coroutines to be traced through the environment variable below, separated by commas. +.. envvar:: OTEL_PYTHON_ASYNCIO_COROUTINE_NAMES_TO_TRACE + + Enter the names of the coroutines to be traced through the environment variable below, separated by commas. + +.. envvar:: OTEL_PYTHON_ASYNCIO_FUTURE_TRACE_ENABLED + + To determine whether the tracing feature for Future of Asyncio in Python is enabled or not. + +.. envvar:: OTEL_PYTHON_ASYNCIO_TO_THREAD_FUNCTION_NAMES_TO_TRACE + + Enter the names of the functions to be traced through the environment variable below, separated by commas. """ OTEL_PYTHON_ASYNCIO_COROUTINE_NAMES_TO_TRACE = ( "OTEL_PYTHON_ASYNCIO_COROUTINE_NAMES_TO_TRACE" ) -""" -To determines whether the tracing feature for Future of Asyncio in Python is enabled or not. -""" OTEL_PYTHON_ASYNCIO_FUTURE_TRACE_ENABLED = ( "OTEL_PYTHON_ASYNCIO_FUTURE_TRACE_ENABLED" ) -""" -Enter the names of the functions to be traced through the environment variable below, separated by commas. -""" OTEL_PYTHON_ASYNCIO_TO_THREAD_FUNCTION_NAMES_TO_TRACE = ( "OTEL_PYTHON_ASYNCIO_TO_THREAD_FUNCTION_NAMES_TO_TRACE" ) From b71eef0d2190fbd85512bca3ea4922028f27df97 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 2 Mar 2026 10:31:59 +0000 Subject: [PATCH 15/41] maint: Add stale github action (#4220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * maint: Add stale github action * add changelog entry * add exempt PR labels * increase close days to 14 * update contributing.md for stale PRs * Update cron schedule Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> * update stale message * clean-up --------- Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> --- .github/workflows/stale.yml | 35 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 +++ CONTRIBUTING.md | 7 +++++++ 3 files changed, 45 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..f212b5e035 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +name: Mark stale PRs + +on: + schedule: + - cron: "12 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + stale: + permissions: + contents: read + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-pr-stale: 14 + days-before-pr-close: 14 + days-before-issue-stale: -1 + days-before-issue-close: -1 + stale-pr-message: > + This PR has been automatically marked as stale because it has not had + any activity for 14 days. It will be closed if no further activity + occurs within 14 days of this comment. + + If you're still working on this, please add a comment or push new commits. + close-pr-message: > + This PR has been closed due to inactivity. Please reopen if you would + like to continue working on it. + exempt-pr-labels: "hold,WIP,blocked-by-spec,do not merge" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f8cd10d..0092fa9747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add stale PR GitHub Action + ([#4220](https://github.com/open-telemetry/opentelemetry-python/pull/4220)) + ### Added - Add Python 3.14 support diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 025fdfd894..29d600baca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ Please also read the [OpenTelemetry Contributor Guide](https://github.com/open-t - [How to Receive Comments](#how-to-receive-comments) - [How to Get PRs Reviewed](#how-to-get-prs-reviewed) - [How to Get PRs Merged](#how-to-get-prs-merged) + - [Stale PRs](#stale-prs) - [Design Choices](#design-choices) - [Focus on Capabilities, Not Structure Compliance](#focus-on-capabilities-not-structure-compliance) - [Running Tests Locally](#running-tests-locally) @@ -241,6 +242,12 @@ A PR is considered to be **ready to merge** when: Any Approver / Maintainer can merge the PR once it is **ready to merge**. +### Stale PRs + +PRs with no activity for 14 days will be automatically marked as stale and closed after a further 14 days of inactivity. To prevent a PR from being marked stale, ensure there is regular activity (commits, comments, reviews, etc). + +Project managers can also exempt a PR from this by applying one of the following labels: `hold`, `WIP`, `blocked-by-spec`, `do not merge`. + ## Design Choices As with other OpenTelemetry clients, opentelemetry-python follows the From 1bc4001a95653ef3a8b9bed819b6183046af4c39 Mon Sep 17 00:00:00 2001 From: Ritesh Tripathi Date: Mon, 2 Mar 2026 16:54:07 +0530 Subject: [PATCH 16/41] fix(flask): align http.server.active_requests metric with semconv helper (#4094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(flask): correct HTTP metrics handling with semconv opt-in * fix(flask): avoid hardcoded legacy http.server.duration metric name * changelog: note flask http server metrics consistency fix * fix(flask): use semconv constant for legacy http.server.duration metric * Update instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py * Update CHANGELOG.md * fix(flask): explicitly split active requests metric by semconv mode * fix(flask): align http.server.active_requests metric with semconv helper --------- Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 2 ++ .../instrumentation/flask/__init__.py | 33 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0092fa9747..96c77c1a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `opentelemetry-instrumentation-flask`: Align `http.server.active_requests` initialization with semantic convention helpers to ensure consistent names, units, and descriptions. + ([#4094](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4094)) - `opentelemetry-instrumentation-asyncio`: Fix environment variables not appearing in Read the Docs documentation ([#4256](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4256)) - `opentelemetry-instrumentation-mysql`: Refactor MySQL integration test mocks to use concrete DBAPI connection attributes, reducing noisy attribute type warnings. diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 536cd23bb1..c66e8fa10d 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -287,6 +287,9 @@ def response_hook(span: Span, status: str, response_headers: List): HTTP_ROUTE, HTTP_TARGET, ) +from opentelemetry.semconv._incubating.metrics.http_metrics import ( + create_http_server_active_requests, +) from opentelemetry.semconv.metrics import MetricInstruments from opentelemetry.semconv.metrics.http_metrics import ( HTTP_SERVER_REQUEST_DURATION, @@ -299,7 +302,6 @@ def response_hook(span: Span, status: str, response_headers: List): ) _logger = getLogger(__name__) - # Global constants for Flask 3.1+ streaming context cleanup _IS_FLASK_31_PLUS = hasattr(flask, "__version__") and package_version.parse( flask.__version__ @@ -692,11 +694,15 @@ def __init__(self, *args, **kwargs): description="Duration of HTTP server requests.", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, ) - active_requests_counter = meter.create_up_down_counter( - name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, - unit="requests", - description="measures the number of concurrent HTTP requests that are currently in-flight", - ) + + if _report_new(_InstrumentedFlask._sem_conv_opt_in_mode): + active_requests_counter = create_http_server_active_requests(meter) + else: + active_requests_counter = meter.create_up_down_counter( + name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, + unit="requests", + description="Measures the number of concurrent HTTP requests that are currently in-flight.", + ) self.wsgi_app = _rewrapped_app( self.wsgi_app, @@ -826,11 +832,16 @@ def instrument_app( description="Duration of HTTP server requests.", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, ) - active_requests_counter = meter.create_up_down_counter( - name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, - unit="{request}", - description="Number of active HTTP server requests.", - ) + if _report_new(sem_conv_opt_in_mode): + active_requests_counter = create_http_server_active_requests( + meter + ) + else: + active_requests_counter = meter.create_up_down_counter( + name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, + unit="requests", + description="Measures the number of concurrent HTTP requests that are currently in-flight.", + ) app._original_wsgi_app = app.wsgi_app app.wsgi_app = _rewrapped_app( From 5d5b7dd2d10b63b1e8923d0e2e39ff18aa6a8285 Mon Sep 17 00:00:00 2001 From: Sri Kaaviya <107148069+srikaaviya@users.noreply.github.com> Date: Mon, 2 Mar 2026 03:27:24 -0800 Subject: [PATCH 17/41] Fix falcon-instrumentation _handle_exception method to remove pylint disables (#4207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix falcon-instrumentation _handle_exception method to remove pylint disables * Refactor _handle_exception method for Falcon 3 * try fix Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com> * Add CHANGELOG entry for falcon _handle_exception refactor (#4207) --------- Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com> Co-authored-by: Emídio Neto <9735060+emdneto@users.noreply.github.com> Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 2 ++ .../instrumentation/falcon/__init__.py | 27 ++++++------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c77c1a8b..4481a1128c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4078](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4171)) - `opentelemetry-instrumentation-aiohttp-server`: fix HTTP error inconsistencies ([#4175](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4175)) +- `opentelemetry-instrumentation-falcon`: Refactor `_handle_exception` to remove pylint disables + ([#4207](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4207)) - `opentelemetry-docker-tests` Fix docker-tests assumption by Postgres-Sqlalchemy case about scope of metrics ([#4258](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4258)) - `opentelemetry-instrumentation-threading`: fix AttributeError when Thread is run without starting diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py index bfc82b7a17..09429f8bb5 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py @@ -328,33 +328,22 @@ def __del__(self): if self in _InstrumentedFalconAPI._instrumented_falcon_apps: _InstrumentedFalconAPI._instrumented_falcon_apps.remove(self) - def _handle_exception(self, arg1, arg2, arg3, arg4): # pylint: disable=C0103,W0237 - # Falcon 3 does not execute middleware within the context of the exception - # so we capture the exception here and save it into the env dict - - # Translation layer for handling the changed arg position of "ex" in Falcon > 2 vs - # Falcon < 2 + def _handle_exception(self, *args): + # Falcon 3 does not execute middleware within the context + # of the exception so we capture the exception here and + # save it into the env dict if not self._is_instrumented_by_opentelemetry: - return super()._handle_exception(arg1, arg2, arg3, arg4) + return super()._handle_exception(*args) if _falcon_version == 1: - ex = arg1 - req = arg2 - resp = arg3 - params = arg4 + _, req, _, _ = args # ex, req, resp, params else: - req = arg1 - resp = arg2 - ex = arg3 - params = arg4 + req, _, _, _ = args # req, resp, ex, params _, exc, _ = exc_info() req.env[_ENVIRON_EXC] = exc - if _falcon_version == 1: - return super()._handle_exception(ex, req, resp, params) # pylint: disable=W1114 - - return super()._handle_exception(req, resp, ex, params) + return super()._handle_exception(*args) def __call__(self, env, start_response): # pylint: disable=E1101 From 2dc8bfb4900d7b33dbd2f9b0d6c60434f7c45731 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 2 Mar 2026 14:56:44 +0100 Subject: [PATCH 18/41] gen-requirements: drop virtualenv limit (#4271) A new hatch release is out and that should work. --- gen-requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/gen-requirements.txt b/gen-requirements.txt index 203cd7b7b3..2512362a93 100644 --- a/gen-requirements.txt +++ b/gen-requirements.txt @@ -7,5 +7,3 @@ requests tomli tomli_w hatch -# TODO: stick with virtualenv < 21 until a new hatch release -virtualenv<21 From 45fa111b5c8e09a7e5bf62a78a59abc3f532fdab Mon Sep 17 00:00:00 2001 From: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:35:29 -0800 Subject: [PATCH 19/41] Fix psycopg2 (un)instrument_connection to use weakref, not mutate connection object (#4257) * Fix psycopg2 (un)instrument_connection to use weakref, not mutate object * Changelog * conditional import for docs types * Lint * SImplify test * Simplify * Fix docs --------- Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 2 + docs/conf.py | 2 + .../instrumentation/psycopg2/__init__.py | 62 +++++++++---- .../tests/test_psycopg2_integration.py | 90 +++++++++++++++++++ 4 files changed, 138 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4481a1128c..e75506344c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4258](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4258)) - `opentelemetry-instrumentation-threading`: fix AttributeError when Thread is run without starting ([#4246](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4246)) +- `opentelemetry-instrumentation-psycopg2`: Fix AttributeError by using instrumented connections weakref, instead of mutating connection object + ([#4257](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4257)) ### Breaking changes diff --git a/docs/conf.py b/docs/conf.py index e3e8ae145b..0f42c92be7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -118,6 +118,8 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), + "psycopg": ("https://www.psycopg.org/psycopg3/docs/", None), + "psycopg2": ("https://www.psycopg.org/docs/", None), "opentracing": ( "https://opentracing-python.readthedocs.io/en/latest/", None, diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py index f5e113a47e..d1753af6a9 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py @@ -140,8 +140,12 @@ --- """ +from __future__ import annotations + import logging +import threading import typing +import weakref from importlib.metadata import PackageNotFoundError, distribution from typing import Collection @@ -151,6 +155,7 @@ ) from psycopg2.sql import Composed # pylint: disable=no-name-in-module +from opentelemetry import trace as trace_api from opentelemetry.instrumentation import dbapi from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.psycopg2.package import ( @@ -161,7 +166,11 @@ from opentelemetry.instrumentation.psycopg2.version import __version__ _logger = logging.getLogger(__name__) -_OTEL_CURSOR_FACTORY_KEY = "_otel_orig_cursor_factory" + +if typing.TYPE_CHECKING: + from psycopg2.extensions import ( # pylint: disable=no-name-in-module + connection as PgConnection, + ) class Psycopg2Instrumentor(BaseInstrumentor): @@ -173,6 +182,8 @@ class Psycopg2Instrumentor(BaseInstrumentor): } _DATABASE_SYSTEM = "postgresql" + _INSTRUMENTED_CONNECTIONS = weakref.WeakKeyDictionary() + _INSTRUMENTED_CONNECTIONS_LOCK = threading.Lock() def instrumentation_dependencies(self) -> Collection[str]: # Determine which package of psycopg2 is installed @@ -222,11 +233,17 @@ def _uninstrument(self, **kwargs): # TODO(owais): check if core dbapi can do this for all dbapi implementations e.g, pymysql and mysql @staticmethod - def instrument_connection(connection, tracer_provider=None): + def instrument_connection( + connection: PgConnection, + tracer_provider: typing.Optional[trace_api.TracerProvider] = None, + ) -> PgConnection: """Enable instrumentation in a psycopg2 connection. + Uses `_INSTRUMENTED_CONNECTIONS` to store the original `cursor_factory` + per connection. + Args: - connection: psycopg2.extensions.connection + connection: The psycopg2 connection object to be instrumented. tracer_provider: opentelemetry.trace.TracerProvider, optional The TracerProvider to use for instrumentation. If not specified, @@ -236,29 +253,38 @@ def instrument_connection(connection, tracer_provider=None): An instrumented psycopg2 connection object. """ - if not hasattr(connection, "_is_instrumented_by_opentelemetry"): - connection._is_instrumented_by_opentelemetry = False + with Psycopg2Instrumentor._INSTRUMENTED_CONNECTIONS_LOCK: + if connection in Psycopg2Instrumentor._INSTRUMENTED_CONNECTIONS: + _logger.warning( + "Attempting to instrument Psycopg connection while already instrumented" + ) + return connection - if not connection._is_instrumented_by_opentelemetry: - setattr( - connection, _OTEL_CURSOR_FACTORY_KEY, connection.cursor_factory - ) + original_cursor_factory = connection.cursor_factory connection.cursor_factory = _new_cursor_factory( - tracer_provider=tracer_provider + base_factory=original_cursor_factory, + tracer_provider=tracer_provider, ) - connection._is_instrumented_by_opentelemetry = True - else: - _logger.warning( - "Attempting to instrument Psycopg connection while already instrumented" + Psycopg2Instrumentor._INSTRUMENTED_CONNECTIONS[connection] = ( + original_cursor_factory ) + return connection # TODO(owais): check if core dbapi can do this for all dbapi implementations e.g, pymysql and mysql @staticmethod - def uninstrument_connection(connection): - connection.cursor_factory = getattr( - connection, _OTEL_CURSOR_FACTORY_KEY, None - ) + def uninstrument_connection(connection: PgConnection) -> PgConnection: + """Disable instrumentation for a psycopg2 connection. + + Restores the original `cursor_factory` from `_INSTRUMENTED_CONNECTIONS`. + """ + with Psycopg2Instrumentor._INSTRUMENTED_CONNECTIONS_LOCK: + original_cursor_factory = ( + Psycopg2Instrumentor._INSTRUMENTED_CONNECTIONS.pop( + connection, None + ) + ) + connection.cursor_factory = original_cursor_factory return connection diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_integration.py b/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_integration.py index 9a6a5ff2fa..a1477278db 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_integration.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_integration.py @@ -215,6 +215,96 @@ def test_instrument_connection_with_instrument(self): spans_list = self.memory_exporter.get_finished_spans() self.assertEqual(len(spans_list), 1) + # pylint: disable=unused-argument + def test_instrument_connection_is_idempotent(self): + cnx = psycopg2.connect(database="test") + query = "SELECT * FROM test" + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 0) + + instrumentor = Psycopg2Instrumentor() + cnx = instrumentor.instrument_connection(cnx) + cnx = instrumentor.instrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + def test_instrument_connection_with_custom_cursor_factory_instrument_then_uninstrument( + self, + ): + instrumentor = Psycopg2Instrumentor() + cnx = psycopg2.connect(database="test", cursor_factory=MockCursor) + query = "SELECT * FROM test" + + self.assertIs(cnx.cursor_factory, MockCursor) + + cnx = instrumentor.instrument_connection(cnx) + self.assertIsNot(cnx.cursor_factory, MockCursor) + + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = instrumentor.uninstrument_connection(cnx) + self.assertIs(cnx.cursor_factory, MockCursor) + + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + def test_uninstrument_connection_is_idempotent(self): + instrumentor = Psycopg2Instrumentor() + cnx = psycopg2.connect(database="test") + query = "SELECT * FROM test" + + cnx = instrumentor.instrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = instrumentor.uninstrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = instrumentor.uninstrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + def test_instrument_connection_reinstrument_after_uninstrument(self): + instrumentor = Psycopg2Instrumentor() + cnx = psycopg2.connect(database="test") + query = "SELECT * FROM test" + + cnx = instrumentor.instrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = instrumentor.uninstrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = instrumentor.instrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 2) + # pylint: disable=unused-argument def test_uninstrument_connection_with_instrument(self): Psycopg2Instrumentor().instrument() From 8e64458b7fe413f1cb3b78066727741c6928645e Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 3 Mar 2026 10:55:30 +0100 Subject: [PATCH 20/41] Move logger handlers to opentelemetry-instrumentation-logging (#4210) * Move logging integrations into auto-instrumentation * opentelemetry-instrumentation-logging: move the sdk logging handler here And hook it up via entry point * Add header and future annotations import * Rename entry point group to opentelemetry_logging_integrations * Consider setting up the LoggingHandler as a normal instrumentation * Fix typo * Add missing import * Copy handler tests from core * More work towards green tests * Cleanup properly after loggingHandler tests * Quite hard to expect a mock to setup the handler * Call removehandler also on local loggers No change in practice * Fix wrong noop test * Move to our own env var for controlling autoinstrumentation * Copy handler benchmark from sdk * Document the new environment variables * Add changelog * Please pylint * Added warning about coexistence with sdk code * Reword a bit * Assert that the LoggingHandler has not been setup in uninstrumented test * Add manual handling of auto instrumentation and code attributes logging * Update instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/constants.py * Apply suggestions from code review Co-authored-by: Pablo Collins * Apply more Pablo feedback --------- Co-authored-by: Pablo Collins --- CHANGELOG.md | 2 + .../benchmark-requirements.txt | 1 + .../test_benchmark_logging_handler.py | 53 ++ .../instrumentation/logging/__init__.py | 61 +- .../instrumentation/logging/constants.py | 37 +- .../logging/environment_variables.py | 2 + .../instrumentation/logging/handler.py | 280 +++++++ .../tests/test_handler.py | 732 ++++++++++++++++++ .../tests/test_logging.py | 155 +++- tox.ini | 3 + 10 files changed, 1316 insertions(+), 10 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt create mode 100644 instrumentation/opentelemetry-instrumentation-logging/benchmarks/test_benchmark_logging_handler.py create mode 100644 instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py create mode 100644 instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e75506344c..6bb7aefc82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4141](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4141)) - `opentelemetry-instrumentation-pyramid`: pass request attributes at span creation ([#4139](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4139)) +- `opentelemetry-instrumentation-logging`: Move there the SDK LoggingHandler + ([#4210](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4210)) ### Fixed diff --git a/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt b/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt new file mode 100644 index 0000000000..44564857ef --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt @@ -0,0 +1 @@ +pytest-benchmark==4.0.0 diff --git a/instrumentation/opentelemetry-instrumentation-logging/benchmarks/test_benchmark_logging_handler.py b/instrumentation/opentelemetry-instrumentation-logging/benchmarks/test_benchmark_logging_handler.py new file mode 100644 index 0000000000..2f20d31835 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-logging/benchmarks/test_benchmark_logging_handler.py @@ -0,0 +1,53 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +import logging + +import pytest + +from opentelemetry.instrumentation.logging.handler import LoggingHandler +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +) + + +def _set_up_logging_handler(level): + logger_provider = LoggerProvider() + exporter = InMemoryLogRecordExporter() + processor = SimpleLogRecordProcessor(exporter=exporter) + logger_provider.add_log_record_processor(processor) + handler = LoggingHandler(level=level, logger_provider=logger_provider) + return handler + + +def _create_logger(handler, name): + logger = logging.getLogger(name) + logger.addHandler(handler) + return logger + + +@pytest.mark.parametrize("num_loggers", [1, 10, 100, 1000]) +def test_simple_get_logger_different_names(benchmark, num_loggers): + handler = _set_up_logging_handler(level=logging.DEBUG) + loggers = [ + _create_logger(handler, str(f"logger_{i}")) for i in range(num_loggers) + ] + + def benchmark_get_logger(): + for index in range(1000): + loggers[index % num_loggers].warning("test message") + + benchmark(benchmark_get_logger) diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/__init__.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/__init__.py index 53197957cb..ef01884de1 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/__init__.py @@ -15,7 +15,11 @@ # pylint: disable=empty-docstring,no-value-for-parameter,no-member,no-name-in-module """ -The OpenTelemetry `logging` integration automatically injects tracing context into +The OpenTelemetry `logging` instrumentation automatically instruments Python logging +system with an handler to convert log messages into OpenTelemetry logs. +You can disable this setting `OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION` to `false`. + +The OpenTelemetry `logging` integration can inject tracing context into log statements, though it is opt-in and must be enabled explicitly by setting the environment variable `OTEL_PYTHON_LOG_CORRELATION` to `true`. @@ -59,16 +63,22 @@ from os import environ from typing import Collection +from opentelemetry._logs import get_logger_provider from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.logging.constants import ( _MODULE_DOC, DEFAULT_LOGGING_FORMAT, ) from opentelemetry.instrumentation.logging.environment_variables import ( + OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION, + OTEL_PYTHON_LOG_CODE_ATTRIBUTES, OTEL_PYTHON_LOG_CORRELATION, OTEL_PYTHON_LOG_FORMAT, OTEL_PYTHON_LOG_LEVEL, ) +from opentelemetry.instrumentation.logging.handler import ( + _setup_logging_handler, +) from opentelemetry.instrumentation.logging.package import _instruments from opentelemetry.trace import ( INVALID_SPAN, @@ -86,6 +96,8 @@ "error": logging.ERROR, } +_logger = logging.getLogger(__name__) + class LoggingInstrumentor(BaseInstrumentor): # pylint: disable=empty-docstring __doc__ = f"""An instrumentor for stdlib logging module. @@ -120,6 +132,7 @@ def log_hook(span: Span, record: LogRecord): _old_factory = None _log_hook = None + _logging_handler = None def instrumentation_dependencies(self) -> Collection[str]: return _instruments @@ -199,7 +212,53 @@ def record_factory(*args, **kwargs): logging.setLogRecordFactory(record_factory) + # Here we need to handle 3 scenarios: + # - the sdk logging handler is enabled and we should do no nothing + # - the sdk logging handler is not enabled and we should setup the handler by default + # - the sdk logging handler is not enabled and the user do not want we setup the handler + sdk_autoinstrumentation_env_var = ( + environ.get( + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED", "notset" + ) + .strip() + .lower() + ) + if sdk_autoinstrumentation_env_var == "true": + _logger.warning( + "Skipping installation of LoggingHandler from " + "`opentelemetry-instrumentation-logging` to avoid duplicate logs. " + "The SDK's deprecated LoggingHandler is already active " + "(OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true). To migrate, unset " + "this environment variable. The SDK's handler will be removed in a future release." + ) + elif kwargs.get( + "enable_log_auto_instrumentation", + environ.get(OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION, "true") + .strip() + .lower() + == "true", + ): + log_code_attributes = kwargs.get( + "log_code_attributes", + environ.get(OTEL_PYTHON_LOG_CODE_ATTRIBUTES, "false") + .strip() + .lower() + == "true", + ) + logger_provider = get_logger_provider() + handler = _setup_logging_handler( + logger_provider=logger_provider, + log_code_attributes=log_code_attributes, + ) + LoggingInstrumentor._logging_handler = handler + def _uninstrument(self, **kwargs): if LoggingInstrumentor._old_factory: logging.setLogRecordFactory(LoggingInstrumentor._old_factory) LoggingInstrumentor._old_factory = None + + if LoggingInstrumentor._logging_handler: + logging.getLogger().removeHandler( + LoggingInstrumentor._logging_handler + ) + LoggingInstrumentor._logging_handler = None diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/constants.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/constants.py index 5eb6798231..7d9b33ba09 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/constants.py +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/constants.py @@ -16,7 +16,22 @@ _MODULE_DOC = """ -The OpenTelemetry ``logging`` integration automatically injects tracing context into log statements. +The OpenTelemetry ``logging`` instrumentation automatically instruments Python logging +with a handler to convert Python log messages into OpenTelemetry logs and export them. +You can disable this by setting ``OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION`` to ``false``. + +.. warning:: + + This package provides a logging handler to replace the deprecated one in ``opentelemetry-sdk``. + Therefore if you have ``opentelemetry-instrumentation-logging`` installed, you don't need to set the + ``OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED`` environment variable to ``true``. + By default, this instrumentation does not add ``code`` namespace attributes as the SDK's logger does, but adding them can be enabled by using the + ``OTEL_PYTHON_LOG_CODE_ATTRIBUTES`` environment variable. + +Enable trace context injection +------------------------------ + +The OpenTelemetry ``logging`` integration can also be configured to inject tracing context into log statements. The integration registers a custom log record factory with the the standard library logging module that automatically inject tracing context into log record objects. Optionally, the integration can also call ``logging.basicConfig()`` to set a logging @@ -35,19 +50,25 @@ {default_logging_format} -Enable trace context injection ------------------------------- - The integration is opt-in and must be enabled explicitly by setting the environment variable ``OTEL_PYTHON_LOG_CORRELATION`` to ``true``. - -The integration always registers the custom factory that injects the tracing context into the log record objects. Setting -``OTEL_PYTHON_LOG_CORRELATION`` to ``true`` calls ``logging.basicConfig()`` to set a logging format that actually makes +Setting ``OTEL_PYTHON_LOG_CORRELATION`` to ``true`` calls ``logging.basicConfig()`` to set a logging format that actually makes use of the injected variables. - Environment variables --------------------- +.. envvar:: OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION + +Set this env var to ``false`` to skip installing the logging handler provided by this package. + +The default value is ``true``. + +.. envvar:: OTEL_PYTHON_CODE_ATTRIBUTES + +Set this env var to ``true`` to add ``code`` attributes (``code.file.path``, ``code.function.name``, ``code.line.number``) to OpenTelemetry logs, referencing the Python source location that emitted each log message. + +The default value is ``false``. + .. envvar:: OTEL_PYTHON_LOG_CORRELATION This env var must be set to ``true`` in order to enable trace context injection into logs by calling ``logging.basicConfig()`` and diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/environment_variables.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/environment_variables.py index 394689265f..bad44c1994 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/environment_variables.py +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/environment_variables.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION = "OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION" +OTEL_PYTHON_LOG_CODE_ATTRIBUTES = "OTEL_PYTHON_LOG_CODE_ATTRIBUTES" OTEL_PYTHON_LOG_CORRELATION = "OTEL_PYTHON_LOG_CORRELATION" OTEL_PYTHON_LOG_FORMAT = "OTEL_PYTHON_LOG_FORMAT" OTEL_PYTHON_LOG_LEVEL = "OTEL_PYTHON_LOG_LEVEL" diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py new file mode 100644 index 0000000000..c9a890db8b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py @@ -0,0 +1,280 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +from __future__ import annotations + +import logging +import logging.config +import threading +import traceback +from time import time_ns +from typing import Callable + +from opentelemetry._logs import ( + LoggerProvider, + LogRecord, + NoOpLogger, + SeverityNumber, + get_logger, + get_logger_provider, +) +from opentelemetry.attributes import _VALID_ANY_VALUE_TYPES +from opentelemetry.context import get_current +from opentelemetry.semconv._incubating.attributes import code_attributes +from opentelemetry.semconv.attributes import exception_attributes +from opentelemetry.util.types import _ExtendedAttributes + + +def _setup_logging_handler( + logger_provider: LoggerProvider, log_code_attributes: bool = False +) -> LoggingHandler: + handler = LoggingHandler( + level=logging.NOTSET, + logger_provider=logger_provider, + log_code_attributes=log_code_attributes, + ) + logging.getLogger().addHandler(handler) + _overwrite_logging_config_fns(handler) + return handler + + +def _overwrite_logging_config_fns(handler: "LoggingHandler") -> None: + root = logging.getLogger() + + def wrapper(config_fn: Callable) -> Callable: + def overwritten_config_fn(*args, **kwargs): + removed_handler = False + # We don't want the OTLP handler to be modified or deleted by the logging config functions. + # So we remove it and then add it back after the function call. + if handler in root.handlers: + removed_handler = True + root.handlers.remove(handler) + try: + config_fn(*args, **kwargs) + finally: + # Ensure handler is added back if logging function throws exception. + if removed_handler: + root.addHandler(handler) + + return overwritten_config_fn + + logging.config.fileConfig = wrapper(logging.config.fileConfig) + logging.config.dictConfig = wrapper(logging.config.dictConfig) + logging.basicConfig = wrapper(logging.basicConfig) + + +# skip natural LogRecord attributes +# http://docs.python.org/library/logging.html#logrecord-attributes +_RESERVED_ATTRS = frozenset( + ( + "asctime", + "args", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "getMessage", + "message", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + "taskName", + ) +) + + +class LoggingHandler(logging.Handler): + """A handler class which writes logging records, in OTLP format, to + a network destination or file. Supports signals from the `logging` module. + https://docs.python.org/3/library/logging.html + """ + + def __init__( + self, + level: int = logging.NOTSET, + logger_provider: LoggerProvider | None = None, + log_code_attributes: bool = False, + ) -> None: + super().__init__(level=level) + self._logger_provider = logger_provider or get_logger_provider() + + self._log_code_attributes = log_code_attributes + + def _get_attributes( + self, record: logging.LogRecord + ) -> _ExtendedAttributes: + attributes = { + k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS + } + + if self._log_code_attributes: + # Add standard code attributes for logs. + attributes[code_attributes.CODE_FILE_PATH] = record.pathname + attributes[code_attributes.CODE_FUNCTION_NAME] = record.funcName + attributes[code_attributes.CODE_LINE_NUMBER] = record.lineno + + if record.exc_info: + exctype, value, tb = record.exc_info + if exctype is not None: + attributes[exception_attributes.EXCEPTION_TYPE] = ( + exctype.__name__ + ) + if value is not None and value.args: + attributes[exception_attributes.EXCEPTION_MESSAGE] = str( + value.args[0] + ) + if tb is not None: + # https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans/#stacktrace-representation + attributes[exception_attributes.EXCEPTION_STACKTRACE] = ( + "".join(traceback.format_exception(*record.exc_info)) + ) + return attributes + + def _translate(self, record: logging.LogRecord) -> LogRecord: + timestamp = int(record.created * 1e9) + observered_timestamp = time_ns() + attributes = self._get_attributes(record) + severity_number = std_to_otel(record.levelno) + if self.formatter: + body = self.format(record) + else: + # `record.getMessage()` uses `record.msg` as a template to format + # `record.args` into. There is a special case in `record.getMessage()` + # where it will only attempt formatting if args are provided, + # otherwise, it just stringifies `record.msg`. + # + # Since the OTLP body field has a type of 'any' and the logging module + # is sometimes used in such a way that objects incorrectly end up + # set as record.msg, in those cases we would like to bypass + # `record.getMessage()` completely and set the body to the object + # itself instead of its string representation. + # For more background, see: https://github.com/open-telemetry/opentelemetry-python/pull/4216 + if not record.args and not isinstance(record.msg, str): + # if record.msg is not a value we can export, cast it to string + if not isinstance(record.msg, _VALID_ANY_VALUE_TYPES): + body = str(record.msg) + else: + body = record.msg + else: + body = record.getMessage() + + # related to https://github.com/open-telemetry/opentelemetry-python/issues/3548 + # Severity Text = WARN as defined in https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#displaying-severity. + level_name = ( + "WARN" if record.levelname == "WARNING" else record.levelname + ) + + return LogRecord( + timestamp=timestamp, + observed_timestamp=observered_timestamp, + context=get_current() or None, + severity_text=level_name, + severity_number=severity_number, + body=body, + attributes=attributes, + ) + + def emit(self, record: logging.LogRecord) -> None: + """ + Emit a record. Skip emitting if logger is NoOp. + + The record is translated to OTel format, and then sent across the pipeline. + """ + logger = get_logger(record.name, logger_provider=self._logger_provider) + if not isinstance(logger, NoOpLogger): + logger.emit(self._translate(record)) + + def flush(self) -> None: + """ + Flushes the logging output. Skip flushing if logging_provider has no force_flush method. + """ + if hasattr(self._logger_provider, "force_flush") and callable( + self._logger_provider.force_flush # type: ignore[reportAttributeAccessIssue] + ): + # This is done in a separate thread to avoid a potential deadlock, for + # details see https://github.com/open-telemetry/opentelemetry-python/pull/4636. + thread = threading.Thread(target=self._logger_provider.force_flush) # type: ignore[reportAttributeAccessIssue] + thread.start() + + +_STD_TO_OTEL = { + 10: SeverityNumber.DEBUG, + 11: SeverityNumber.DEBUG2, + 12: SeverityNumber.DEBUG3, + 13: SeverityNumber.DEBUG4, + 14: SeverityNumber.DEBUG4, + 15: SeverityNumber.DEBUG4, + 16: SeverityNumber.DEBUG4, + 17: SeverityNumber.DEBUG4, + 18: SeverityNumber.DEBUG4, + 19: SeverityNumber.DEBUG4, + 20: SeverityNumber.INFO, + 21: SeverityNumber.INFO2, + 22: SeverityNumber.INFO3, + 23: SeverityNumber.INFO4, + 24: SeverityNumber.INFO4, + 25: SeverityNumber.INFO4, + 26: SeverityNumber.INFO4, + 27: SeverityNumber.INFO4, + 28: SeverityNumber.INFO4, + 29: SeverityNumber.INFO4, + 30: SeverityNumber.WARN, + 31: SeverityNumber.WARN2, + 32: SeverityNumber.WARN3, + 33: SeverityNumber.WARN4, + 34: SeverityNumber.WARN4, + 35: SeverityNumber.WARN4, + 36: SeverityNumber.WARN4, + 37: SeverityNumber.WARN4, + 38: SeverityNumber.WARN4, + 39: SeverityNumber.WARN4, + 40: SeverityNumber.ERROR, + 41: SeverityNumber.ERROR2, + 42: SeverityNumber.ERROR3, + 43: SeverityNumber.ERROR4, + 44: SeverityNumber.ERROR4, + 45: SeverityNumber.ERROR4, + 46: SeverityNumber.ERROR4, + 47: SeverityNumber.ERROR4, + 48: SeverityNumber.ERROR4, + 49: SeverityNumber.ERROR4, + 50: SeverityNumber.FATAL, + 51: SeverityNumber.FATAL2, + 52: SeverityNumber.FATAL3, + 53: SeverityNumber.FATAL4, +} + + +def std_to_otel(levelno: int) -> SeverityNumber: + """ + Map python log levelno as defined in https://docs.python.org/3/library/logging.html#logging-levels + to OTel log severity number as defined here: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#field-severitynumber + """ + if levelno < 10: + return SeverityNumber.UNSPECIFIED + if levelno > 53: + return SeverityNumber.FATAL4 + return _STD_TO_OTEL[levelno] diff --git a/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py b/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py new file mode 100644 index 0000000000..68c8daaa24 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py @@ -0,0 +1,732 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +import logging +import os +import unittest +from unittest.mock import Mock, patch + +from opentelemetry._logs import NoOpLoggerProvider, SeverityNumber +from opentelemetry._logs import get_logger as APIGetLogger +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.instrumentation.logging.handler import ( + LoggingHandler, + _setup_logging_handler, +) +from opentelemetry.sdk import trace +from opentelemetry.sdk._logs import ( + LoggerProvider, + LogRecordProcessor, + ReadableLogRecord, +) +from opentelemetry.sdk.environment_variables import OTEL_ATTRIBUTE_COUNT_LIMIT +from opentelemetry.semconv._incubating.attributes import code_attributes +from opentelemetry.semconv.attributes import exception_attributes +from opentelemetry.trace import ( + INVALID_SPAN_CONTEXT, + set_span_in_context, +) + + +# pylint: disable=too-many-public-methods +class TestLoggingHandler(unittest.TestCase): + def test_handler_default_log_level(self): + processor, logger, handler = set_up_test_logging(logging.NOTSET) + + # Make sure debug messages are ignored by default + logger.debug("Debug message") + assert processor.emit_count() == 0 + + # Assert emit gets called for warning message + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message") + self.assertEqual(processor.emit_count(), 1) + + logger.removeHandler(handler) + + def test_handler_custom_log_level(self): + processor, logger, handler = set_up_test_logging(logging.ERROR) + + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message test custom log level") + # Make sure any log with level < ERROR is ignored + assert processor.emit_count() == 0 + + with self.assertLogs(level=logging.ERROR): + logger.error("Mumbai, we have a major problem") + with self.assertLogs(level=logging.CRITICAL): + logger.critical("No Time For Caution") + self.assertEqual(processor.emit_count(), 2) + + logger.removeHandler(handler) + + # pylint: disable=protected-access + def test_log_record_emit_noop(self): + noop_logger_provider = NoOpLoggerProvider() + logger_mock = APIGetLogger( + __name__, logger_provider=noop_logger_provider + ) + logger = logging.getLogger(__name__) + handler_mock = Mock(spec=LoggingHandler) + handler_mock._logger = logger_mock + handler_mock.level = logging.WARNING + logger.addHandler(handler_mock) + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message") + + logger.removeHandler(handler_mock) + + def test_log_flush_noop(self): + no_op_logger_provider = NoOpLoggerProvider() + + logger = logging.getLogger("foo") + handler = LoggingHandler( + level=logging.NOTSET, logger_provider=no_op_logger_provider + ) + logger.addHandler(handler) + + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message") + + # the LoggingHandler flush method will call the force_flush method of LoggerProvider in + # a separate thread if present. NoOpLoggerProvider is not supposed to have that + with patch( + "opentelemetry.instrumentation.logging.handler.threading" + ) as threading_mock: + logger.handlers[0].flush() + + threading_mock.Thread.assert_not_called() + + logger.removeHandler(handler) + + def test_log_record_no_span_context(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + + # Assert emit gets called for warning message + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message") + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertEqual( + record.log_record.trace_id, INVALID_SPAN_CONTEXT.trace_id + ) + self.assertEqual( + record.log_record.span_id, INVALID_SPAN_CONTEXT.span_id + ) + self.assertEqual( + record.log_record.trace_flags, + INVALID_SPAN_CONTEXT.trace_flags, + ) + + logger.removeHandler(handler) + + def test_log_record_observed_timestamp(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message") + + record = processor.get_log_record(0) + self.assertIsNotNone(record.log_record.observed_timestamp) + + logger.removeHandler(handler) + + def test_log_record_user_attributes(self): + """Attributes can be injected into logs by adding them to the ReadWriteLogRecord""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + # Assert emit gets called for warning message + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message", extra={"http.status_code": 200}) + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertEqual(len(record.log_record.attributes), 1) + self.assertEqual(record.log_record.attributes["http.status_code"], 200) + self.assertTrue( + isinstance(record.log_record.attributes, BoundedAttributes) + ) + + logger.removeHandler(handler) + + def test_log_record_with_code_attributes(self): + processor, logger, handler = set_up_test_logging( + logging.WARNING, log_code_attributes=True + ) + + # Assert emit gets called for warning message + with self.assertLogs(level=logging.WARNING): + logger.warning("Warning message", extra={"http.status_code": 200}) + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertEqual(len(record.log_record.attributes), 4) + self.assertEqual(record.log_record.attributes["http.status_code"], 200) + self.assertTrue( + record.log_record.attributes[ + code_attributes.CODE_FILE_PATH + ].endswith("test_handler.py") + ) + self.assertEqual( + record.log_record.attributes[code_attributes.CODE_FUNCTION_NAME], + "test_log_record_with_code_attributes", + ) + # The line of the log statement is not a constant (changing tests may change that), + # so only check that the attribute is present. + self.assertTrue( + code_attributes.CODE_LINE_NUMBER in record.log_record.attributes + ) + self.assertTrue( + isinstance(record.log_record.attributes, BoundedAttributes) + ) + + logger.removeHandler(handler) + + def test_log_record_exception(self): + """Exception information will be included in attributes""" + processor, logger, handler = set_up_test_logging(logging.ERROR) + + try: + raise ZeroDivisionError("division by zero") + except ZeroDivisionError: + with self.assertLogs(level=logging.ERROR): + logger.exception("Zero Division Error") + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertTrue(isinstance(record.log_record.body, str)) + self.assertEqual(record.log_record.body, "Zero Division Error") + self.assertEqual( + record.log_record.attributes[exception_attributes.EXCEPTION_TYPE], + ZeroDivisionError.__name__, + ) + self.assertEqual( + record.log_record.attributes[ + exception_attributes.EXCEPTION_MESSAGE + ], + "division by zero", + ) + stack_trace = record.log_record.attributes[ + exception_attributes.EXCEPTION_STACKTRACE + ] + self.assertIsInstance(stack_trace, str) + self.assertTrue("Traceback" in stack_trace) + self.assertTrue("ZeroDivisionError" in stack_trace) + self.assertTrue("division by zero" in stack_trace) + self.assertTrue(__file__ in stack_trace) + + logger.removeHandler(handler) + + def test_log_record_recursive_exception(self): + """Exception information will be included in attributes even though it is recursive""" + processor, logger, handler = set_up_test_logging(logging.ERROR) + + try: + raise ZeroDivisionError( + ZeroDivisionError(ZeroDivisionError("division by zero")) + ) + except ZeroDivisionError: + with self.assertLogs(level=logging.ERROR): + logger.exception("Zero Division Error") + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertEqual(record.log_record.body, "Zero Division Error") + self.assertEqual( + record.log_record.attributes[exception_attributes.EXCEPTION_TYPE], + ZeroDivisionError.__name__, + ) + self.assertEqual( + record.log_record.attributes[ + exception_attributes.EXCEPTION_MESSAGE + ], + "division by zero", + ) + stack_trace = record.log_record.attributes[ + exception_attributes.EXCEPTION_STACKTRACE + ] + self.assertIsInstance(stack_trace, str) + self.assertTrue("Traceback" in stack_trace) + self.assertTrue("ZeroDivisionError" in stack_trace) + self.assertTrue("division by zero" in stack_trace) + self.assertTrue(__file__ in stack_trace) + + logger.removeHandler(handler) + + def test_log_exc_info_false(self): + """Exception information will not be included in attributes""" + processor, logger, handler = set_up_test_logging(logging.NOTSET) + + try: + raise ZeroDivisionError("division by zero") + except ZeroDivisionError: + with self.assertLogs(level=logging.ERROR): + logger.error("Zero Division Error", exc_info=False) + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertEqual(record.log_record.body, "Zero Division Error") + self.assertNotIn( + exception_attributes.EXCEPTION_TYPE, + record.log_record.attributes, + ) + self.assertNotIn( + exception_attributes.EXCEPTION_MESSAGE, + record.log_record.attributes, + ) + self.assertNotIn( + exception_attributes.EXCEPTION_STACKTRACE, + record.log_record.attributes, + ) + + logger.removeHandler(handler) + + def test_log_record_exception_with_object_payload(self): + processor, logger, handler = set_up_test_logging(logging.ERROR) + + class CustomException(Exception): + def __str__(self): + return "CustomException stringified" + + try: + raise CustomException("CustomException message") + except CustomException as exception: + with self.assertLogs(level=logging.ERROR): + logger.exception(exception) + + record = processor.get_log_record(0) + + self.assertIsNotNone(record) + self.assertTrue(isinstance(record.log_record.body, str)) + self.assertEqual(record.log_record.body, "CustomException stringified") + self.assertEqual( + record.log_record.attributes[exception_attributes.EXCEPTION_TYPE], + CustomException.__name__, + ) + self.assertEqual( + record.log_record.attributes[ + exception_attributes.EXCEPTION_MESSAGE + ], + "CustomException message", + ) + stack_trace = record.log_record.attributes[ + exception_attributes.EXCEPTION_STACKTRACE + ] + self.assertIsInstance(stack_trace, str) + self.assertTrue("Traceback" in stack_trace) + self.assertTrue("CustomException" in stack_trace) + self.assertTrue(__file__ in stack_trace) + + logger.removeHandler(handler) + + def test_log_record_trace_correlation(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + + tracer = trace.TracerProvider().get_tracer(__name__) + with tracer.start_as_current_span("test") as span: + mock_context = set_span_in_context(span) + + with patch( + "opentelemetry.sdk._logs._internal.get_current", + return_value=mock_context, + ): + with self.assertLogs(level=logging.CRITICAL): + logger.critical("Critical message within span") + + record = processor.get_log_record(0) + + self.assertEqual( + record.log_record.body, + "Critical message within span", + ) + self.assertEqual(record.log_record.severity_text, "CRITICAL") + self.assertEqual( + record.log_record.severity_number, + SeverityNumber.FATAL, + ) + self.assertEqual(record.log_record.context, mock_context) + span_context = span.get_span_context() + self.assertEqual( + record.log_record.trace_id, span_context.trace_id + ) + self.assertEqual( + record.log_record.span_id, span_context.span_id + ) + self.assertEqual( + record.log_record.trace_flags, + span_context.trace_flags, + ) + + logger.removeHandler(handler) + + def test_log_record_trace_correlation_deprecated(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + + tracer = trace.TracerProvider().get_tracer(__name__) + with tracer.start_as_current_span("test") as span: + with self.assertLogs(level=logging.CRITICAL): + logger.critical("Critical message within span") + + record = processor.get_log_record(0) + + self.assertEqual( + record.log_record.body, "Critical message within span" + ) + self.assertEqual(record.log_record.severity_text, "CRITICAL") + self.assertEqual( + record.log_record.severity_number, SeverityNumber.FATAL + ) + span_context = span.get_span_context() + self.assertEqual(record.log_record.trace_id, span_context.trace_id) + self.assertEqual(record.log_record.span_id, span_context.span_id) + self.assertEqual( + record.log_record.trace_flags, span_context.trace_flags + ) + + logger.removeHandler(handler) + + def test_warning_without_formatter(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + logger.warning("Test message") + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.body, "Test message") + + logger.removeHandler(handler) + + def test_exception_without_formatter(self): + processor, logger, handler = set_up_test_logging(logging.WARNING) + logger.exception("Test exception") + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.body, "Test exception") + + logger.removeHandler(handler) + + def test_warning_with_formatter(self): + processor, logger, handler = set_up_test_logging( + logging.WARNING, + formatter=logging.Formatter( + "%(name)s - %(levelname)s - %(message)s" + ), + ) + logger.warning("Test message") + + record = processor.get_log_record(0) + self.assertEqual( + record.log_record.body, "foo - WARNING - Test message" + ) + + logger.removeHandler(handler) + + def test_log_body_is_always_string_with_formatter(self): + processor, logger, handler = set_up_test_logging( + logging.WARNING, + formatter=logging.Formatter( + "%(name)s - %(levelname)s - %(message)s" + ), + ) + logger.warning(["something", "of", "note"]) + + record = processor.get_log_record(0) + self.assertIsInstance(record.log_record.body, str) + + logger.removeHandler(handler) + + @patch.dict(os.environ, {"OTEL_SDK_DISABLED": "true"}) + def test_handler_root_logger_with_disabled_sdk_does_not_go_into_recursion_error( + self, + ): + processor, logger, handler = set_up_test_logging( + logging.NOTSET, root_logger=True + ) + logger.warning("hello") + + self.assertEqual(processor.emit_count(), 0) + + logger.removeHandler(handler) + + @patch.dict(os.environ, {OTEL_ATTRIBUTE_COUNT_LIMIT: "3"}) + def test_otel_attribute_count_limit_respected_in_logging_handler(self): + """Test that OTEL_ATTRIBUTE_COUNT_LIMIT is properly respected by LoggingHandler.""" + # Create a new LoggerProvider within the patched environment + # This will create LogRecordLimits() that reads from the environment variable + logger_provider = LoggerProvider() + processor = FakeProcessor() + logger_provider.add_log_record_processor(processor) + logger = logging.getLogger("env_test") + handler = LoggingHandler( + level=logging.WARNING, logger_provider=logger_provider + ) + logger.addHandler(handler) + + # Create a log record with many extra attributes + extra_attrs = {f"custom_attr_{i}": f"value_{i}" for i in range(10)} + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "Test message with many attributes", extra=extra_attrs + ) + + record = processor.get_log_record(0) + + # With OTEL_ATTRIBUTE_COUNT_LIMIT=3, should have exactly 3 attributes + total_attrs = len(record.log_record.attributes) + self.assertEqual( + total_attrs, + 3, + f"Should have exactly 3 attributes due to limit, got {total_attrs}", + ) + + # Should have 7 dropped attributes (10 custom - 3 kept = 7 dropped) + self.assertEqual( + record.dropped_attributes, + 7, + f"Should have 7 dropped attributes, got {record.dropped_attributes}", + ) + + logger.removeHandler(handler) + + @patch.dict(os.environ, {OTEL_ATTRIBUTE_COUNT_LIMIT: "5"}) + def test_otel_attribute_count_limit_includes_code_attributes(self): + """Test that OTEL_ATTRIBUTE_COUNT_LIMIT applies to all attributes including code attributes.""" + # Create a new LoggerProvider within the patched environment + # This will create LogRecordLimits() that reads from the environment variable + logger_provider = LoggerProvider() + processor = FakeProcessor() + logger_provider.add_log_record_processor(processor) + logger = logging.getLogger("env_test_2") + handler = LoggingHandler( + level=logging.WARNING, logger_provider=logger_provider + ) + logger.addHandler(handler) + + # Create a log record with some extra attributes + extra_attrs = {f"user_attr_{i}": f"value_{i}" for i in range(8)} + + with self.assertLogs(level=logging.WARNING): + logger.warning("Test message", extra=extra_attrs) + + record = processor.get_log_record(0) + + # With OTEL_ATTRIBUTE_COUNT_LIMIT=5, should have exactly 5 attributes + total_attrs = len(record.log_record.attributes) + self.assertEqual( + total_attrs, + 5, + f"Should have exactly 5 attributes due to limit, got {total_attrs}", + ) + + # Should have 3 dropped attributes (8 user - 5 kept = 3 dropped) + self.assertEqual( + record.dropped_attributes, + 3, + f"Should have 3 dropped attributes, got {record.dropped_attributes}", + ) + + logger.removeHandler(handler) + + def test_logging_handler_without_env_var_uses_default_limit(self): + """Test that without OTEL_ATTRIBUTE_COUNT_LIMIT, default limit (128) should apply.""" + processor, logger, _ = set_up_test_logging(logging.WARNING) + + # Create a log record with many attributes (more than default limit of 128) + extra_attrs = {f"attr_{i}": f"value_{i}" for i in range(150)} + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "Test message with many attributes", extra=extra_attrs + ) + + record = processor.get_log_record(0) + + # Should be limited to default limit (128) total attributes + total_attrs = len(record.log_record.attributes) + self.assertEqual( + total_attrs, + 128, + f"Should have exactly 128 attributes (default limit), got {total_attrs}", + ) + + # Should have 22 dropped attributes (150 user - 128 kept = 22 dropped) + self.assertEqual( + record.dropped_attributes, + 22, + f"Should have 22 dropped attributes, got {record.dropped_attributes}", + ) + + +# pylint: disable=invalid-name +class SetupLoggingHandlerTestCase(unittest.TestCase): + def test_basicConfig_works_with_otel_handler(self): + logger_provider = LoggerProvider() + with ResetGlobalLoggingState(): + _setup_logging_handler(logger_provider=logger_provider) + + logging.basicConfig(level=logging.INFO) + + root_logger = logging.getLogger() + stream_handlers = [ + h + for h in root_logger.handlers + if isinstance(h, logging.StreamHandler) + ] + self.assertEqual( + len(stream_handlers), + 1, + "basicConfig should add a StreamHandler even when OTel handler exists", + ) + + def test_basicConfig_preserves_otel_handler(self): + logger_provider = LoggerProvider() + with ResetGlobalLoggingState(): + _setup_logging_handler(logger_provider=logger_provider) + + root_logger = logging.getLogger() + self.assertEqual( + len(root_logger.handlers), + 1, + "Should be exactly one OpenTelemetry LoggingHandler", + ) + handler = root_logger.handlers[0] + self.assertIsInstance(handler, LoggingHandler) + logging.basicConfig() + + self.assertGreater(len(root_logger.handlers), 1) + + logging_handlers = [ + h + for h in root_logger.handlers + if isinstance(h, LoggingHandler) + ] + self.assertEqual( + len(logging_handlers), + 1, + "Should still have exactly one OpenTelemetry LoggingHandler", + ) + root_logger.removeHandler(logging_handlers[0]) + + def test_dictConfig_preserves_otel_handler(self): + logger_provider = LoggerProvider() + with ResetGlobalLoggingState(): + _setup_logging_handler(logger_provider=logger_provider) + + root_logger = logging.getLogger() + self.assertEqual( + len(root_logger.handlers), + 1, + "Should be exactly one OpenTelemetry LoggingHandler", + ) + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, # If this is True all loggers are disabled. Many unit tests assert loggers emit logs. + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "": { # root logger + "handlers": ["console"], + }, + }, + } + ) + self.assertEqual(len(root_logger.handlers), 2) + + logging_handlers = [ + h + for h in root_logger.handlers + if isinstance(h, LoggingHandler) + ] + self.assertEqual( + len(logging_handlers), + 1, + "Should still have exactly one OpenTelemetry LoggingHandler", + ) + + root_logger.removeHandler(logging_handlers[0]) + + +def set_up_test_logging( + level, formatter=None, root_logger=False, log_code_attributes=False +): + logger_provider = LoggerProvider() + processor = FakeProcessor() + logger_provider.add_log_record_processor(processor) + logger = logging.getLogger(None if root_logger else "foo") + handler = LoggingHandler( + level=level, + logger_provider=logger_provider, + log_code_attributes=log_code_attributes, + ) + if formatter: + handler.setFormatter(formatter) + logger.addHandler(handler) + return processor, logger, handler + + +class FakeProcessor(LogRecordProcessor): + def __init__(self): + self.log_data_emitted = [] + + def on_emit(self, log_record: ReadableLogRecord): + self.log_data_emitted.append(log_record) + + def shutdown(self): + pass + + def force_flush(self, timeout_millis: int = 30000): + pass + + def emit_count(self): + return len(self.log_data_emitted) + + def get_log_record(self, i): + return self.log_data_emitted[i] + + +# Any test that calls _init_logging with setup_logging_handler=True +# should call _init_logging within this context manager, to +# ensure the global logging state is reset after the test. +class ResetGlobalLoggingState: + def __init__(self): + self.original_basic_config = logging.basicConfig + self.original_dict_config = logging.config.dictConfig + self.original_file_config = logging.config.fileConfig + self.root_logger = logging.getLogger() + self.original_handlers = None + + def __enter__(self): + self.original_handlers = self.root_logger.handlers[:] + self.root_logger.handlers = [] + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.root_logger.handlers = [] + for handler in self.original_handlers: + self.root_logger.addHandler(handler) + logging.basicConfig = self.original_basic_config + logging.config.dictConfig = self.original_dict_config + logging.config.fileConfig = self.original_file_config diff --git a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py index 37e635f2a4..bd1d0ddba3 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py +++ b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py @@ -18,10 +18,12 @@ import pytest -from opentelemetry.instrumentation.logging import ( # pylint: disable=no-name-in-module +from opentelemetry._logs import get_logger_provider +from opentelemetry.instrumentation.logging import ( DEFAULT_LOGGING_FORMAT, LoggingInstrumentor, ) +from opentelemetry.instrumentation.logging.handler import LoggingHandler from opentelemetry.test.test_base import TestBase from opentelemetry.trace import NoOpTracerProvider, ProxyTracer, get_tracer @@ -40,6 +42,7 @@ def get_tracer( # pylint: disable=no-self-use ) +# pylint: disable=no-self-use,too-many-public-methods class TestLoggingInstrumentorProxyTracerProvider(TestBase): @pytest.fixture(autouse=True) def inject_fixtures(self, caplog): @@ -241,6 +244,14 @@ def test_uninstrumented(self): self.assertFalse(hasattr(record, "otelTraceID")) self.assertFalse(hasattr(record, "otelTraceSampled")) + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(logging_handler_instances, []) + def test_no_op_tracer_provider(self): LoggingInstrumentor().uninstrument() LoggingInstrumentor().instrument( @@ -257,3 +268,145 @@ def test_no_op_tracer_provider(self): self.assertEqual(record.otelTraceID, "0") self.assertEqual(record.otelServiceName, "") self.assertEqual(record.otelTraceSampled, False) + + @mock.patch.dict( + "os.environ", + {"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true"}, + ) + def test_handler_setup_is_disabled_if_sdk_autoinstrumentation_env_var_is_set_to_true( + self, + ): + LoggingInstrumentor().uninstrument() + with self.caplog.at_level(level=logging.WARNING): + LoggingInstrumentor().instrument() + + self.assertEqual(len(self.caplog.records), 1) + record = self.caplog.records[0] + self.assertEqual( + record.message, + "Skipping installation of LoggingHandler from `opentelemetry-instrumentation-logging` " + "to avoid duplicate logs. The SDK's deprecated LoggingHandler is already " + "active (OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true). To migrate, unset " + "this environment variable. The SDK's handler will be removed in a future release.", + ) + + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(logging_handler_instances, []) + + @mock.patch.dict( + "os.environ", + {"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "false"}, + ) + def test_handler_setup_is_enabled_if_sdk_autoinstrumentation_env_var_is_set_to_false( + self, + ): + LoggingInstrumentor().uninstrument() + with self.caplog.at_level(level=logging.WARNING): + LoggingInstrumentor().instrument() + + self.assertEqual(len(self.caplog.records), 0) + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(len(logging_handler_instances), 1) + + @mock.patch.dict( + "os.environ", + {"OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION": "false"}, + ) + def test_handler_setup_is_enabled_if_autoinstrumentation_env_var_is_set_to_false( + self, + ): + LoggingInstrumentor().uninstrument() + with self.caplog.at_level(level=logging.WARNING): + LoggingInstrumentor().instrument() + + self.assertEqual(len(self.caplog.records), 0) + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(logging_handler_instances, []) + + def test_handler_setup_is_called_if_autoinstrumentation_env_vars_are_not_set( + self, + ): + LoggingInstrumentor().uninstrument() + with self.caplog.at_level(level=logging.WARNING): + LoggingInstrumentor().instrument() + + self.assertEqual(len(self.caplog.records), 0) + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(len(logging_handler_instances), 1) + + def test_handler_setup_is_called_without_code_attributes_by_default(self): + LoggingInstrumentor().uninstrument() + with mock.patch( + "opentelemetry.instrumentation.logging._setup_logging_handler" + ) as setup_mock: + LoggingInstrumentor().instrument() + + logger_provider = get_logger_provider() + setup_mock.assert_called_once_with( + logger_provider=logger_provider, log_code_attributes=False + ) + + @mock.patch.dict("os.environ", {"OTEL_PYTHON_LOG_CODE_ATTRIBUTES": "true"}) + def test_handler_setup_is_called_with_code_attributes_from_env_var(self): + LoggingInstrumentor().uninstrument() + with mock.patch( + "opentelemetry.instrumentation.logging._setup_logging_handler" + ) as setup_mock: + LoggingInstrumentor().instrument() + + logger_provider = get_logger_provider() + setup_mock.assert_called_once_with( + logger_provider=logger_provider, log_code_attributes=True + ) + + def test_handler_setup_is_controlled_by_instrumentor_parameter( + self, + ): + LoggingInstrumentor().uninstrument() + with self.caplog.at_level(level=logging.WARNING): + LoggingInstrumentor().instrument( + enable_log_auto_instrumentation=False + ) + + self.assertEqual(len(self.caplog.records), 0) + root_logger = logging.getLogger() + logging_handler_instances = [ + handler + for handler in root_logger.handlers + if isinstance(handler, LoggingHandler) + ] + self.assertEqual(logging_handler_instances, []) + + def test_handler_code_attributes_is_controlled_by_instrumentor_parameter( + self, + ): + LoggingInstrumentor().uninstrument() + with mock.patch( + "opentelemetry.instrumentation.logging._setup_logging_handler" + ) as setup_mock: + LoggingInstrumentor().instrument(log_code_attributes=True) + + logger_provider = get_logger_provider() + setup_mock.assert_called_once_with( + logger_provider=logger_provider, log_code_attributes=True + ) diff --git a/tox.ini b/tox.ini index d1989a834b..350584e85a 100644 --- a/tox.ini +++ b/tox.ini @@ -211,6 +211,7 @@ envlist = py3{9,10,11,12,13,14}-test-instrumentation-logging pypy3-test-instrumentation-logging lint-instrumentation-logging + benchmark-instrumentation-logging ; opentelemetry-exporter-richconsole py3{9,10,11,12,13,14}-test-exporter-richconsole @@ -685,6 +686,7 @@ deps = logging: {[testenv]test_deps} logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt + benchmark-instrumentation-logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt aiohttp-client: {[testenv]test_deps} aiohttp-client: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-aiohttp-client/test-requirements.txt @@ -876,6 +878,7 @@ commands = test-instrumentation-logging: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/tests {posargs} lint-instrumentation-logging: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-logging" + benchmark-instrumentation-logging: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmarks {posargs} --benchmark-json=instrumentation-logging-benchmark.json test-instrumentation-mysql: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql/tests {posargs} lint-instrumentation-mysql: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-mysql" From 5fc58747383571c0a5a379ae6df2c0f7adca3ebb Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Tue, 3 Mar 2026 03:24:35 -0800 Subject: [PATCH 21/41] OpenAI v2 onboard onto semantic conventions 1.37.0: chat history and other breaking changes (#3715) * Support latest experimental conventions in openai * format * changelog * lint * lint * update to use new test utils, fix tests * lint * review * address feedback * review * feedback: more readable --- .../CHANGELOG.md | 4 + .../README.rst | 27 +- .../examples/manual/.env | 19 +- .../examples/manual/README.rst | 3 +- .../examples/zero-code/.env | 19 +- .../examples/zero-code/README.rst | 3 +- .../pyproject.toml | 3 +- .../instrumentation/openai_v2/__init__.py | 53 +- .../instrumentation/openai_v2/patch.py | 516 ++++-- .../instrumentation/openai_v2/utils.py | 245 ++- ...nc_chat_completion_with_raw_response.yaml} | 0 ...est_embeddings_with_not_given_values.yaml} | 0 ...th_not_given_values[not_given_value1].yaml | 124 -- .../tests/conftest.py | 72 +- .../tests/requirements.latest.txt | 1 + .../tests/requirements.oldest.txt | 1 + .../tests/test_async_chat_completions.py | 1424 ++++++++++------ .../tests/test_async_embeddings.py | 191 ++- .../tests/test_chat_completions.py | 1497 ++++++++++------- .../tests/test_chat_metrics.py | 98 +- .../tests/test_embeddings.py | 214 +-- .../tests/test_utils.py | 186 +- uv.lock | 2 + 23 files changed, 3048 insertions(+), 1654 deletions(-) rename instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/{test_async_chat_completion_with_raw_repsonse.yaml => test_async_chat_completion_with_raw_response.yaml} (100%) rename instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/{test_embeddings_with_not_given_values[not_given_value0].yaml => test_embeddings_with_not_given_values.yaml} (100%) delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value1].yaml diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md index 734171f6ab..9132163d9b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `StreamWrapper` missing `.headers` and other attributes when using `with_raw_response` streaming ([#4113](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4113)) +- Add opt-in support for latest experimental semantic conventions (v1.37.0). Set + `OTEL_SEMCONV_STABILITY_OPT_IN` to `gen_ai_latest_experimental` to enable. + Add dependency on `opentelemetry-util-genai` pypi package. + ([#3715](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3715)) ## Version 2.3b0 (2025-12-24) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst index 1cd3a51b07..8cf82f4261 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst @@ -19,7 +19,7 @@ Many LLM platforms support the OpenAI SDK. This means systems such as the follow * - Name - gen_ai.system * - `Azure OpenAI `_ - - ``az.ai.openai`` + - ``azure.ai.openai`` * - `Gemini `_ - ``gemini`` * - `Perplexity `_ @@ -75,7 +75,7 @@ Make sure to configure OpenTelemetry tracing, logging, and events to capture all {"role": "user", "content": "Write a short poem on open telemetry."}, ], ) - + # Embeddings example embedding_response = client.embeddings.create( model="text-embedding-3-small", @@ -87,7 +87,28 @@ Enabling message content Message content such as the contents of the prompt, completion, function arguments and return values are not captured by default. To capture message content as log events, set the environment variable -`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to `true`. +``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` to one of the following values: + +- ``true`` - Legacy. Used to enable content capturing on ``gen_ai.{role}.message`` and ``gen_ai.choice`` events when + `latest experimental features <#enabling-the-latest-experimental-features>`_ are *not* enabled. +- ``span_only`` - Used to enable content capturing on *span* attributes when + `latest experimental features <#enabling-the-latest-experimental-features>`_ are enabled. +- ``event_only`` - Used to enable content capturing on *event* attributes when + `latest experimental features <#enabling-the-latest-experimental-features>`_ are enabled. +- ``span_and_event`` - Used to enable content capturing on both *span* and *event* attributes when + `latest experimental features <#enabling-the-latest-experimental-features>`_ are enabled. + +Enabling the latest experimental features +*********************************************** + +To enable the latest experimental features, set the environment variable +``OTEL_SEMCONV_STABILITY_OPT_IN`` to ``gen_ai_latest_experimental``. Or, if you use +``OTEL_SEMCONV_STABILITY_OPT_IN`` to enable other features, append ``,gen_ai_latest_experimental`` to its value. + +Without this setting, OpenAI instrumentation aligns with `Semantic Conventions v1.30.0 `_ +and would not capture additional details introduced in later versions. + +.. note:: Generative AI semantic conventions are still evolving. The latest experimental features will introduce breaking changes in future releases. Uninstrument ************ diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/.env index 1e77ee78c0..044f393ced 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/.env @@ -12,5 +12,20 @@ OPENAI_API_KEY=sk-YOUR_API_KEY OTEL_SERVICE_NAME=opentelemetry-python-openai -# Change to 'false' to hide prompt and completion content -OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +# Remove to hide prompt and completion content +# Possible values (case insensitive): +# - `span_only` - record content on span attibutes +# - `event_only` - record content on event attributes +# - `span_and_event` - record content on both span and event attributes +# - `true` - only used for backward compatibility when +# `gen_ai_latest_experimental` is not set in the +# `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. +# - everything else - don't record content on any signal +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only + +# Enables latest and greatest features available in GenAI semantic conventions. +# Note: since conventions are still in development, using this flag would +# likely result in having breaking changes. +# +# Comment out if you want to use semantic conventions of version 1.30.0. +OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/README.rst index 61e4c4ae8e..5aa93c8728 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/manual/README.rst @@ -11,7 +11,8 @@ your OpenAI requests. Note: `.env <.env>`_ file configures additional environment variables: -- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` configures OpenAI instrumentation to capture prompt and completion contents on events. +- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only`` configures OpenAI instrumentation to capture prompt and completion contents on *span* attributes. +- ``OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`` enables latest experimental features. Setup ----- diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env index ab64ccd5f3..97f3d1100f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/.env @@ -15,5 +15,20 @@ OTEL_SERVICE_NAME=opentelemetry-python-openai # Uncomment if your OTLP endpoint doesn't support logs # OTEL_LOGS_EXPORTER=console -# Change to 'false' to hide prompt and completion content -OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +# Remove to hide prompt and completion content +# Possible values (case insensitive): +# - `span_only` - record content on span attibutes +# - `event_only` - record content on event attributes +# - `span_and_event` - record content on both span and event attributes +# - `true` - only used for backward compatibility when +# `gen_ai_latest_experimental` is not set in the +# `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. +# - everything else - don't record content on any signal +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only + +# Enables latest and greatest features available in GenAI semantic conventions. +# Note: since conventions are still in development, using this flag would +# likely result in having breaking changes. +# +# Comment out if you want to use semantic conventions of version 1.30.0. +OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst index 1498fc75de..99562d06ec 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/examples/zero-code/README.rst @@ -12,8 +12,9 @@ your OpenAI requests. Note: `.env <.env>`_ file configures additional environment variables: -- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` configures OpenAI instrumentation to capture prompt and completion contents on events. +- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only`` configures OpenAI instrumentation to capture prompt and completion contents on *span* attributes. - ``OTEL_LOGS_EXPORTER=otlp`` to specify exporter type. +- ``OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`` enables latest experimental features. Setup ----- diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml index 49d8b62302..fc5939985b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml @@ -28,7 +28,8 @@ classifiers = [ dependencies = [ "opentelemetry-api ~= 1.37", "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0" + "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-util-genai", ] [project.optional-dependencies] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py index 4bb06574ba..e959083751 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py @@ -52,12 +52,22 @@ from opentelemetry.metrics import get_meter from opentelemetry.semconv.schemas import Schemas from opentelemetry.trace import get_tracer +from opentelemetry.util.genai.handler import ( + TelemetryHandler, +) +from opentelemetry.util.genai.types import ContentCapturingMode +from opentelemetry.util.genai.utils import ( + get_content_capturing_mode, + is_experimental_mode, +) from .instruments import Instruments from .patch import ( - async_chat_completions_create, + async_chat_completions_create_v_new, + async_chat_completions_create_v_old, async_embeddings_create, - chat_completions_create, + chat_completions_create_v_new, + chat_completions_create_v_old, embeddings_create, ) @@ -71,43 +81,64 @@ def instrumentation_dependencies(self) -> Collection[str]: def _instrument(self, **kwargs): """Enable OpenAI instrumentation.""" + + latest_experimental_enabled = is_experimental_mode() tracer_provider = kwargs.get("tracer_provider") tracer = get_tracer( __name__, "", tracer_provider, - schema_url=Schemas.V1_30_0.value, + schema_url=Schemas.V1_30_0.value, # only used on the legacy path ) logger_provider = kwargs.get("logger_provider") logger = get_logger( __name__, "", - schema_url=Schemas.V1_30_0.value, logger_provider=logger_provider, + schema_url=Schemas.V1_30_0.value, # only used on the legacy path ) meter_provider = kwargs.get("meter_provider") self._meter = get_meter( __name__, "", meter_provider, - schema_url=Schemas.V1_30_0.value, + schema_url=Schemas.V1_30_0.value, # only used on the legacy path ) instruments = Instruments(self._meter) + content_mode = ( + get_content_capturing_mode() + if latest_experimental_enabled + else ContentCapturingMode.NO_CONTENT + ) + handler = TelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + wrap_function_wrapper( module="openai.resources.chat.completions", name="Completions.create", - wrapper=chat_completions_create( - tracer, logger, instruments, is_content_enabled() + wrapper=( + chat_completions_create_v_new(handler, content_mode) + if latest_experimental_enabled + else chat_completions_create_v_old( + tracer, logger, instruments, is_content_enabled() + ) ), ) wrap_function_wrapper( module="openai.resources.chat.completions", name="AsyncCompletions.create", - wrapper=async_chat_completions_create( - tracer, logger, instruments, is_content_enabled() + wrapper=( + async_chat_completions_create_v_new(handler, content_mode) + if latest_experimental_enabled + else async_chat_completions_create_v_old( + tracer, logger, instruments, is_content_enabled() + ) ), ) @@ -116,7 +147,7 @@ def _instrument(self, **kwargs): module="openai.resources.embeddings", name="Embeddings.create", wrapper=embeddings_create( - tracer, instruments, is_content_enabled() + tracer, instruments, latest_experimental_enabled ), ) @@ -124,7 +155,7 @@ def _instrument(self, **kwargs): module="openai.resources.embeddings", name="AsyncEmbeddings.create", wrapper=async_embeddings_create( - tracer, instruments, is_content_enabled() + tracer, instruments, latest_experimental_enabled ), ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py index 1543d1ab79..3bc42f103c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py @@ -13,6 +13,7 @@ # limitations under the License. +import json from timeit import default_timer from typing import Any, Optional @@ -23,15 +24,29 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) from opentelemetry.trace import Span, SpanKind, Tracer from opentelemetry.trace.propagation import set_span_in_context +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import ( + ContentCapturingMode, + Error, + LLMInvocation, + OutputMessage, + Text, + ToolCall, +) from .instruments import Instruments from .utils import ( + _prepare_output_messages, choice_to_event, + create_chat_invocation, get_llm_request_attributes, handle_span_exception, is_streaming, @@ -40,7 +55,7 @@ ) -def chat_completions_create( +def chat_completions_create_v_old( tracer: Tracer, logger: Logger, instruments: Instruments, @@ -49,7 +64,9 @@ def chat_completions_create( """Wrap the `create` method of the `ChatCompletion` class to trace it.""" def traced_method(wrapped, instance, args, kwargs): - span_attributes = {**get_llm_request_attributes(kwargs, instance)} + span_attributes = { + **get_llm_request_attributes(kwargs, instance, False) + } span_name = f"{span_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME]} {span_attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]}" with tracer.start_as_current_span( @@ -72,14 +89,12 @@ def traced_method(wrapped, instance, args, kwargs): else: parsed_result = result if is_streaming(kwargs): - return StreamWrapper( + return LegacyChatStreamWrapper( parsed_result, span, logger, capture_content ) if span.is_recording(): - _set_response_attributes( - span, parsed_result, logger, capture_content - ) + _set_response_attributes(span, parsed_result) for choice in getattr(parsed_result, "choices", []): logger.emit(choice_to_event(choice, capture_content)) @@ -104,7 +119,48 @@ def traced_method(wrapped, instance, args, kwargs): return traced_method -def async_chat_completions_create( +def chat_completions_create_v_new( + handler: TelemetryHandler, + content_capturing_mode: ContentCapturingMode, +): + """Wrap the `create` method of the `ChatCompletion` class to trace it.""" + + capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT + + def traced_method(wrapped, instance, args, kwargs): + chat_invocation = handler.start_llm( + create_chat_invocation( + kwargs, instance, capture_content=capture_content + ) + ) + + try: + result = wrapped(*args, **kwargs) + if hasattr(result, "parse"): + # result is of type LegacyAPIResponse, call parse to get the actual response + parsed_result = result.parse() + else: + parsed_result = result + if is_streaming(kwargs): + return ChatStreamWrapper( + parsed_result, handler, chat_invocation, capture_content + ) + + _set_response_properties( + chat_invocation, parsed_result, capture_content + ) + handler.stop_llm(chat_invocation) + return result + except Exception as error: + handler.fail_llm( + chat_invocation, Error(type=type(error), message=str(error)) + ) + raise + + return traced_method + + +def async_chat_completions_create_v_old( tracer: Tracer, logger: Logger, instruments: Instruments, @@ -113,7 +169,9 @@ def async_chat_completions_create( """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" async def traced_method(wrapped, instance, args, kwargs): - span_attributes = {**get_llm_request_attributes(kwargs, instance)} + span_attributes = { + **get_llm_request_attributes(kwargs, instance, False) + } span_name = f"{span_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME]} {span_attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]}" with tracer.start_as_current_span( @@ -136,14 +194,12 @@ async def traced_method(wrapped, instance, args, kwargs): else: parsed_result = result if is_streaming(kwargs): - return StreamWrapper( + return LegacyChatStreamWrapper( parsed_result, span, logger, capture_content ) if span.is_recording(): - _set_response_attributes( - span, parsed_result, logger, capture_content - ) + _set_response_attributes(span, parsed_result) for choice in getattr(parsed_result, "choices", []): logger.emit(choice_to_event(choice, capture_content)) @@ -168,10 +224,51 @@ async def traced_method(wrapped, instance, args, kwargs): return traced_method +def async_chat_completions_create_v_new( + handler: TelemetryHandler, + content_capturing_mode: ContentCapturingMode, +): + """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" + capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT + + async def traced_method(wrapped, instance, args, kwargs): + chat_invocation = handler.start_llm( + create_chat_invocation( + kwargs, instance, capture_content=capture_content + ) + ) + + try: + result = await wrapped(*args, **kwargs) + if hasattr(result, "parse"): + # result is of type LegacyAPIResponse, calling parse to get the actual response + parsed_result = result.parse() + else: + parsed_result = result + if is_streaming(kwargs): + return ChatStreamWrapper( + parsed_result, handler, chat_invocation, capture_content + ) + + _set_response_properties( + chat_invocation, parsed_result, capture_content + ) + handler.stop_llm(chat_invocation) + return result + + except Exception as error: + handler.fail_llm( + chat_invocation, Error(type=type(error), message=str(error)) + ) + raise + + return traced_method + + def embeddings_create( tracer: Tracer, instruments: Instruments, - capture_content: bool, + latest_experimental_enabled: bool, ): """Wrap the `create` method of the `Embeddings` class to trace it.""" @@ -179,10 +276,10 @@ def traced_method(wrapped, instance, args, kwargs): span_attributes = get_llm_request_attributes( kwargs, instance, + latest_experimental_enabled, GenAIAttributes.GenAiOperationNameValues.EMBEDDINGS.value, ) span_name = _get_embeddings_span_name(span_attributes) - input_text = kwargs.get("input", "") with tracer.start_as_current_span( name=span_name, @@ -198,9 +295,7 @@ def traced_method(wrapped, instance, args, kwargs): result = wrapped(*args, **kwargs) if span.is_recording(): - _set_embeddings_response_attributes( - span, result, capture_content, input_text - ) + _set_embeddings_response_attributes(span, result) return result @@ -226,7 +321,7 @@ def traced_method(wrapped, instance, args, kwargs): def async_embeddings_create( tracer: Tracer, instruments: Instruments, - capture_content: bool, + latest_experimental_enabled: bool, ): """Wrap the `create` method of the `AsyncEmbeddings` class to trace it.""" @@ -234,10 +329,10 @@ async def traced_method(wrapped, instance, args, kwargs): span_attributes = get_llm_request_attributes( kwargs, instance, + latest_experimental_enabled, GenAIAttributes.GenAiOperationNameValues.EMBEDDINGS.value, ) span_name = _get_embeddings_span_name(span_attributes) - input_text = kwargs.get("input", "") with tracer.start_as_current_span( name=span_name, @@ -253,9 +348,7 @@ async def traced_method(wrapped, instance, args, kwargs): result = await wrapped(*args, **kwargs) if span.is_recording(): - _set_embeddings_response_attributes( - span, result, capture_content, input_text - ) + _set_embeddings_response_attributes(span, result) return result @@ -316,9 +409,9 @@ def _record_metrics( ] = result.service_tier if result and getattr(result, "system_fingerprint", None): - common_attributes["gen_ai.openai.response.system_fingerprint"] = ( - result.system_fingerprint - ) + common_attributes[ + GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SYSTEM_FINGERPRINT + ] = result.system_fingerprint if ServerAttributes.SERVER_ADDRESS in request_attributes: common_attributes[ServerAttributes.SERVER_ADDRESS] = ( @@ -360,9 +453,7 @@ def _record_metrics( ) -def _set_response_attributes( - span, result, logger: Logger, capture_content: bool -): +def _set_response_attributes(span, result): if getattr(result, "model", None): set_span_attribute( span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, result.model @@ -403,11 +494,61 @@ def _set_response_attributes( ) +def _set_response_properties( + chat_invocation: LLMInvocation, result, capture_content: bool +) -> LLMInvocation: + if getattr(result, "model", None): + chat_invocation.response_model_name = result.model + + if getattr(result, "choices", None): + finish_reasons = [] + for choice in result.choices: + finish_reasons.append(choice.finish_reason or "error") + + chat_invocation.finish_reasons = finish_reasons + + if capture_content: # optimization + chat_invocation.output_messages = _prepare_output_messages( + result.choices + ) + + if getattr(result, "id", None): + chat_invocation.response_id = result.id + + if getattr(result, "service_tier", None): + chat_invocation.attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER: result.service_tier + }, + ) + chat_invocation.metric_attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER: result.service_tier + }, + ) + + if getattr(result, "usage", None): + chat_invocation.input_tokens = result.usage.prompt_tokens + chat_invocation.output_tokens = result.usage.completion_tokens + + if getattr(result, "system_fingerprint", None): + chat_invocation.attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SYSTEM_FINGERPRINT: result.system_fingerprint + }, + ) + chat_invocation.metric_attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SYSTEM_FINGERPRINT: result.system_fingerprint + }, + ) + + return chat_invocation + + def _set_embeddings_response_attributes( span: Span, result: Any, - capture_content: bool, - input_text: str, ): set_span_attribute( span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, result.model @@ -469,8 +610,7 @@ def append_tool_call(self, tool_call): ) -class StreamWrapper: - span: Span +class BaseStreamWrapper: response_id: Optional[str] = None response_model: Optional[str] = None service_tier: Optional[str] = None @@ -481,127 +621,37 @@ class StreamWrapper: def __init__( self, stream: Stream, - span: Span, - logger: Logger, capture_content: bool, ): self.stream = stream - self.span = span self.choice_buffers = [] - self._span_started = False + self._started = False self.capture_content = capture_content + self._setup() - self.logger = logger - self.setup() - - def setup(self): - if not self._span_started: - self._span_started = True - - def cleanup(self): - if self._span_started: - if self.span.is_recording(): - if self.response_model: - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_RESPONSE_MODEL, - self.response_model, - ) - - if self.response_id: - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_RESPONSE_ID, - self.response_id, - ) - - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, - self.prompt_tokens, - ) - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, - self.completion_tokens, - ) - - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER, - self.service_tier, - ) - - set_span_attribute( - self.span, - GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, - self.finish_reasons, - ) - - for idx, choice in enumerate(self.choice_buffers): - message = {"role": "assistant"} - if self.capture_content and choice.text_content: - message["content"] = "".join(choice.text_content) - if choice.tool_calls_buffers: - tool_calls = [] - for tool_call in choice.tool_calls_buffers: - function = {"name": tool_call.function_name} - if self.capture_content: - function["arguments"] = "".join( - tool_call.arguments - ) - tool_call_dict = { - "id": tool_call.tool_call_id, - "type": "function", - "function": function, - } - tool_calls.append(tool_call_dict) - message["tool_calls"] = tool_calls - - body = { - "index": idx, - "finish_reason": choice.finish_reason or "error", - "message": message, - } - - event_attributes = { - GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value - } - context = set_span_in_context(self.span, get_current()) - self.logger.emit( - LogRecord( - event_name="gen_ai.choice", - attributes=event_attributes, - body=body, - context=context, - ) - ) + def _setup(self): + if not self._started: + self._started = True - self.span.end() - self._span_started = False + def cleanup(self, error: Optional[BaseException] = None): + pass def __enter__(self): - self.setup() + self._setup() return self def __exit__(self, exc_type, exc_val, exc_tb): - try: - if exc_type is not None: - handle_span_exception(self.span, exc_val) - finally: - self.cleanup() + error = exc_val if exc_type else None + self.cleanup(error) return False # Propagate the exception async def __aenter__(self): - self.setup() + self._setup() return self async def __aexit__(self, exc_type, exc_val, exc_tb): - try: - if exc_type is not None: - handle_span_exception(self.span, exc_val) - finally: - self.cleanup() + error = exc_val if exc_type else None + self.cleanup(error) return False # Propagate the exception def close(self): @@ -623,8 +673,7 @@ def __next__(self): self.cleanup() raise except Exception as error: - handle_span_exception(self.span, error) - self.cleanup() + self.cleanup(error) raise async def __anext__(self): @@ -636,8 +685,7 @@ async def __anext__(self): self.cleanup() raise except Exception as error: - handle_span_exception(self.span, error) - self.cleanup() + self.cleanup(error) raise def set_response_model(self, chunk): @@ -708,3 +756,193 @@ def __getattr__(self, name): def parse(self): """Called when using with_raw_response with stream=True""" return self + + +class LegacyChatStreamWrapper(BaseStreamWrapper): + span: Span + response_id: Optional[str] = None + response_model: Optional[str] = None + service_tier: Optional[str] = None + finish_reasons: list = [] + prompt_tokens: Optional[int] = 0 + completion_tokens: Optional[int] = 0 + + def __init__( + self, + stream: Stream, + span: Span, + logger: Logger, + capture_content: bool, + ): + super().__init__(stream, capture_content=capture_content) + self.span = span + self.logger = logger + + def cleanup(self, error: Optional[BaseException] = None): + if not self._started: + return + if self.span.is_recording(): + if self.response_model: + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_RESPONSE_MODEL, + self.response_model, + ) + + if self.response_id: + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_RESPONSE_ID, + self.response_id, + ) + + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, + self.prompt_tokens, + ) + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, + self.completion_tokens, + ) + if self.service_tier: + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER, + self.service_tier, + ) + + set_span_attribute( + self.span, + GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, + self.finish_reasons, + ) + + for idx, choice in enumerate(self.choice_buffers): + message = {"role": "assistant"} + if self.capture_content and choice.text_content: + message["content"] = "".join(choice.text_content) + if choice.tool_calls_buffers: + tool_calls = [] + for tool_call in choice.tool_calls_buffers: + function = {"name": tool_call.function_name} + if self.capture_content: + function["arguments"] = "".join(tool_call.arguments) + tool_call_dict = { + "id": tool_call.tool_call_id, + "type": "function", + "function": function, + } + tool_calls.append(tool_call_dict) + message["tool_calls"] = tool_calls + + body = { + "index": idx, + "finish_reason": choice.finish_reason or "error", + "message": message, + } + + event_attributes = { + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value + } + context = set_span_in_context(self.span, get_current()) + self.logger.emit( + LogRecord( + event_name="gen_ai.choice", + attributes=event_attributes, + body=body, + context=context, + ) + ) + + if error: + handle_span_exception(self.span, error) + else: + self.span.end() + self._started = False + + +class ChatStreamWrapper(BaseStreamWrapper): + handler: TelemetryHandler + invocation: LLMInvocation + response_id: Optional[str] = None + response_model: Optional[str] = None + service_tier: Optional[str] = None + finish_reasons: list = [] + prompt_tokens: Optional[int] = None + completion_tokens: Optional[int] = None + + def __init__( + self, + stream: Stream, + handler: TelemetryHandler, + invocation: LLMInvocation, + capture_content: bool, + ): + super().__init__(stream, capture_content=capture_content) + self.stream = stream + self.handler = handler + self.invocation = invocation + self.choice_buffers = [] + + def _set_output_messages(self): + if not self.capture_content: # optimization + return + output_messages = [] + for choice in self.choice_buffers: + message = OutputMessage( + role="assistant", + finish_reason=choice.finish_reason or "error", + parts=[], + ) + if choice.text_content: + message.parts.append( + Text(content="".join(choice.text_content)) + ) + if choice.tool_calls_buffers: + tool_calls = [] + for tool_call in choice.tool_calls_buffers: + arguments = None + arguments_str = "".join(tool_call.arguments) + if arguments_str: + try: + arguments = json.loads(arguments_str) + except json.JSONDecodeError: + arguments = arguments_str + tool_call_part = ToolCall( + name=tool_call.function_name, + id=tool_call.tool_call_id, + arguments=arguments, + ) + tool_calls.append(tool_call_part) + message.parts.extend(tool_calls) + output_messages.append(message) + + self.invocation.output_messages = output_messages + + def cleanup(self, error: Optional[BaseException] = None): + if not self._started: + return + + self.invocation.response_model_name = self.response_model + self.invocation.response_id = self.response_id + self.invocation.input_tokens = self.prompt_tokens + self.invocation.output_tokens = self.completion_tokens + self.invocation.finish_reasons = self.finish_reasons + if self.service_tier: + self.invocation.attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER: self.service_tier + }, + ) + + self._set_output_messages() + + if error: + self.handler.fail_llm( + self.invocation, Error(type=type(error), message=str(error)) + ) + else: + self.handler.stop_llm(self.invocation) + self._started = False 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 6e3ebad2ed..7e5d2307ca 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 @@ -14,8 +14,9 @@ from __future__ import annotations +import json from os import environ -from typing import Mapping +from typing import Any, Iterable, List, Mapping from urllib.parse import urlparse from httpx import URL @@ -25,6 +26,9 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) @@ -32,6 +36,14 @@ error_attributes as ErrorAttributes, ) from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util.genai.types import ( + InputMessage, + LLMInvocation, + OutputMessage, + Text, + ToolCall, + ToolCallResponse, +) OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = ( "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" @@ -80,23 +92,27 @@ def extract_tool_calls(item, capture_content): return calls -def set_server_address_and_port(client_instance, attributes): +def get_server_address_and_port( + client_instance, +) -> tuple[str | None, int | None]: base_client = getattr(client_instance, "_client", None) base_url = getattr(base_client, "base_url", None) if not base_url: - return - - port = -1 + return None, None + address = None + port = None if isinstance(base_url, URL): - attributes[ServerAttributes.SERVER_ADDRESS] = base_url.host + address = base_url.host port = base_url.port elif isinstance(base_url, str): url = urlparse(base_url) - attributes[ServerAttributes.SERVER_ADDRESS] = url.hostname + address = url.hostname port = url.port - if port and port != 443 and port > 0: - attributes[ServerAttributes.SERVER_PORT] = port + if port == 443: + port = None + + return address, port def get_property_value(obj, property_name): @@ -192,16 +208,31 @@ def value_is_set(value): def get_llm_request_attributes( kwargs, client_instance, + latest_experimental_enabled, 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, GenAIAttributes.GEN_AI_REQUEST_MODEL: kwargs.get("model"), } + if latest_experimental_enabled: + attributes.update( + { + GenAIAttributes.GEN_AI_PROVIDER_NAME: ( + GenAIAttributes.GenAiProviderNameValues.OPENAI.value + ), + } + ) + else: + attributes.update( + { + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiProviderNameValues.OPENAI.value, + } + ) + # Add chat-specific attributes only for chat operations if operation_name == GenAIAttributes.GenAiOperationNameValues.CHAT.value: attributes.update( @@ -238,19 +269,22 @@ def get_llm_request_attributes( stop_sequences ) + request_response_format_attr_key = ( + GenAIAttributes.GEN_AI_OUTPUT_TYPE + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT + ) 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): if ( response_format_type := response_format.get("type") ) is not None: - attributes[ - GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT - ] = response_format_type + attributes[request_response_format_attr_key] = ( + response_format_type + ) else: - attributes[ - GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT - ] = response_format + attributes[request_response_format_attr_key] = response_format # service_tier can be passed directly or in extra_body (in SDK 1.26.0 it's via extra_body) service_tier = kwargs.get("service_tier") @@ -258,7 +292,13 @@ def get_llm_request_attributes( extra_body = kwargs.get("extra_body") if isinstance(extra_body, Mapping): service_tier = extra_body.get("service_tier") - attributes[GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER] = ( + + request_service_tier_attr_key = ( + OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER + ) + attributes[request_service_tier_attr_key] = ( service_tier if service_tier != "auto" else None ) @@ -278,16 +318,183 @@ def get_llm_request_attributes( kwargs["encoding_format"] ] - set_server_address_and_port(client_instance, attributes) + address, port = get_server_address_and_port(client_instance) + if address: + attributes[ServerAttributes.SERVER_ADDRESS] = address + if port: + attributes[ServerAttributes.SERVER_PORT] = port # filter out values not set return {k: v for k, v in attributes.items() if value_is_set(v)} -def handle_span_exception(span, error): +def create_chat_invocation( + kwargs, + client_instance, + capture_content: bool, +) -> LLMInvocation: + # pylint: disable=too-many-branches + + llm_invocation = LLMInvocation(request_model=kwargs.get("model", "")) + llm_invocation.provider = ( + GenAIAttributes.GenAiProviderNameValues.OPENAI.value + ) + llm_invocation.temperature = get_value(kwargs.get("temperature")) + llm_invocation.top_p = get_value(kwargs.get("p") or kwargs.get("top_p")) + llm_invocation.max_tokens = get_value(kwargs.get("max_tokens")) + llm_invocation.presence_penalty = get_value(kwargs.get("presence_penalty")) + llm_invocation.frequency_penalty = get_value( + kwargs.get("frequency_penalty") + ) + llm_invocation.seed = get_value(kwargs.get("seed")) + if (stop_sequences := get_value(kwargs.get("stop"))) is not None: + if isinstance(stop_sequences, str): + stop_sequences = [stop_sequences] + llm_invocation.stop_sequences = stop_sequences + + address, port = get_server_address_and_port(client_instance) + if address: + llm_invocation.server_address = address + if port: + llm_invocation.server_port = port + + attributes = {} + if (choice_count := get_value(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 ( + response_format := get_value(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): + if ( + response_format_type := get_value(response_format.get("type")) + ) is not None: + attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = ( + response_format_type + ) + else: + attributes[ + GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT + ] = response_format + + # service_tier can be passed directly or in extra_body (in SDK 1.26.0 it's via extra_body) + service_tier = get_value(kwargs.get("service_tier")) + if service_tier is None: + extra_body = get_value(kwargs.get("extra_body")) + if isinstance(extra_body, Mapping): + service_tier = get_value(extra_body.get("service_tier")) + if service_tier is not None: + attributes[OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER] = service_tier + + if len(attributes) > 0: + llm_invocation.attributes = attributes + + if capture_content: # optimization + llm_invocation.input_messages = _prepare_input_messages( + kwargs.get("messages", []) + ) + return llm_invocation + + +def get_value(v: Any): + if value_is_set(v): + return v + return None + + +def handle_span_exception(span, error: BaseException): span.set_status(Status(StatusCode.ERROR, str(error))) if span.is_recording(): span.set_attribute( ErrorAttributes.ERROR_TYPE, type(error).__qualname__ ) span.end() + + +def _is_text_part(content: Any) -> bool: + return isinstance(content, str) or ( + isinstance(content, Iterable) + and all(isinstance(part, str) for part in content) + ) + + +def _prepare_input_messages(messages) -> List[InputMessage]: + chat_messages = [] + for message in messages: + role = get_property_value(message, "role") + chat_message = InputMessage(role=str(role), parts=[]) + chat_messages.append(chat_message) + + content = get_property_value(message, "content") + + if role == "assistant": + tool_calls = get_property_value(message, "tool_calls") + if tool_calls: + chat_message.parts += extract_tool_calls_new(tool_calls) + if _is_text_part(content): + chat_message.parts.append(Text(content=str(content))) + + elif role == "tool": + tool_call_id = get_property_value(message, "tool_call_id") + chat_message.parts.append( + ToolCallResponse(id=tool_call_id, response=content) + ) + + else: + # system, developer, user, fallback + if _is_text_part(content): + chat_message.parts.append(Text(content=str(content))) + return chat_messages + + +def extract_tool_calls_new(tool_calls) -> list[ToolCall]: + parts = [] + for tool_call in tool_calls: + call_id = get_property_value(tool_call, "id") + + func_name = "" + arguments = None + func = get_property_value(tool_call, "function") + if func: + func_name = get_property_value(func, "name") or "" + arguments_str = get_property_value(func, "arguments") + if arguments_str: + try: + arguments = json.loads(arguments_str) + except json.JSONDecodeError: + arguments = arguments_str + + # TODO: support custom + parts.append(ToolCall(id=call_id, name=func_name, arguments=arguments)) + return parts + + +def _prepare_output_messages(choices) -> List[OutputMessage]: + output_messages = [] + for choice in choices: + if choice.message: + parts = [] + tool_calls = get_property_value(choice.message, "tool_calls") + if tool_calls: + parts += extract_tool_calls_new(tool_calls) + content = get_property_value(choice.message, "content") + if _is_text_part(content): + parts.append(Text(content=str(content))) + + message = OutputMessage( + finish_reason=choice.finish_reason or "error", + role=( + choice.message.role + if choice.message and choice.message.role + else "" + ), + parts=parts, + ) + output_messages.append(message) + + return output_messages diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_async_chat_completion_with_raw_repsonse.yaml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_async_chat_completion_with_raw_response.yaml similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_async_chat_completion_with_raw_repsonse.yaml rename to instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_async_chat_completion_with_raw_response.yaml diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value0].yaml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values.yaml similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value0].yaml rename to instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values.yaml diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value1].yaml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value1].yaml deleted file mode 100644 index f8c9717b0c..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/cassettes/test_embeddings_with_not_given_values[not_given_value1].yaml +++ /dev/null @@ -1,124 +0,0 @@ -interactions: -- request: - body: |- - { - "input": "This is a test for embeddings with encoding format", - "model": "text-embedding-3-small", - "encoding_format": "base64" - } - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate - authorization: - - Bearer test_openai_api_key - connection: - - keep-alive - content-length: - - '127' - 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-read-timeout: - - '600' - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.10.12 - method: POST - uri: https://api.openai.com/v1/embeddings - response: - body: - string: |- - { - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": "DK6fO8qEirxr7OE8SQSPvXpWIry659e8mJ5BPUKZiTxZdqM76WvmPDaVLT0xwge86TEXvU+lObzFhka8XkUfvNgvd7yb1d08OC0NuzMvyTshVB07MPgsvc7umDzYWhU9SjeBPBQdz7wB2Yu8gYuCPbZ9ST1078a8c4rZvDwyrrxnsRs8BRj8vNv2nrz3bA88HFZZvMXnCT2xSeC8Pp/vOuVimzyN/1K8kc5OvXAk9bvH6AA8UgsePWdMrrxvgQ69+Z8BvEE0HD2w4Ei9FbEEPUY9Zzyd0qq7ZbROvA6Azjx0Ho+8vbKpPITGyLsC3bW7mAMvPTXHKL3QIYu6mNRmvNYnozx078a6l295vQyuHz1frra6bYhrvP/bPj1O17Q8KCSQvJKcUzzXjBA9m9XduwBFVrwJSDs7RTk9vSS6gb2m2Tm8UXfoPL+6/bxyggW9mnDwOxBS/Tx6u4+94JMfvIIqv7xvgQ69/d7xvH1XGb08lxu9H7y9vBvtwTtOdnG8G1IvvJU4Xb06ZCm7+NUmPJg5VLzP8kI8y4i0u35fbbta2xC6arKSvGUVkjwU56m7Y0eNvT7ON7xc3Ae9C39Xu5drT7w4yJ88kmKEPVtEKLwLf1c86ZYEvdP3Y7ujDug7jf/SvHuJFLxWED89LyqovB2FIb1i6fy8IbkKulupFb0O5bu8UXdovOqaLrzeXIO8XNwHPRAYLj36o6u9gYsCvQjfo72zRi083S07vbmCaj1KbaY6oWsBvfaeCjz9Q1+8PgTdvKtHcjwVUMG9qqQLPQhEkTwW7308dLkhvAJ88jtoUFi9v+UbPGIUG73BHDi9H/LivEI4xrypEFY9MWHEu2N9MjmyQgM9NQF4PDsuhDrC6jy7kwFBvBfoID0tLds6o9QYvRVMF7x3VSu8PcqNvPM4przCu/S5kmIEvfVvwjqrqLW892yPvZJiBDvwAQq9mDnUvBAYLj3/2z68HBwKPCMmzLyLMU49mNRmPCGKwjycaRM9wuo8uwJCo7ypENY8r9yePQbiVjvgyUQ8cOolvXIhQj0B2Qu9CRIWPbkd/bzvCGe8tBAIPYTGyLxgsuA8Bd6sPFrbEL1BNBy9RzYKvdPBvrxkSze8e172PKPUmLzAGA48OMgfO3ZRgTv3ByI9fiUevYWQI7xS3FU8izHOvIbDlbyxSeA8nAhQPZg51DyuDpq9KvIUvAt/17zHuTi93sx3PUc2iryjOQa77GzdvNHvjztJo0s8HByKPE3TCrzGVEs7xFf+uyW+q7zT9+M8c+scvVDUAbqHYlI9nAhQu2AXTrx+X+08aRqzO/k+Pr31CtU8dO9GOuEy3DyS/ZY8W6kVvP48Ar1MP9W8c1AKve5pqrymqnE9YnmIPO02uDc6ms66l9C8PAKnkLya0bO6ptWPPFUMlbxMBYY8z1ewO0ptJrqD+EO9c8B+O0nZ8LzellI8L4+VPFWnJ70zL0k9oaGmvCpXgrz0axi7KvKUPK6pLL0xwoe9DuW7O43JLT3C5pK89j3HN052cbxbDgO8saojvfY5HTuoRnu7UXdovQJCIz3uaao8JLoBvbkdfTwTtDc80/fjO7GqIz3rZIm8XHcaPUQ1kzvLUg89FB1PPSj1R71rh/Q8ljGAvSW+Kz1XeVY9GVXiujsDZryx5HI8n2oKu51x5zw4Z1y9MPgsvDD4rDyrR3K8njtCPNBb2rpu7Vi8AnzyOeH8Nj1SQUO9kv2WPH28Bj0Ae/u8XqqMPPrdertGA5g8cyXsvPXULz20Slc9RTm9u6Q9sLwyKx+8RDUTvQwTjTyvd7E7qj+evOkG+bwQs8C8ogo+PWvs4TzIhz29TnJHPeaVjTvb9h49rq3WPBVMl7x7iZS7OfsRvGrot7xPQMw8/QmQPW+BDr0PFIQ8sniovEmjy7yqpAu9OAJvPKaq8bu24ja8Q6HdPIstpDx2wfW8DK4fvf1DXzw3/sQ8tXkfvI4yxbzChc872L+CvJ0MejsNfKS8eIgdOkOdMzmOkwg9IbkKPbewOz1WEL88M5Q2vYbDFb371h29BagHPA4b4Twbt5w8AQ8xPKcIgrxWdSw5h5h3PIpjSbq85KS86ALPvEai1DwvYE09SaPLPIJZB72xD5G6ua2IvGJOarxREnu9jpOIPN3IzbyhBhS7Qc8uPWVPYT2V/g086WvmPBGBRbxmfqm8Hx2BvO1lAD3S87m862SJPAp7LTveMWW56WvmvJloHDyuSOk8V3lWvFupFb3q/5u9WrDyO7lIm7x6VqK8CBlzPQsaarzP8kI7gYuCvRkbE71+igu8aOtqu1cU6TsVTJe8EeYyvfOdEz1iTuo8OMgfvDyXm7vTIgI8wksAPcK79LyhPDm9nXFnvJPLG72SnNO7xecJPQB7+zr3QfG8o3NVvKmraL0A4Oi8fI0+vYKPrDzwAQq9ecJsvLmCajy3FSk9KvKUvC33tTuhBpS91CYsvY4yxTzFvGs9e4mUvOJhJL02lS28EFL9PIHBJ7xhSsC8V3lWPSSPY7uP/B88b4GOPDP5o7yyQoM8oDgPvJdrz7zT9+O87mmqu+T5A73tZQC9gfv2uuZq77xzUAo+VnWsvE48Ir0yKx89HriTu6M5Br3vMwW8H1dQvWCyYLwVTBc8x1j1vJPLm7yBi4K8ZuMWPZ0M+ryMmmW8+zuLPFfewzxOoY8842n4PFQ+kDxAZhe9MV2avAcVybyY1OY7jJrlPIdi0rwB2Qu9iv5bPD83zzt/xNo7yoQKvSosZD1g4Si9DhvhvMa1jjy5fsC8zb9QvFZ1rLxFOb07ExmlPNVZnrzA6cW9CH7gvJeal7wYUbi8jf9SvGgWCT3YlGQ7aIZ9u6zbJ7zZ+dG7wYGlPDJlbjyOMkW8ukzFPO8IZ7wCfPI8MC5SvCAhq7t1vcs84saRPB3qjrnWwrW8CnutPPY5nTpjfbK8elaiPPgLTL3QvJ08FB3PPK2lAj1GPWe8O8kWvePKOztiTmo8c1CKPGKzVz3dkig9wOlFvTpkKT2mD986TAkwvIrINj0W7/28eyQnvYT87brnmTc8mnBwO4GLAr371h08KixkvKVwojyMmuW8A+HfO9sw7rtBNBw8xFd+PDb6GjxREvu86WvmOwbi1jw+BF28tn1JvHBPkzlOcsc80/djPNSLmbtmGTy9brezOw+vFjw+BN08CneDvNhaFb3mlQ09NGK7PDf+xDyKme68HFbZPLUUMjw3YzI9mDnUvC33NT2oRvs8O574uiGO7Lsjhw+8723UvPaeirsCeMg801zRuxyM/jvdkig7EkugPBbv/bugct47D6+WvGJO6rsGER88C39XO/pC6Dybnzg7VUK6O/M4pjwlIxk8CH5gvCGKwjsKdwM9SQQPvf1D37yH/eS7toHzvJ4FHbysrN88KcNMvSMmzLv3QfG42fnRPLQQiDxInyG7+AvMvBKwjTtyIUK8pabHu+DJRLyCj6w88qTwvOT5AzyF9ZC8bekuPHZRAb16u4+8wksAvKBy3rjHgxO9TAWGvKOp+jmuSGk6Ov+7vO02uDu5SJs8l5qXvZxpEzzqNcG8CnstPKM5hrwMSTK7zb9QPKHXS71GotQ8jJY7vexs3bxDZ447wksAvJX+Dby5HX28JPAmPEZohbwX6KC8RZqAPKaq8Tv/QCw9nZyFvC/FOruLLaS70e8Pvd3IzbwI36M8+t36uzZfiDzEHS89RAbLPPMCATymD187ak0lOyYnQzt3HwY9gSYVu64OGr3ezPe5PQAzvdkoGrzezPc8YrPXPH6KC7yWMYA7850TvP+lmTySYoQ8ZRWSPOtos7zn/qQ8Rj1nu7mtCLxm4xY8fI2+vFGiBrzO7pi776N5OmgWibyV02+8BRh8unwoUTv3ojS9st2VvFILHjydnIU8lzWqu1faGby651c8Oy4EPNJYJ7wO5Tu76wPGvC8qKLylcKK6o9hCO/nZUDwRHFg83vcVvaejFD0GER89JIs5vHmMRzwXg7O8gWBkvIBYEDzkM1O8tErXu4JZh7y3sLu8PzMlPePKO7y85KQ6H431PBNT9DuEJww9KFq1OfM8UDxfrja7c4rZO0TQpbwFfem7H/JiPKYP3zxxU707qEb7u1d51rt8KFE8FlRrPaBy3rzvo/m8gFiQPLewO73k+QM9K1ssPFytPzxQ1AE8wrv0vNLzObyRzk48NstSvBdNjjyUNDO8vB50vc2FAb01x6g79wciPe0Ak7vVWR48CnstvCos5LwJrai99weivIuSkTxcTPy80YoiPMlRmDy0gPy7Ca0oPNVZnrxasPI89NCFO1JBw7w7A+a4ntbUvHhdfzzRiqK8d1UrPbdLzrxUE/K8rq1WPMGBJbyNLhs85ceIPSfxnTye1lS9axuquVES+7uuc4e8QTQcvGNHDTuzq5q8F02OvCG5irwD4V88hl6oPOH8trycaRO8kv2WO4L0GT1/xFq6QwKhPGJO6rwJ4828GfB0vFN0NT01LJa7c8D+O4zFA7wCp5A83CkRvW2IazrkM1M5o6n6uI1kwLy5gmo83S07Pdn5UTy5Hf27WRE2vGsXAD3kmMC88qTwPA9KqTtUPpC7MmVuPA6287sl9FC8/UPfPBO0t7x+igu9mnBwPKULNbvuaSo9q0fyO2fnQL2ZzYm8p6MUPWnkDT3Zw6w6Z+dAPbBFNj0ovyI9rHa6uhYanDz3pl68x4OTO5X+jbxJo0u9v0oJvdkomrtAO3k8MWHEvIQnjLwuXKM8H411uBmAgLxOdnE8njtCveGXybo1kYO8yh8dPSOHjzz93nG8OGfcvKgMLL1n50C8RmgFPC2SyDzellK6dFS0OhJPyjsZVWI8MWHEO5pwcLxmfim9/QkQvEo3AbzEghw9G+1BPHcfBrtDPHC8BNqCPdbCtTxREvu88aBGvXAkdTqYA687v+UbOuGXyby8SZI8URJ7vAEPMT3szSC89p6KvQPh3zyWZ6U8uHqWu9z6SDy05Wm9IvPZu/UKVTzdyM28pnTMvHMl7LvH6IC8zfV1PLOrGr2/H+s8sniou5X+Db25fkC8dYemPGIUm7wTtDe8LfOLPAkSlrzeXIO7v0oJPOtoszwtksg80r0UOgp7LTyabMa87NFKvAsa6jxBoGa8IIaYvB8dAT1aFeC8tID8uxhROL2pq2g8wuq8O1gNDLtOdvE8jPuoPBSCvDuSYoQ8elaiPF9N8zrAsyA9qavoPEbYebqcCFA825XbORu3HD2eoC87xYZGvYb5Oru7Gso8J5DavG5SxjyBJpU78m5LPIn6sTuBxdG8bIAXu/emXjya0TM7oaGmvPemXj3S87k7GbrPO95cg7wtksi8tRQyu+zNoDzTIoI8Ov+7vInEjDxnTC482cMsPD6f7zwghpg7v0oJvf3ecTyPNm+9QaBmvEBmlzwB2Ys8WA2MuhtSrzyxSeC8KPXHu1Z1LDykoh08LyooPTJlbrzxzw47nQx6O/vWHb1I1UY7MsaxPEo3Ab1LOys8PcoNPGJO6rzcxCO9VD4QPQtFiLxX3kM87NFKOwh+YDySN+a8A+FfuiqRUbz52VC7KFo1PfRrGDz1b8I8+m0GPGuH9LvgLrK7LyoovSfGfzzGtQ48IIaYvGYZPLy6sTI9rkhpO0w/1TplsKQ8kpzTPAbiVr0Ae3s7aBYJPEo3gbzZ+dG8VnGCvE48IjxeSck88zxQPXOKWTuD+EO71vhau0KZCTuuSGm8a+xhPDqaTj0ZVWI7txWpvNb4Wrx5J9o8WrDyulfewzxjRw297ZulvH+ONTypq2g8QGYXO/pthryvQYy7fMNjvJrRs7xPpTm6WtsQPci94ruE/O28xeuzPKYP3ztiTuo8Qc8uvCS6gTwLGmq8cIU4vBVMlzzoY5I8Pp/vvOH4DL2AWBC8PzOluTWRAzzvo/k8ybYFvKM5Bj3eMeU76v8bOyS6gbxxUz27786Xu6mr6LwE2gK8SzsrvD2bxTzZw6w6nGmTPFLcVT0TU3S8EbfqPIRh2zxmGTw88aBGvJ9qCrsyKx+8j2ENvbDgSDqbOku8IiIivMKFT722gXO8bSP+u/ShvTypdcO8Sm0mO4X1kLyv3B48l295O/TQBT0vKig8YxjFOzbLUj0TfpK8HSA0vKgMLDzlxwg8c4pZu70Xl7zraLM8ptk5vbQQiLyxD5G5hZAjPd4xZbvellK8kpzTPHdVqzw2X4i8rECVvFtEKDxwJHU6zSCUO3G4KrvrZIm6bYhrPD83T7xVQrq7eL7CvEABqjxfE6Q8EFL9PNz6yLzblds8ntZUOQtFCDxUPpA83jFlPP7XFL34OhQ9OfsRu4qZbrytpYK8RZqAvFeve7sST0q8ST7evGexG7yacHC7F4Mzvd0tuzxkSzc8+qfVOgu1/DzI7Cq9CH7gO1gNDD3qmi69723Uuo4yRTyxqiM9qRBWvNP34zwZgAA9mWicvB/y4jyHYlK81pPturJCg7veMWU8wBiOPDPKWz36p9U8UXfoPKbVj7ydcee8iv7bN80glDyeBZ08lgI4vMAYDr37Ows9BhGfvGqDSjpqTaU72JRkPZnNCbxi6fw7SzsrPNEltbxmfqk86pouPaQHC7uY/4S8X01zO8jsqjrvbdS7+HA5vB0gtDuAXLq8ciFCPTRekTtg4ag8LyooPGGrA72VOF09imPJO6DTIbwhVJ29hGFbO4HF0Tv1CtU8CxrqO4X1ELxs5YQ8cLSAunwo0bw0XhE9+DqUvGCy4Drb9p485JjAvDpkqbsvYE08TW4dPWKvrbwHerY88QW0vBm2Jby1FDI7/jwCO9BbWrxV3Uw9XbFpvI3/Uru35uA7cbgqPRvtQTr2PUc8CRIWvRxW2Tsqx/Y8nQz6OwYRnzxk5sk8ISl/POEyXD0PSqm63yqIu/RrmLjTXFG9h2JSO3UiObyhawG9hfWQvJLS+DxJPt48v0qJPFF3aDzqNUE8jcmtPFtEqLzWXci7Y+Ifu9wpkbzun888MPisux5TJjwCfPI8gcGnvJbMEjuhBpQ8P5iSPEBml7sZ8PQ8e7+5PIz7qLwBdB69ee0KPOlr5jzOJD69oHLeuydWizxaek28VHjfvJc1qjuuqay79aXnPGtRzzzVvou8t7C7OrnjLTlLcVA8blLGu5EzvLwARda7blLGvKIKvrt9W0O9THV6vH76f7zyCV48yL3iu2kas7ttiOs8aLVFu1FzPj3raLM8coKFvJGYqTuM+6i8dx+GOgh+YD296M68ST5eOgWoB7sSSyA9mAMvu52cBTwIRJG8oaEmvMjsqryvEsQ738WaPGpNpTxygoW8dLmhvHiIHTzQkX88iV+fvP3e8Tq7ew08nZyFvCcrbb3YvwK97TY4PIb5ujvQW1q83sz3u+/OFz1dsWk89aVnvIGLAr3sMg68fMPjPIFgZDzwAQq7WaxIuoGLgrkBdB48xlChvOH4jLyBJhU8pqrxPIfHv7v63fq8KSg6vdtbjLsahKq8C7X8OjD4rLx3Vau8axcAvUGgZruMxQM976N5uzP5Izx6uw+9qRBWu3QeDzyXNSq81PAGPW1OHDx/xNq8HeoOvW1OnLzrZIm8kpzTPCpXgjwhVJ28NZGDO3bwPb2s2ye7Hu44vBZU67yz4T+7hyiDPGLp/LuYOdQ7paZHPF17RLyMmmW8Hx0BO3nCbLzmMCC9w0+qvIBcurxpf6A8e+6BvHRUtDwG4ta8fVtDvPY9x7zn/iQ9U3ALPYD3TDvTknY6F02OuiIiorxbDgM9HPFrPP1D3zwn8R296jVBPKaq8TuurVY7KPXHPLOrmjx1vUu8eYxHPH/EWjwwLtI8kC8SvEx1ejw0XhE8yh+dujto0zwR4gi94JMfPT7ON7ymqvE8XbHpPE528Ty546282JRkPByMfjw0/U09xFd+vJrRM7tFOb080CGLPKl1Q70kKva86GOSPOc0SjulcCK8RgMYPDvJFrrkzuW8Ps63vCgkkDwWGhy9mJ5BO1qwcjwvZPc8DRe3vIz7qLyKme48/g26PBjsSjq5rQg82PWnuofHP7ww9AK6EFJ9PPg6lLyaNiG8uR39vBlV4rwEdZW7ogq+PCqR0bw2lS2962gzPZ2cBT2abMa8brezNy3zi7xfeJG9J/EdvBeDMzuAXDq9/d5xPN9grTshVB29ylnsui0tW7o1kYM81fQwPMDpxby54y08" - } - ], - "model": "text-embedding-3-small", - "usage": { - "prompt_tokens": 9, - "total_tokens": 9 - } - } - headers: - CF-RAY: - - 99ac1f320cac4c6f-MXP - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Fri, 07 Nov 2025 10:24:37 GMT - Server: - - cloudflare - Set-Cookie: test_set_cookie - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - nosniff - access-control-allow-origin: - - '*' - access-control-expose-headers: - - X-Request-ID - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - content-length: - - '8414' - openai-model: - - text-embedding-3-small - openai-organization: test_openai_org_id - openai-processing-ms: - - '58' - openai-project: - - proj_Pf1eM5R55Z35wBy4rt8PxAGq - openai-version: - - '2020-10-01' - strict-transport-security: - - max-age=31536000; includeSubDomains; preload - via: - - envoy-router-7cd555d77c-nhr8p - x-envoy-upstream-service-time: - - '867' - x-openai-proxy-wasm: - - v0.1 - x-ratelimit-limit-requests: - - '10000' - x-ratelimit-limit-tokens: - - '5000000' - x-ratelimit-remaining-requests: - - '9999' - x-ratelimit-remaining-tokens: - - '4999988' - x-ratelimit-reset-requests: - - 6ms - x-ratelimit-reset-tokens: - - 0s - x-request-id: - - req_2d5eb18c0eed49f1b12359871cb8d17d - status: - code: 200 - message: OK -version: 1 diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py index af626d7990..9c8f87c943 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py @@ -7,11 +7,15 @@ import yaml from openai import AsyncOpenAI, OpenAI +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor -from opentelemetry.instrumentation.openai_v2.utils import ( +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.util.genai.environment_variables import ( OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, ) -from opentelemetry.sdk._logs import LoggerProvider # Backward compatibility for InMemoryLogExporter -> InMemoryLogRecordExporter rename try: @@ -112,10 +116,30 @@ def vcr_config(): } +@pytest.fixture( + scope="function", + params=[(True, "span_only"), (False, "True")], + name="content_mode", +) +def fixture_content_mode(request): + # returns tuple: (latest_experimental_enabled: bool, content_mode: str) + # we don't test (True, "event_only"), (True, "span_and_event") because it's util's + # responsibility + return request.param + + @pytest.fixture(scope="function") -def instrument_no_content(tracer_provider, logger_provider, meter_provider): +def instrument_no_content( + tracer_provider, logger_provider, meter_provider, content_mode +): + _OpenTelemetrySemanticConventionStability._initialized = False + latest_experimental_enabled, _ = content_mode os.environ.update( - {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "False"} + { + OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental" + if latest_experimental_enabled + else "" + } ) instrumentor = OpenAIInstrumentor() @@ -127,14 +151,32 @@ def instrument_no_content(tracer_provider, logger_provider, meter_provider): yield instrumentor os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) instrumentor.uninstrument() @pytest.fixture(scope="function") -def instrument_with_content(tracer_provider, logger_provider, meter_provider): +def instrument_with_content( + tracer_provider, logger_provider, meter_provider, content_mode +): + _OpenTelemetrySemanticConventionStability._initialized = False + + latest_experimental_enabled, content_mode_value = content_mode + + os.environ.update( + { + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: content_mode_value + } + ) + os.environ.update( - {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "True"} + { + OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental" + if latest_experimental_enabled + else "" + } ) + instrumentor = OpenAIInstrumentor() instrumentor.instrument( tracer_provider=tracer_provider, @@ -144,15 +186,28 @@ def instrument_with_content(tracer_provider, logger_provider, meter_provider): yield instrumentor os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) instrumentor.uninstrument() @pytest.fixture(scope="function") def instrument_with_content_unsampled( - span_exporter, logger_provider, meter_provider + span_exporter, logger_provider, meter_provider, content_mode ): + _OpenTelemetrySemanticConventionStability._initialized = False + latest_experimental_enabled, content_mode_value = content_mode + os.environ.update( + { + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: content_mode_value + } + ) + os.environ.update( - {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "True"} + { + OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental" + if latest_experimental_enabled + else "" + } ) tracer_provider = TracerProvider(sampler=ALWAYS_OFF) @@ -167,6 +222,7 @@ def instrument_with_content_unsampled( yield instrumentor os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) instrumentor.uninstrument() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.latest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.latest.txt index 714f742e17..af8a712d21 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.latest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.latest.txt @@ -52,3 +52,4 @@ wrapt==1.16.0 -e opentelemetry-instrumentation -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 +-e util/opentelemetry-util-genai \ No newline at end of file diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt index e48ca0510a..2644ba47e8 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt @@ -34,3 +34,4 @@ opentelemetry-sdk==1.37 # when updating, also update in pyproject.toml opentelemetry-semantic-conventions==0.58b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 +-e util/opentelemetry-util-genai \ No newline at end of file diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_chat_completions.py index e5306da766..5da88e6b5a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_chat_completions.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_chat_completions.py @@ -11,8 +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 pytest from openai import APIConnectionError, AsyncOpenAI, NotFoundError @@ -23,74 +23,105 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) +from opentelemetry.util.genai.utils import is_experimental_mode from .test_utils import ( + DEFAULT_MODEL, + USER_ONLY_EXPECTED_INPUT_MESSAGES, + USER_ONLY_PROMPT, + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + WEATHER_TOOL_PROMPT, assert_all_attributes, - assert_log_parent, - remove_none_values, + assert_message_in_logs, + assert_messages_attribute, + format_simple_expected_output_message, + get_current_weather_tool_definition, ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_with_content( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - - response = await async_openai_client.chat.completions.create( - messages=messages_value, model=llm_model_value, stream=False - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_async_chat_completion_with_content.yaml"): + response = await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, response.usage.completion_tokens, ) - logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response.choices[0].message.content + ), + ) + else: + logs = log_exporter.get_finished_logs() + assert len(logs) == 2 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_no_content( - span_exporter, log_exporter, async_openai_client, instrument_no_content + span_exporter, + log_exporter, + async_openai_client, + instrument_no_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - - response = await async_openai_client.chat.completions.create( - messages=messages_value, model=llm_model_value, stream=False - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_async_chat_completion_no_content.yaml"): + response = await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -98,37 +129,45 @@ async def test_async_chat_completion_no_content( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert len(logs) == 0 + assert "gen_ai.input.messages" not in spans[0].attributes + assert "gen_ai.output.messages" not in spans[0].attributes + else: + assert len(logs) == 2 - assert_message_in_logs(logs[0], "gen_ai.user.message", None, spans[0]) + assert_message_in_logs(logs[0], "gen_ai.user.message", None, spans[0]) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant"}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant"}, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) @pytest.mark.asyncio() async def test_async_chat_completion_bad_endpoint( span_exporter, instrument_no_content ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - + latest_experimental_enabled = is_experimental_mode() client = AsyncOpenAI(base_url="http://localhost:4242") with pytest.raises(APIConnectionError): await client.chat.completions.create( - messages=messages_value, - model=llm_model_value, + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, timeout=0.1, ) spans = span_exporter.get_finished_spans() assert_all_attributes( - spans[0], llm_model_value, server_address="localhost" + spans[0], + DEFAULT_MODEL, + latest_experimental_enabled, + server_address="localhost", ) assert 4242 == spans[0].attributes[ServerAttributes.SERVER_PORT] assert ( @@ -136,49 +175,51 @@ async def test_async_chat_completion_bad_endpoint( ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_404( - span_exporter, async_openai_client, instrument_no_content + span_exporter, async_openai_client, instrument_no_content, vcr ): + latest_experimental_enabled = is_experimental_mode() llm_model_value = "this-model-does-not-exist" - messages_value = [{"role": "user", "content": "Say this is a test"}] - with pytest.raises(NotFoundError): - await async_openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - ) + with vcr.use_cassette("test_async_chat_completion_404.yaml"): + with pytest.raises(NotFoundError): + await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=llm_model_value, + ) spans = span_exporter.get_finished_spans() - assert_all_attributes(spans[0], llm_model_value) + assert_all_attributes( + spans[0], llm_model_value, latest_experimental_enabled + ) assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_extra_params( - span_exporter, async_openai_client, instrument_no_content + span_exporter, async_openai_client, instrument_no_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - - response = await async_openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - seed=42, - temperature=0.5, - max_tokens=50, - stream=False, - extra_body={"service_tier": "default"}, - response_format={"type": "text"}, - ) + latest_experimental_enabled = is_experimental_mode() + + with vcr.use_cassette("test_async_chat_completion_extra_params.yaml"): + response = await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + seed=42, + temperature=0.5, + max_tokens=50, + stream=False, + extra_body={"service_tier": "default"}, + response_format={"type": "text"}, + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -191,34 +232,41 @@ async def test_async_chat_completion_extra_params( spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE] == 0.5 ) assert spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS] == 50 - assert ( - spans[0].attributes[GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER] - == "default" + request_service_tier_attr_key = ( + OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER ) - assert ( - spans[0].attributes[ - GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT - ] - == "text" + assert spans[0].attributes[request_service_tier_attr_key] == "default" + + output_type_attr_key = ( + GenAIAttributes.GEN_AI_OUTPUT_TYPE + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT ) + assert spans[0].attributes[output_type_attr_key] == "text" -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_multiple_choices( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] + latest_experimental_enabled = is_experimental_mode() - response = await async_openai_client.chat.completions.create( - messages=messages_value, model=llm_model_value, n=2, stream=False - ) + with vcr.use_cassette("test_async_chat_completion_multiple_choices.yaml"): + response = await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, n=2, stream=False + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -226,52 +274,89 @@ async def test_async_chat_completion_multiple_choices( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 3 # 1 user message + 2 choice messages - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + if latest_experimental_enabled: + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": response.choices[0].message.content, + } + ], + "finish_reason": "stop", + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": response.choices[1].message.content, + } + ], + "finish_reason": "stop", + }, + ] - choice_event_0 = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event_0, spans[0]) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + expected_output_messages, + ) + else: + assert len(logs) == 3 # 1 user message + 2 choice messages - choice_event_1 = { - "index": 1, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[1].message.content, - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event_1, spans[0]) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) + + choice_event_0 = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event_0, spans[0] + ) + + choice_event_1 = { + "index": 1, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[1].message.content, + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event_1, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() -async def test_async_chat_completion_with_raw_repsonse( - span_exporter, log_exporter, async_openai_client, instrument_with_content +async def test_async_chat_completion_with_raw_response( + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - response = ( - await async_openai_client.chat.completions.with_raw_response.create( - messages=messages_value, - model=llm_model_value, + latest_experimental_enabled = is_experimental_mode() + + with vcr.use_cassette("test_async_chat_completion_with_raw_response.yaml"): + response = await async_openai_client.chat.completions.with_raw_response.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, ) - ) response = response.parse() spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -279,39 +364,57 @@ async def test_async_chat_completion_with_raw_repsonse( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response.choices[0].message.content + ), + ) + else: + assert len(logs) == 2 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_chat_completion_with_raw_response_streaming( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - raw_response = ( - await async_openai_client.chat.completions.with_raw_response.create( - messages=messages_value, - model=llm_model_value, + latest_experimental_enabled = is_experimental_mode() + + with vcr.use_cassette( + "test_chat_completion_with_raw_response_streaming.yaml" + ): + raw_response = await async_openai_client.chat.completions.with_raw_response.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, stream=True, stream_options={"include_usage": True}, ) - ) response = raw_response.parse() message_content = "" @@ -327,7 +430,8 @@ async def test_chat_completion_with_raw_response_streaming( spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -336,59 +440,91 @@ async def test_chat_completion_with_raw_response_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert len(logs) == 0 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message(message_content), + ) + else: + assert len(logs) == 2 - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": message_content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) + + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": message_content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_tool_calls_with_content( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - await chat_completion_tool_call( - span_exporter, log_exporter, async_openai_client, True - ) + with vcr.use_cassette( + "test_async_chat_completion_tool_calls_with_content.yaml" + ): + await chat_completion_tool_call( + span_exporter, + log_exporter, + async_openai_client, + True, + is_experimental_mode(), + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_tool_calls_no_content( - span_exporter, log_exporter, async_openai_client, instrument_no_content + span_exporter, + log_exporter, + async_openai_client, + instrument_no_content, + vcr, ): - await chat_completion_tool_call( - span_exporter, log_exporter, async_openai_client, False - ) + with vcr.use_cassette( + "test_async_chat_completion_tool_calls_no_content.yaml" + ): + await chat_completion_tool_call( + span_exporter, + log_exporter, + async_openai_client, + False, + is_experimental_mode(), + ) async def chat_completion_tool_call( - span_exporter, log_exporter, async_openai_client, expect_content + span_exporter, + log_exporter, + async_openai_client, + expect_content, + latest_experimental_enabled, ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] + # pylint: disable=too-many-statements + messages_value = WEATHER_TOOL_PROMPT.copy() response_0 = await async_openai_client.chat.completions.create( messages=messages_value, - model=llm_model_value, + model=DEFAULT_MODEL, tool_choice="auto", tools=[get_current_weather_tool_definition()], ) @@ -421,7 +557,7 @@ async def chat_completion_tool_call( messages_value.append(tool_call_result_1) response_1 = await async_openai_client.chat.completions.create( - messages=messages_value, model=llm_model_value + messages=messages_value, model=DEFAULT_MODEL ) # sanity check @@ -432,7 +568,8 @@ async def chat_completion_tool_call( assert len(spans) == 2 assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_0.id, response_0.model, response_0.usage.prompt_tokens, @@ -440,7 +577,8 @@ async def chat_completion_tool_call( ) assert_all_attributes( spans[1], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_1.id, response_1.model, response_1.usage.prompt_tokens, @@ -448,125 +586,238 @@ async def chat_completion_tool_call( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 9 # 3 logs for first completion, 6 for second - - # call one - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) - - user_message = ( - {"content": messages_value[1]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) - - function_call_0 = {"name": "get_current_weather"} - function_call_1 = {"name": "get_current_weather"} - if expect_content: - function_call_0["arguments"] = ( - response_0.choices[0] - .message.tool_calls[0] - .function.arguments.replace("\n", "") - ) - function_call_1["arguments"] = ( - response_0.choices[0] - .message.tool_calls[1] - .function.arguments.replace("\n", "") - ) - - choice_event = { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ + if latest_experimental_enabled: + if not expect_content: + pass + else: + # first call + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + + first_output = [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": response_0.choices[0] + .message.tool_calls[0] + .id, + "name": "get_current_weather", + "arguments": {"location": "Seattle, WA"}, + }, + { + "type": "tool_call", + "id": response_0.choices[0] + .message.tool_calls[1] + .id, + "name": "get_current_weather", + "arguments": {"location": "San Francisco, CA"}, + }, + ], + "finish_reason": "tool_calls", + } + ] + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], first_output + ) + + # second call + del first_output[0]["finish_reason"] + second_input = [] + second_input += WEATHER_TOOL_EXPECTED_INPUT_MESSAGES.copy() + second_input += first_output + second_input += [ { - "id": response_0.choices[0].message.tool_calls[0].id, - "type": "function", - "function": function_call_0, + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": response_0.choices[0] + .message.tool_calls[0] + .id, + "response": tool_call_result_0["content"], + } + ], }, { - "id": response_0.choices[0].message.tool_calls[1].id, - "type": "function", - "function": function_call_1, + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": response_0.choices[0] + .message.tool_calls[1] + .id, + "response": tool_call_result_1["content"], + } + ], }, - ], - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event, spans[0]) + ] + + assert_messages_attribute( + spans[1].attributes["gen_ai.input.messages"], second_input + ) + + assert_messages_attribute( + spans[1].attributes["gen_ai.output.messages"], + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": response_1.choices[ + 0 + ].message.content, + }, + ], + "finish_reason": "stop", + } + ], + ) + else: + assert len(logs) == 9 # 3 logs for first completion, 6 for second - # call two - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[3], "gen_ai.system.message", system_message, spans[1] - ) + # call one + system_message = ( + {"content": messages_value[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) - user_message = ( - {"content": messages_value[1]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[4], "gen_ai.user.message", user_message, spans[1] - ) + user_message = ( + {"content": messages_value[1]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - assistant_tool_call = {"tool_calls": messages_value[2]["tool_calls"]} - if not expect_content: - assistant_tool_call["tool_calls"][0]["function"]["arguments"] = None - assistant_tool_call["tool_calls"][1]["function"]["arguments"] = None + function_call_0 = {"name": "get_current_weather"} + function_call_1 = {"name": "get_current_weather"} + if expect_content: + function_call_0["arguments"] = ( + response_0.choices[0] + .message.tool_calls[0] + .function.arguments.replace("\n", "") + ) + function_call_1["arguments"] = ( + response_0.choices[0] + .message.tool_calls[1] + .function.arguments.replace("\n", "") + ) + + choice_event = { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": response_0.choices[0].message.tool_calls[0].id, + "type": "function", + "function": function_call_0, + }, + { + "id": response_0.choices[0].message.tool_calls[1].id, + "type": "function", + "function": function_call_1, + }, + ], + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event, spans[0] + ) - assert_message_in_logs( - logs[5], "gen_ai.assistant.message", assistant_tool_call, spans[1] - ) + # call two + system_message = ( + {"content": messages_value[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[3], "gen_ai.system.message", system_message, spans[1] + ) - tool_message_0 = { - "id": tool_call_result_0["tool_call_id"], - "content": tool_call_result_0["content"] if expect_content else None, - } + user_message = ( + {"content": messages_value[1]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[4], "gen_ai.user.message", user_message, spans[1] + ) - assert_message_in_logs( - logs[6], "gen_ai.tool.message", tool_message_0, spans[1] - ) + assistant_tool_call = {"tool_calls": messages_value[2]["tool_calls"]} + if not expect_content: + assistant_tool_call["tool_calls"][0]["function"]["arguments"] = ( + None + ) + assistant_tool_call["tool_calls"][1]["function"]["arguments"] = ( + None + ) + + assert_message_in_logs( + logs[5], "gen_ai.assistant.message", assistant_tool_call, spans[1] + ) - tool_message_1 = { - "id": tool_call_result_1["tool_call_id"], - "content": tool_call_result_1["content"] if expect_content else None, - } + tool_message_0 = { + "id": tool_call_result_0["tool_call_id"], + "content": tool_call_result_0["content"] + if expect_content + else None, + } - assert_message_in_logs( - logs[7], "gen_ai.tool.message", tool_message_1, spans[1] - ) + assert_message_in_logs( + logs[6], "gen_ai.tool.message", tool_message_0, spans[1] + ) - message = { - "role": "assistant", - "content": response_1.choices[0].message.content - if expect_content - else None, - } - choice = { - "index": 0, - "finish_reason": "stop", - "message": message, - } - assert_message_in_logs(logs[8], "gen_ai.choice", choice, spans[1]) + tool_message_1 = { + "id": tool_call_result_1["tool_call_id"], + "content": tool_call_result_1["content"] + if expect_content + else None, + } + + assert_message_in_logs( + logs[7], "gen_ai.tool.message", tool_message_1, spans[1] + ) + + message = { + "role": "assistant", + "content": response_1.choices[0].message.content + if expect_content + else None, + } + choice = { + "index": 0, + "finish_reason": "stop", + "message": message, + } + assert_message_in_logs(logs[8], "gen_ai.choice", choice, spans[1]) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_streaming( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): + latest_experimental_enabled = is_experimental_mode() llm_model_value = "gpt-4" - messages_value = [{"role": "user", "content": "Say this is a test"}] kwargs = { "model": llm_model_value, - "messages": messages_value, + "messages": USER_ONLY_PROMPT, "stream": True, "stream_options": {"include_usage": True}, } @@ -575,21 +826,23 @@ async def test_async_chat_completion_streaming( response_stream_model = None response_stream_id = None response_stream_result = "" - response = await async_openai_client.chat.completions.create(**kwargs) - async for chunk in response: - if chunk.choices: - response_stream_result += chunk.choices[0].delta.content or "" - - # get the last chunk - if getattr(chunk, "usage", None): - response_stream_usage = chunk.usage - response_stream_model = chunk.model - response_stream_id = chunk.id + with vcr.use_cassette("test_async_chat_completion_streaming.yaml"): + response = await async_openai_client.chat.completions.create(**kwargs) + async for chunk in response: + if chunk.choices: + response_stream_result += chunk.choices[0].delta.content or "" + + # get the last chunk + if getattr(chunk, "usage", None): + response_stream_usage = chunk.usage + response_stream_model = chunk.model + response_stream_id = chunk.id spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], llm_model_value, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -597,122 +850,165 @@ async def test_async_chat_completion_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message(response_stream_result), + ) + else: + assert len(logs) == 2 - user_message = {"content": "Say this is a test"} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": "Say this is a test"} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": response_stream_result}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response_stream_result, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_streaming_not_complete( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4" - messages_value = [{"role": "user", "content": "Say this is a test"}] + latest_experimental_enabled = is_experimental_mode() kwargs = { - "model": llm_model_value, - "messages": messages_value, + "model": DEFAULT_MODEL, + "messages": USER_ONLY_PROMPT, "stream": True, } response_stream_model = None response_stream_id = None response_stream_result = "" - response = await async_openai_client.chat.completions.create(**kwargs) - idx = 0 - async for chunk in response: - if chunk.choices: - response_stream_result += chunk.choices[0].delta.content or "" - if idx == 1: - # fake a stop - break - if chunk.model: - response_stream_model = chunk.model - if chunk.id: - response_stream_id = chunk.id - idx += 1 - - response.close() + with vcr.use_cassette( + "test_async_chat_completion_streaming_not_complete.yaml" + ): + response = await async_openai_client.chat.completions.create(**kwargs) + idx = 0 + async for chunk in response: + if chunk.choices: + response_stream_result += chunk.choices[0].delta.content or "" + if idx == 1: + # fake a stop + break + + if chunk.model: + response_stream_model = chunk.model + if chunk.id: + response_stream_id = chunk.id + idx += 1 + + response.close() spans = span_exporter.get_finished_spans() assert_all_attributes( - spans[0], llm_model_value, response_stream_id, response_stream_model + spans[0], + DEFAULT_MODEL, + latest_experimental_enabled, + response_stream_id, + response_stream_model, ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response_stream_result, finish_reason="error" + ), + ) + else: + assert len(logs) == 2 - user_message = {"content": "Say this is a test"} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": "Say this is a test"} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "error", - "message": {"role": "assistant", "content": response_stream_result}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "error", + "message": { + "role": "assistant", + "content": response_stream_result, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_multiple_choices_streaming( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] + latest_experimental_enabled = is_experimental_mode() + messages_value = WEATHER_TOOL_PROMPT.copy() - response_0 = await async_openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - n=2, - stream=True, - stream_options={"include_usage": True}, - ) - - # two strings for each choice - response_stream_result = ["", ""] - finish_reasons = ["", ""] - async for chunk in response_0: - if chunk.choices: - for choice in chunk.choices: - response_stream_result[choice.index] += ( - choice.delta.content or "" - ) - if choice.finish_reason: - finish_reasons[choice.index] = choice.finish_reason - - # get the last chunk - if getattr(chunk, "usage", None): - response_stream_usage = chunk.usage - response_stream_model = chunk.model - response_stream_id = chunk.id + with vcr.use_cassette( + "test_async_chat_completion_multiple_choices_streaming.yaml" + ): + response_0 = await async_openai_client.chat.completions.create( + messages=messages_value, + model=DEFAULT_MODEL, + n=2, + stream=True, + stream_options={"include_usage": True}, + ) - # sanity check - assert "stop" == finish_reasons[0] + # two strings for each choice + response_stream_result = ["", ""] + finish_reasons = ["", ""] + async for chunk in response_0: + if chunk.choices: + for choice in chunk.choices: + response_stream_result[choice.index] += ( + choice.delta.content or "" + ) + if choice.finish_reason: + finish_reasons[choice.index] = choice.finish_reason + + # get the last chunk + if getattr(chunk, "usage", None): + response_stream_usage = chunk.usage + response_stream_model = chunk.model + response_stream_id = chunk.id + + # sanity check + assert "stop" == finish_reasons[0] spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -720,125 +1016,186 @@ async def test_async_chat_completion_multiple_choices_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 4 + if latest_experimental_enabled: + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "".join(response_stream_result[0]), + } + ], + "finish_reason": "stop", + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "".join(response_stream_result[1]), + } + ], + "finish_reason": "stop", + }, + ] + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + expected_output_messages, + ) + else: + assert len(logs) == 4 - system_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) + system_message = {"content": messages_value[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) - user_message = { - "content": "What's the weather in Seattle and San Francisco today?" - } - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) + user_message = { + "content": "What's the weather in Seattle and San Francisco today?" + } + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - choice_event_0 = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "".join(response_stream_result[0]), - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event_0, spans[0]) + choice_event_0 = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "".join(response_stream_result[0]), + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event_0, spans[0] + ) - choice_event_1 = { - "index": 1, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "".join(response_stream_result[1]), - }, - } - assert_message_in_logs(logs[3], "gen_ai.choice", choice_event_1, spans[0]) + choice_event_1 = { + "index": 1, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "".join(response_stream_result[1]), + }, + } + assert_message_in_logs( + logs[3], "gen_ai.choice", choice_event_1, spans[0] + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_multiple_tools_streaming_with_content( - span_exporter, log_exporter, async_openai_client, instrument_with_content + span_exporter, + log_exporter, + async_openai_client, + instrument_with_content, + vcr, ): - await async_chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, async_openai_client, True - ) + with vcr.use_cassette( + "test_async_chat_completion_multiple_tools_streaming_with_content.yaml" + ): + await async_chat_completion_multiple_tools_streaming( + span_exporter, + log_exporter, + async_openai_client, + True, + is_experimental_mode(), + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_multiple_tools_streaming_no_content( - span_exporter, log_exporter, async_openai_client, instrument_no_content + span_exporter, + log_exporter, + async_openai_client, + instrument_no_content, + vcr, ): - await async_chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, async_openai_client, False - ) + with vcr.use_cassette( + "test_async_chat_completion_multiple_tools_streaming_no_content.yaml" + ): + await async_chat_completion_multiple_tools_streaming( + span_exporter, + log_exporter, + async_openai_client, + False, + is_experimental_mode(), + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_streaming_unsampled( span_exporter, log_exporter, async_openai_client, instrument_with_content_unsampled, + vcr, ): - llm_model_value = "gpt-4" - messages_value = [{"role": "user", "content": "Say this is a test"}] + latest_experimental_enabled = is_experimental_mode() kwargs = { - "model": llm_model_value, - "messages": messages_value, + "model": DEFAULT_MODEL, + "messages": USER_ONLY_PROMPT, "stream": True, "stream_options": {"include_usage": True}, } response_stream_result = "" - response = await async_openai_client.chat.completions.create(**kwargs) - async for chunk in response: - if chunk.choices: - response_stream_result += chunk.choices[0].delta.content or "" + with vcr.use_cassette( + "test_async_chat_completion_streaming_unsampled.yaml" + ): + response = await async_openai_client.chat.completions.create(**kwargs) + async for chunk in response: + if chunk.choices: + response_stream_result += chunk.choices[0].delta.content or "" spans = span_exporter.get_finished_spans() assert len(spans) == 0 logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if not latest_experimental_enabled: + assert len(logs) == 2 - user_message = {"content": "Say this is a test"} - assert_message_in_logs(logs[0], "gen_ai.user.message", user_message, None) + user_message = {"content": "Say this is a test"} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, None + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": response_stream_result}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, None) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response_stream_result, + }, + } + assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, None) - assert logs[0].log_record.trace_id is not None - assert logs[0].log_record.span_id is not None - assert logs[0].log_record.trace_flags == 0 + assert logs[0].log_record.trace_id is not None + assert logs[0].log_record.span_id is not None + assert logs[0].log_record.trace_flags == 0 - assert logs[0].log_record.trace_id == logs[1].log_record.trace_id - assert logs[0].log_record.span_id == logs[1].log_record.span_id - assert logs[0].log_record.trace_flags == logs[1].log_record.trace_flags + assert logs[0].log_record.trace_id == logs[1].log_record.trace_id + assert logs[0].log_record.span_id == logs[1].log_record.span_id + assert logs[0].log_record.trace_flags == logs[1].log_record.trace_flags async def async_chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, async_openai_client, expect_content + span_exporter, + log_exporter, + async_openai_client, + expect_content, + latest_experimental_enabled, ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] + messages_value = WEATHER_TOOL_PROMPT.copy() response = await async_openai_client.chat.completions.create( messages=messages_value, - model=llm_model_value, + model=DEFAULT_MODEL, tool_choice="auto", tools=[get_current_weather_tool_definition()], stream=True, @@ -876,7 +1233,8 @@ async def async_chat_completion_multiple_tools_streaming( spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -884,93 +1242,93 @@ async def async_chat_completion_multiple_tools_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 3 - - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) - - user_message = ( - {"content": "What's the weather in Seattle and San Francisco today?"} - if expect_content - else None - ) - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) - - choice_event = { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ + if latest_experimental_enabled: + if expect_content: + # first call + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + + first_output = [ { - "id": tool_call_ids[0], - "type": "function", - "function": { - "name": tool_names[0], - "arguments": ( - tool_args[0].replace("\n", "") - if expect_content - else None - ), - }, - }, - { - "id": tool_call_ids[1], - "type": "function", - "function": { - "name": tool_names[1], - "arguments": ( - tool_args[1].replace("\n", "") - if expect_content - else None - ), - }, - }, - ], - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event, spans[0]) + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": tool_call_ids[0], + "name": "get_current_weather", + "arguments": {"location": "Seattle, WA"}, + }, + { + "type": "tool_call", + "id": tool_call_ids[1], + "name": "get_current_weather", + "arguments": {"location": "San Francisco, CA"}, + }, + ], + "finish_reason": "tool_calls", + } + ] + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], first_output + ) + else: + assert len(logs) == 3 + system_message = ( + {"content": messages_value[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) -def assert_message_in_logs(log, event_name, expected_content, parent_span): - assert log.log_record.event_name == event_name - assert ( - log.log_record.attributes[GenAIAttributes.GEN_AI_SYSTEM] - == GenAIAttributes.GenAiSystemValues.OPENAI.value - ) + user_message = ( + { + "content": "What's the weather in Seattle and San Francisco today?" + } + if expect_content + else None + ) + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - if not expected_content: - assert not log.log_record.body - else: - assert log.log_record.body - assert dict(log.log_record.body) == remove_none_values( - expected_content - ) - assert_log_parent(log, parent_span) - - -def get_current_weather_tool_definition(): - return { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "Get the current weather in a given location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. Boston, MA", + choice_event = { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": tool_call_ids[0], + "type": "function", + "function": { + "name": tool_names[0], + "arguments": ( + tool_args[0].replace("\n", "") + if expect_content + else None + ), + }, }, - }, - "required": ["location"], - "additionalProperties": False, + { + "id": tool_call_ids[1], + "type": "function", + "function": { + "name": tool_names[1], + "arguments": ( + tool_args[1].replace("\n", "") + if expect_content + else None + ), + }, + }, + ], }, - }, - } + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event, spans[0] + ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_embeddings.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_embeddings.py index cfb2f5fa9f..4d0b74b8e2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_embeddings.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_async_embeddings.py @@ -17,7 +17,6 @@ import pytest from openai import AsyncOpenAI, NotFoundError -from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.semconv._incubating.attributes import ( error_attributes as ErrorAttributes, ) @@ -25,28 +24,43 @@ gen_ai_attributes as GenAIAttributes, ) from opentelemetry.semconv._incubating.metrics import gen_ai_metrics +from opentelemetry.util.genai.utils import is_experimental_mode -from .test_utils import assert_all_attributes +from .test_utils import ( + DEFAULT_EMBEDDING_MODEL, + assert_all_attributes, + assert_embedding_attributes, +) @pytest.mark.asyncio -@pytest.mark.vcr() async def test_async_embeddings_no_content( - span_exporter, log_exporter, async_openai_client, instrument_no_content + span_exporter, + log_exporter, + async_openai_client, + instrument_no_content, + vcr, ): """Test creating embeddings asynchronously with content capture disabled""" - model_name = "text-embedding-3-small" + + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for async embeddings" - response = await async_openai_client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_async_embeddings_no_content.yaml"): + response = await async_openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # No logs should be emitted when content capture is disabled logs = log_exporter.get_finished_logs() @@ -54,25 +68,35 @@ async def test_async_embeddings_no_content( @pytest.mark.asyncio -@pytest.mark.vcr() async def test_async_embeddings_with_dimensions( - span_exporter, metric_reader, async_openai_client, instrument_no_content + span_exporter, + metric_reader, + async_openai_client, + instrument_no_content, + vcr, ): """Test creating embeddings asynchronously with custom dimensions""" - model_name = "text-embedding-3-small" + + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for async embeddings with dimensions" dimensions = 512 # Using a smaller dimension than default - response = await async_openai_client.embeddings.create( - model=model_name, - input=input_text, - dimensions=dimensions, - ) + with vcr.use_cassette("test_async_embeddings_with_dimensions.yaml"): + response = await async_openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + dimensions=dimensions, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify dimensions attribute is set correctly assert ( @@ -84,74 +108,100 @@ async def test_async_embeddings_with_dimensions( @pytest.mark.asyncio -@pytest.mark.vcr() async def test_async_embeddings_with_batch_input( - span_exporter, metric_reader, async_openai_client, instrument_with_content + span_exporter, + metric_reader, + async_openai_client, + instrument_with_content, + vcr, ): """Test creating embeddings asynchronously with batch input""" - model_name = "text-embedding-3-small" + + latest_experimental_enabled = is_experimental_mode() + input_texts = [ "This is the first test string for async embeddings", "This is the second test string for async embeddings", "This is the third test string for async embeddings", ] - response = await async_openai_client.embeddings.create( - model=model_name, - input=input_texts, - ) + with vcr.use_cassette("test_async_embeddings_with_batch_input.yaml"): + response = await async_openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_texts, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify results contain the same number of embeddings as input texts assert len(response.data) == len(input_texts) @pytest.mark.asyncio -@pytest.mark.vcr() async def test_async_embeddings_error_handling( - span_exporter, metric_reader, instrument_no_content + span_exporter, metric_reader, instrument_no_content, vcr ): """Test async embeddings error handling""" + latest_experimental_enabled = is_experimental_mode() model_name = "non-existent-embedding-model" input_text = "This is a test for async embeddings with error" client = AsyncOpenAI() - - with pytest.raises(NotFoundError): - await client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_async_embeddings_error_handling.yaml"): + with pytest.raises(NotFoundError): + await client.embeddings.create( + model=model_name, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_all_attributes(spans[0], model_name, operation_name="embeddings") + assert_all_attributes( + spans[0], + model_name, + latest_experimental_enabled, + operation_name="embeddings", + ) assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] @pytest.mark.asyncio @pytest.mark.vcr() async def test_async_embeddings_token_metrics( - span_exporter, metric_reader, async_openai_client, instrument_no_content + span_exporter, + metric_reader, + async_openai_client, + instrument_no_content, + vcr, ): """Test that token usage metrics are correctly recorded for async embeddings""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for async embeddings token metrics" - response = await async_openai_client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_async_embeddings_token_metrics.yaml"): + response = await async_openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify metrics metrics = metric_reader.get_metrics_data().resource_metrics @@ -187,55 +237,36 @@ async def test_async_embeddings_token_metrics( @pytest.mark.asyncio -@pytest.mark.vcr() async def test_async_embeddings_with_encoding_format( - span_exporter, metric_reader, async_openai_client, instrument_no_content + span_exporter, + metric_reader, + async_openai_client, + instrument_no_content, + vcr, ): """Test creating embeddings with different encoding format""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for embeddings with encoding format" encoding_format = "base64" - response = await async_openai_client.embeddings.create( - model=model_name, - input=input_text, - encoding_format=encoding_format, - ) + with vcr.use_cassette("test_async_embeddings_with_encoding_format.yaml"): + response = await async_openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + encoding_format=encoding_format, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify encoding_format attribute is set correctly assert spans[0].attributes["gen_ai.request.encoding_formats"] == ( encoding_format, ) - - -def assert_embedding_attributes( - span: ReadableSpan, - request_model: str, - response, -): - """Assert that the span contains all required attributes for embeddings operation""" - # Use the common assertion function - assert_all_attributes( - span, - request_model, - response_id=None, # Embeddings don't have a response ID - response_model=response.model, - input_tokens=response.usage.prompt_tokens, - operation_name="embeddings", - server_address="api.openai.com", - ) - - # Assert embeddings-specific attributes - if ( - hasattr(span, "attributes") - and "gen_ai.embeddings.dimension.count" in span.attributes - ): - # If dimensions were specified, verify that they match the actual dimensions - assert span.attributes["gen_ai.embeddings.dimension.count"] == len( - response.data[0].embedding - ) 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 537cc031f9..4862460ddf 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 @@ -15,6 +15,7 @@ # pylint: disable=too-many-locals,too-many-lines import logging +import os import pytest from openai import ( @@ -35,80 +36,113 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) from opentelemetry.semconv._incubating.metrics import gen_ai_metrics +from opentelemetry.util.genai.utils import is_experimental_mode from .test_utils import ( + DEFAULT_MODEL, + USER_ONLY_EXPECTED_INPUT_MESSAGES, + USER_ONLY_PROMPT, + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + WEATHER_TOOL_PROMPT, assert_all_attributes, - assert_log_parent, - remove_none_values, + assert_message_in_logs, + assert_messages_attribute, + format_simple_expected_output_message, + get_current_weather_tool_definition, ) -@pytest.mark.vcr() def test_chat_completion_with_content( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - 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, - stream=False, + latest_experimental_enabled = is_experimental_mode() + print( + f"latest_experimental_enabled={latest_experimental_enabled}, ENV_VAR={os.getenv('OTEL_SEMCONV_STABILITY_OPT_IN')}" ) + with vcr.use_cassette("test_chat_completion_with_content.yaml"): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + stream=False, + ) + spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, response.usage.completion_tokens, ) - logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response.choices[0].message.content + ), + ) + else: + logs = log_exporter.get_finished_logs() + assert len(logs) == 2 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_handles_not_given( - span_exporter, log_exporter, openai_client, instrument_no_content, caplog + span_exporter, + log_exporter, + openai_client, + instrument_no_content, + vcr, + caplog, ): caplog.set_level(logging.WARNING) - 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, - stream=False, - top_p=NOT_GIVEN, - max_tokens=not_given, - ) + latest_experimental_enabled = is_experimental_mode() + + with vcr.use_cassette("test_chat_completion_handles_not_given.yaml"): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + stream=False, + top_p=NOT_GIVEN, + max_tokens=not_given, + ) (span,) = span_exporter.get_finished_spans() assert_all_attributes( span, - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -119,27 +153,23 @@ def test_chat_completion_handles_not_given( assert GenAIAttributes.GEN_AI_REQUEST_TOP_P not in span.attributes assert GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS not in span.attributes - logs = log_exporter.get_finished_logs() - assert len(logs) == 2 - assert_no_invalid_type_warning(caplog) -@pytest.mark.vcr() def test_chat_completion_no_content( - span_exporter, log_exporter, openai_client, instrument_no_content + span_exporter, log_exporter, openai_client, instrument_no_content, vcr ): - 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, stream=False - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_chat_completion_no_content.yaml"): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -147,36 +177,46 @@ def test_chat_completion_no_content( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert len(logs) == 0 + assert "gen_ai.input.messages" not in spans[0].attributes + assert "gen_ai.output.messages" not in spans[0].attributes + else: + assert len(logs) == 2 - assert_message_in_logs(logs[0], "gen_ai.user.message", None, spans[0]) + assert_message_in_logs(logs[0], "gen_ai.user.message", None, spans[0]) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant"}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant"}, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) def test_chat_completion_bad_endpoint( - span_exporter, metric_reader, instrument_no_content + span_exporter, metric_reader, instrument_no_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] + latest_experimental_enabled = is_experimental_mode() client = OpenAI(base_url="http://localhost:4242") - with pytest.raises(APIConnectionError): - client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - timeout=0.1, - ) + with vcr.use_cassette("test_chat_completion_bad_endpoint.yaml"): + with pytest.raises(APIConnectionError): + client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + timeout=0.1, + ) spans = span_exporter.get_finished_spans() assert_all_attributes( - spans[0], llm_model_value, server_address="localhost" + spans[0], + DEFAULT_MODEL, + latest_experimental_enabled, + server_address="localhost", ) assert 4242 == spans[0].attributes[ServerAttributes.SERVER_PORT] assert ( @@ -205,22 +245,24 @@ def test_chat_completion_bad_endpoint( ) -@pytest.mark.vcr() def test_chat_completion_404( - span_exporter, openai_client, metric_reader, instrument_no_content + span_exporter, openai_client, metric_reader, instrument_no_content, vcr ): + latest_experimental_enabled = is_experimental_mode() llm_model_value = "this-model-does-not-exist" - messages_value = [{"role": "user", "content": "Say this is a test"}] - with pytest.raises(NotFoundError): - openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - ) + with vcr.use_cassette("test_chat_completion_404.yaml"): + with pytest.raises(NotFoundError): + openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=llm_model_value, + ) spans = span_exporter.get_finished_spans() - assert_all_attributes(spans[0], llm_model_value) + assert_all_attributes( + spans[0], llm_model_value, latest_experimental_enabled + ) assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] metrics = metric_reader.get_metrics_data().resource_metrics @@ -245,30 +287,30 @@ def test_chat_completion_404( ) -@pytest.mark.vcr() def test_chat_completion_extra_params( - span_exporter, openai_client, instrument_no_content + span_exporter, openai_client, instrument_no_content, vcr ): - llm_model_value = "gpt-4o-mini" - 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, - temperature=0.5, - max_tokens=50, - stream=False, - extra_body={"service_tier": "default"}, - response_format={"type": "text"}, - stop=["full", "stop"], - ) + latest_experimental_enabled = is_experimental_mode() + + with vcr.use_cassette("test_chat_completion_extra_params.yaml"): + response = openai_client.chat.completions.create( + n=2, + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + seed=42, + temperature=0.5, + max_tokens=50, + stream=False, + extra_body={"service_tier": "default"}, + response_format={"type": "text"}, + stop=["full", "stop"], + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -281,16 +323,20 @@ def test_chat_completion_extra_params( spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE] == 0.5 ) assert spans[0].attributes[GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS] == 50 - assert ( - spans[0].attributes[GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER] - == "default" + + request_service_tier_attr_key = ( + OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER ) - assert ( - spans[0].attributes[ - GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT - ] - == "text" + assert spans[0].attributes[request_service_tier_attr_key] == "default" + + output_type_attr_key = ( + GenAIAttributes.GEN_AI_OUTPUT_TYPE + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT ) + assert spans[0].attributes[output_type_attr_key] == "text" assert spans[0].attributes[ GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES ] == ("full", "stop") @@ -299,23 +345,22 @@ def test_chat_completion_extra_params( ) -@pytest.mark.vcr() def test_chat_completion_n_1_is_not_reported( - span_exporter, openai_client, instrument_no_content + span_exporter, openai_client, instrument_no_content, vcr ): - 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, - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_chat_completion_n_1_is_not_reported.yaml"): + response = openai_client.chat.completions.create( + n=1, + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -327,23 +372,24 @@ def test_chat_completion_n_1_is_not_reported( ) -@pytest.mark.vcr() def test_chat_completion_handle_stop_sequences_as_string( - span_exporter, openai_client, instrument_no_content + span_exporter, openai_client, instrument_no_content, vcr ): - 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", - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette( + "test_chat_completion_handle_stop_sequences_as_string.yaml" + ): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + stop="stop", + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -355,21 +401,20 @@ def test_chat_completion_handle_stop_sequences_as_string( ] == ("stop",) -@pytest.mark.vcr() def test_chat_completion_multiple_choices( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - 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, n=2, stream=False - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_chat_completion_multiple_choices.yaml"): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, n=2, stream=False + ) spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, @@ -381,86 +426,138 @@ def test_chat_completion_multiple_choices( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 3 # 1 user message + 2 choice messages + if latest_experimental_enabled: + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": response.choices[0].message.content, + } + ], + "finish_reason": "stop", + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": response.choices[1].message.content, + } + ], + "finish_reason": "stop", + }, + ] - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + expected_output_messages, + ) + else: + assert len(logs) == 3 # 1 user message + 2 choice messages - choice_event_0 = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event_0, spans[0]) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event_1 = { - "index": 1, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[1].message.content, - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event_1, spans[0]) + choice_event_0 = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event_0, spans[0] + ) + + choice_event_1 = { + "index": 1, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[1].message.content, + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event_1, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_with_raw_response( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - response = openai_client.chat.completions.with_raw_response.create( - messages=messages_value, - model=llm_model_value, - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_chat_completion_with_raw_response.yaml"): + response = openai_client.chat.completions.with_raw_response.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + ) response = response.parse() spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response.id, response.model, response.usage.prompt_tokens, response.usage.completion_tokens, ) - logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response.choices[0].message.content + ), + ) + else: + logs = log_exporter.get_finished_logs() + assert len(logs) == 2 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_with_raw_response_streaming( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - raw_response = openai_client.chat.completions.with_raw_response.create( - messages=messages_value, - model=llm_model_value, - stream=True, - stream_options={"include_usage": True}, - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette( + "test_chat_completion_with_raw_response_streaming.yaml" + ): + raw_response = openai_client.chat.completions.with_raw_response.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + stream=True, + stream_options={"include_usage": True}, + ) response = raw_response.parse() message_content = "" @@ -476,7 +573,8 @@ def test_chat_completion_with_raw_response_streaming( spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -485,55 +583,78 @@ def test_chat_completion_with_raw_response_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert len(logs) == 0 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message(message_content), + ) + else: + assert len(logs) == 2 - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": message_content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) + + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": message_content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_tool_calls_with_content( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - chat_completion_tool_call(span_exporter, log_exporter, openai_client, True) + with vcr.use_cassette("test_chat_completion_tool_calls_with_content.yaml"): + chat_completion_tool_call( + span_exporter, + log_exporter, + openai_client, + True, + is_experimental_mode(), + ) -@pytest.mark.vcr() def test_chat_completion_tool_calls_no_content( - span_exporter, log_exporter, openai_client, instrument_no_content + span_exporter, log_exporter, openai_client, instrument_no_content, vcr ): - chat_completion_tool_call( - span_exporter, log_exporter, openai_client, False - ) + with vcr.use_cassette("test_chat_completion_tool_calls_no_content.yaml"): + chat_completion_tool_call( + span_exporter, + log_exporter, + openai_client, + False, + is_experimental_mode(), + ) def chat_completion_tool_call( - span_exporter, log_exporter, openai_client, expect_content + span_exporter, + log_exporter, + openai_client, + expect_content, + latest_experimental_enabled, ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] + # pylint: disable=too-many-statements + + messages_value = WEATHER_TOOL_PROMPT.copy() response_0 = openai_client.chat.completions.create( messages=messages_value, - model=llm_model_value, + model=DEFAULT_MODEL, tool_choice="auto", tools=[get_current_weather_tool_definition()], ) @@ -566,7 +687,7 @@ def chat_completion_tool_call( messages_value.append(tool_call_result_1) response_1 = openai_client.chat.completions.create( - messages=messages_value, model=llm_model_value + messages=messages_value, model=DEFAULT_MODEL ) # sanity check @@ -577,7 +698,8 @@ def chat_completion_tool_call( assert len(spans) == 2 assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_0.id, response_0.model, response_0.usage.prompt_tokens, @@ -585,7 +707,8 @@ def chat_completion_tool_call( ) assert_all_attributes( spans[1], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_1.id, response_1.model, response_1.usage.prompt_tokens, @@ -593,124 +716,221 @@ def chat_completion_tool_call( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 9 # 3 logs for first completion, 6 for second - - # call one - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) - - user_message = ( - {"content": messages_value[1]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) - - function_call_0 = {"name": "get_current_weather"} - function_call_1 = {"name": "get_current_weather"} - if expect_content: - function_call_0["arguments"] = ( - response_0.choices[0] - .message.tool_calls[0] - .function.arguments.replace("\n", "") - ) - function_call_1["arguments"] = ( - response_0.choices[0] - .message.tool_calls[1] - .function.arguments.replace("\n", "") - ) - - choice_event = { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ + if latest_experimental_enabled: + if not expect_content: + pass + else: + # first call + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + + first_output = [ { - "id": response_0.choices[0].message.tool_calls[0].id, - "type": "function", - "function": function_call_0, + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": response_0.choices[0] + .message.tool_calls[0] + .id, + "name": "get_current_weather", + "arguments": {"location": "Seattle, WA"}, + }, + { + "type": "tool_call", + "id": response_0.choices[0] + .message.tool_calls[1] + .id, + "name": "get_current_weather", + "arguments": {"location": "San Francisco, CA"}, + }, + ], + "finish_reason": "tool_calls", + } + ] + + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], first_output + ) + + # second call + del first_output[0]["finish_reason"] + second_input = [] + second_input += WEATHER_TOOL_EXPECTED_INPUT_MESSAGES.copy() + second_input += first_output + second_input += [ + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": response_0.choices[0] + .message.tool_calls[0] + .id, + "response": tool_call_result_0["content"], + } + ], }, { - "id": response_0.choices[0].message.tool_calls[1].id, - "type": "function", - "function": function_call_1, + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": response_0.choices[0] + .message.tool_calls[1] + .id, + "response": tool_call_result_1["content"], + } + ], }, - ], - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event, spans[0]) + ] + + assert_messages_attribute( + spans[1].attributes["gen_ai.input.messages"], second_input + ) + + assert_messages_attribute( + spans[1].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response_1.choices[0].message.content + ), + ) + else: + assert len(logs) == 9 # 3 logs for first completion, 6 for second - # call two - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[3], "gen_ai.system.message", system_message, spans[1] - ) + # call one + system_message = ( + {"content": messages_value[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) - user_message = ( - {"content": messages_value[1]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[4], "gen_ai.user.message", user_message, spans[1] - ) + user_message = ( + {"content": messages_value[1]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - assistant_tool_call = {"tool_calls": messages_value[2]["tool_calls"]} - if not expect_content: - assistant_tool_call["tool_calls"][0]["function"]["arguments"] = None - assistant_tool_call["tool_calls"][1]["function"]["arguments"] = None + function_call_0 = {"name": "get_current_weather"} + function_call_1 = {"name": "get_current_weather"} + if expect_content: + function_call_0["arguments"] = ( + response_0.choices[0] + .message.tool_calls[0] + .function.arguments.replace("\n", "") + ) + function_call_1["arguments"] = ( + response_0.choices[0] + .message.tool_calls[1] + .function.arguments.replace("\n", "") + ) + + choice_event = { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": response_0.choices[0].message.tool_calls[0].id, + "type": "function", + "function": function_call_0, + }, + { + "id": response_0.choices[0].message.tool_calls[1].id, + "type": "function", + "function": function_call_1, + }, + ], + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event, spans[0] + ) - assert_message_in_logs( - logs[5], "gen_ai.assistant.message", assistant_tool_call, spans[1] - ) + # call two + system_message = ( + {"content": messages_value[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[3], "gen_ai.system.message", system_message, spans[1] + ) - tool_message_0 = { - "id": tool_call_result_0["tool_call_id"], - "content": tool_call_result_0["content"] if expect_content else None, - } + user_message = ( + {"content": messages_value[1]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[4], "gen_ai.user.message", user_message, spans[1] + ) - assert_message_in_logs( - logs[6], "gen_ai.tool.message", tool_message_0, spans[1] - ) + assistant_tool_call = {"tool_calls": messages_value[2]["tool_calls"]} + if not expect_content: + assistant_tool_call["tool_calls"][0]["function"]["arguments"] = ( + None + ) + assistant_tool_call["tool_calls"][1]["function"]["arguments"] = ( + None + ) + + assert_message_in_logs( + logs[5], "gen_ai.assistant.message", assistant_tool_call, spans[1] + ) - tool_message_1 = { - "id": tool_call_result_1["tool_call_id"], - "content": tool_call_result_1["content"] if expect_content else None, - } + tool_message_0 = { + "id": tool_call_result_0["tool_call_id"], + "content": tool_call_result_0["content"] + if expect_content + else None, + } - assert_message_in_logs( - logs[7], "gen_ai.tool.message", tool_message_1, spans[1] - ) + assert_message_in_logs( + logs[6], "gen_ai.tool.message", tool_message_0, spans[1] + ) - message = { - "role": "assistant", - "content": response_1.choices[0].message.content - if expect_content - else None, - } - choice = { - "index": 0, - "finish_reason": "stop", - "message": message, - } - assert_message_in_logs(logs[8], "gen_ai.choice", choice, spans[1]) + tool_message_1 = { + "id": tool_call_result_1["tool_call_id"], + "content": tool_call_result_1["content"] + if expect_content + else None, + } + + assert_message_in_logs( + logs[7], "gen_ai.tool.message", tool_message_1, spans[1] + ) + + message = { + "role": "assistant", + "content": response_1.choices[0].message.content + if expect_content + else None, + } + choice = { + "index": 0, + "finish_reason": "stop", + "message": message, + } + assert_message_in_logs(logs[8], "gen_ai.choice", choice, spans[1]) -@pytest.mark.vcr() def test_chat_completion_streaming( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4" - messages_value = [{"role": "user", "content": "Say this is a test"}] - + latest_experimental_enabled = is_experimental_mode() kwargs = { - "model": llm_model_value, - "messages": messages_value, + "model": DEFAULT_MODEL, + "messages": USER_ONLY_PROMPT, "stream": True, "stream_options": {"include_usage": True}, } @@ -719,21 +939,24 @@ def test_chat_completion_streaming( response_stream_model = None response_stream_id = None response_stream_result = "" - response = openai_client.chat.completions.create(**kwargs) - for chunk in response: - if chunk.choices: - response_stream_result += chunk.choices[0].delta.content or "" - # get the last chunk - if getattr(chunk, "usage", None): - response_stream_usage = chunk.usage - response_stream_model = chunk.model - response_stream_id = chunk.id + with vcr.use_cassette("test_chat_completion_streaming.yaml"): + response = openai_client.chat.completions.create(**kwargs) + for chunk in response: + if chunk.choices: + response_stream_result += chunk.choices[0].delta.content or "" + + # get the last chunk + if getattr(chunk, "usage", None): + response_stream_usage = chunk.usage + response_stream_model = chunk.model + response_stream_id = chunk.id spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -741,92 +964,121 @@ def test_chat_completion_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message(response_stream_result), + ) + else: + assert len(logs) == 2 - user_message = {"content": "Say this is a test"} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": "Say this is a test"} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": {"role": "assistant", "content": response_stream_result}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response_stream_result, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_streaming_not_complete( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4" - messages_value = [{"role": "user", "content": "Say this is a test"}] - + latest_experimental_enabled = is_experimental_mode() kwargs = { - "model": llm_model_value, - "messages": messages_value, + "model": DEFAULT_MODEL, + "messages": USER_ONLY_PROMPT, "stream": True, } response_stream_model = None response_stream_id = None response_stream_result = "" - response = openai_client.chat.completions.create(**kwargs) - for idx, chunk in enumerate(response): - if chunk.choices: - response_stream_result += chunk.choices[0].delta.content or "" - if idx == 1: - # fake a stop - break - if chunk.model: - response_stream_model = chunk.model - if chunk.id: - response_stream_id = chunk.id + with vcr.use_cassette("test_chat_completion_streaming_not_complete.yaml"): + response = openai_client.chat.completions.create(**kwargs) + for idx, chunk in enumerate(response): + if chunk.choices: + response_stream_result += chunk.choices[0].delta.content or "" + if idx == 1: + # fake a stop + break + + if chunk.model: + response_stream_model = chunk.model + if chunk.id: + response_stream_id = chunk.id response.close() spans = span_exporter.get_finished_spans() assert_all_attributes( - spans[0], llm_model_value, response_stream_id, response_stream_model + spans[0], + DEFAULT_MODEL, + latest_experimental_enabled, + response_stream_id, + response_stream_model, ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message( + response_stream_result, finish_reason="error" + ), + ) + else: + assert len(logs) == 2 - user_message = {"content": "Say this is a test"} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + user_message = {"content": "Say this is a test"} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) - choice_event = { - "index": 0, - "finish_reason": "error", - "message": {"role": "assistant", "content": response_stream_result}, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + choice_event = { + "index": 0, + "finish_reason": "error", + "message": { + "role": "assistant", + "content": response_stream_result, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_multiple_choices_streaming( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] - - response_0 = openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - n=2, - stream=True, - stream_options={"include_usage": True}, - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette( + "test_chat_completion_multiple_choices_streaming.yaml" + ): + response_0 = openai_client.chat.completions.create( + messages=WEATHER_TOOL_PROMPT, + model=DEFAULT_MODEL, + n=2, + stream=True, + stream_options={"include_usage": True}, + ) # two strings for each choice response_stream_result = ["", ""] @@ -852,7 +1104,8 @@ def test_chat_completion_multiple_choices_streaming( spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -860,127 +1113,181 @@ def test_chat_completion_multiple_choices_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 4 + if latest_experimental_enabled: + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "".join(response_stream_result[0]), + } + ], + "finish_reason": "stop", + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "".join(response_stream_result[1]), + } + ], + "finish_reason": "stop", + }, + ] + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + expected_output_messages, + ) + else: + assert len(logs) == 4 - system_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) + system_message = {"content": WEATHER_TOOL_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) - user_message = { - "content": "What's the weather in Seattle and San Francisco today?" - } - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) + user_message = { + "content": "What's the weather in Seattle and San Francisco today?" + } + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - choice_event_0 = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "".join(response_stream_result[0]), - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event_0, spans[0]) + choice_event_0 = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "".join(response_stream_result[0]), + }, + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event_0, spans[0] + ) - choice_event_1 = { - "index": 1, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "".join(response_stream_result[1]), - }, - } - assert_message_in_logs(logs[3], "gen_ai.choice", choice_event_1, spans[0]) + choice_event_1 = { + "index": 1, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "".join(response_stream_result[1]), + }, + } + assert_message_in_logs( + logs[3], "gen_ai.choice", choice_event_1, spans[0] + ) -@pytest.mark.vcr() def test_chat_completion_multiple_tools_streaming_with_content( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, openai_client, True - ) + with vcr.use_cassette( + "test_chat_completion_multiple_tools_streaming_with_content.yaml" + ): + chat_completion_multiple_tools_streaming( + span_exporter, + log_exporter, + openai_client, + True, + is_experimental_mode(), + ) -@pytest.mark.vcr() def test_chat_completion_multiple_tools_streaming_no_content( - span_exporter, log_exporter, openai_client, instrument_no_content + span_exporter, log_exporter, openai_client, instrument_no_content, vcr ): - chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, openai_client, False - ) + with vcr.use_cassette( + "test_chat_completion_multiple_tools_streaming_no_content.yaml" + ): + chat_completion_multiple_tools_streaming( + span_exporter, + log_exporter, + openai_client, + False, + is_experimental_mode(), + ) -@pytest.mark.vcr() def test_chat_completion_with_content_span_unsampled( span_exporter, log_exporter, openai_client, instrument_with_content_unsampled, + vcr, ): - 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, stream=False - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette( + "test_chat_completion_with_content_span_unsampled.yaml" + ): + response = openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) spans = span_exporter.get_finished_spans() assert len(spans) == 0 logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if not latest_experimental_enabled: + assert len(logs) == 2 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs(logs[0], "gen_ai.user.message", user_message, None) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, None + ) - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": response.choices[0].message.content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, None) + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": response.choices[0].message.content, + }, + } + assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, None) - assert logs[0].log_record.trace_id is not None - assert logs[0].log_record.span_id is not None - assert logs[0].log_record.trace_flags == 0 + assert logs[0].log_record.trace_id is not None + assert logs[0].log_record.span_id is not None + assert logs[0].log_record.trace_flags == 0 - assert logs[0].log_record.trace_id == logs[1].log_record.trace_id - assert logs[0].log_record.span_id == logs[1].log_record.span_id - assert logs[0].log_record.trace_flags == logs[1].log_record.trace_flags + assert logs[0].log_record.trace_id == logs[1].log_record.trace_id + assert logs[0].log_record.span_id == logs[1].log_record.span_id + assert logs[0].log_record.trace_flags == logs[1].log_record.trace_flags -@pytest.mark.vcr() def test_chat_completion_with_context_manager_streaming( - span_exporter, log_exporter, openai_client, instrument_with_content + span_exporter, log_exporter, openai_client, instrument_with_content, vcr ): - llm_model_value = "gpt-4o-mini" - messages_value = [{"role": "user", "content": "Say this is a test"}] - with openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, - stream=True, - stream_options={"include_usage": True}, - ) as response: - message_content = "" - for chunk in response: - if chunk.choices: - message_content += chunk.choices[0].delta.content or "" - # get the last chunk - if getattr(chunk, "usage", None): - response_stream_usage = chunk.usage - response_stream_model = chunk.model - response_stream_id = chunk.id + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette( + "test_chat_completion_with_context_manager_streaming.yaml" + ): + with openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, + model=DEFAULT_MODEL, + stream=True, + stream_options={"include_usage": True}, + ) as response: + message_content = "" + for chunk in response: + if chunk.choices: + message_content += chunk.choices[0].delta.content or "" + # get the last chunk + if getattr(chunk, "usage", None): + response_stream_usage = chunk.usage + response_stream_model = chunk.model + response_stream_id = chunk.id spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -989,39 +1296,48 @@ def test_chat_completion_with_context_manager_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 2 + if latest_experimental_enabled: + assert len(logs) == 0 - user_message = {"content": messages_value[0]["content"]} - assert_message_in_logs( - logs[0], "gen_ai.user.message", user_message, spans[0] - ) + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + USER_ONLY_EXPECTED_INPUT_MESSAGES, + ) + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], + format_simple_expected_output_message(message_content), + ) + else: + assert len(logs) == 2 - choice_event = { - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": message_content, - }, - } - assert_message_in_logs(logs[1], "gen_ai.choice", choice_event, spans[0]) + user_message = {"content": USER_ONLY_PROMPT[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) + + choice_event = { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": message_content, + }, + } + assert_message_in_logs( + logs[1], "gen_ai.choice", choice_event, spans[0] + ) def chat_completion_multiple_tools_streaming( - span_exporter, log_exporter, openai_client, expect_content + span_exporter, + log_exporter, + openai_client, + expect_content, + latest_experimental_enabled, ): - llm_model_value = "gpt-4o-mini" - messages_value = [ - {"role": "system", "content": "You're a helpful assistant."}, - { - "role": "user", - "content": "What's the weather in Seattle and San Francisco today?", - }, - ] - response = openai_client.chat.completions.create( - messages=messages_value, - model=llm_model_value, + messages=WEATHER_TOOL_PROMPT, + model=DEFAULT_MODEL, tool_choice="auto", tools=[get_current_weather_tool_definition()], stream=True, @@ -1059,7 +1375,8 @@ def chat_completion_multiple_tools_streaming( spans = span_exporter.get_finished_spans() assert_all_attributes( spans[0], - llm_model_value, + DEFAULT_MODEL, + latest_experimental_enabled, response_stream_id, response_stream_model, response_stream_usage.prompt_tokens, @@ -1067,92 +1384,92 @@ def chat_completion_multiple_tools_streaming( ) logs = log_exporter.get_finished_logs() - assert len(logs) == 3 - - system_message = ( - {"content": messages_value[0]["content"]} if expect_content else None - ) - assert_message_in_logs( - logs[0], "gen_ai.system.message", system_message, spans[0] - ) - - user_message = ( - {"content": "What's the weather in Seattle and San Francisco today?"} - if expect_content - else None - ) - assert_message_in_logs( - logs[1], "gen_ai.user.message", user_message, spans[0] - ) - - choice_event = { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ + if latest_experimental_enabled: + if expect_content: + # first call + assert_messages_attribute( + spans[0].attributes["gen_ai.input.messages"], + WEATHER_TOOL_EXPECTED_INPUT_MESSAGES, + ) + + first_output = [ { - "id": tool_call_ids[0], - "type": "function", - "function": { - "name": tool_names[0], - "arguments": tool_args[0].replace("\n", "") - if expect_content - else None, - }, - }, - { - "id": tool_call_ids[1], - "type": "function", - "function": { - "name": tool_names[1], - "arguments": tool_args[1].replace("\n", "") - if expect_content - else None, - }, - }, - ], - }, - } - assert_message_in_logs(logs[2], "gen_ai.choice", choice_event, spans[0]) + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": tool_call_ids[0], + "name": "get_current_weather", + "arguments": {"location": "Seattle, WA"}, + }, + { + "type": "tool_call", + "id": tool_call_ids[1], + "name": "get_current_weather", + "arguments": {"location": "San Francisco, CA"}, + }, + ], + "finish_reason": "tool_calls", + } + ] + assert_messages_attribute( + spans[0].attributes["gen_ai.output.messages"], first_output + ) + else: + assert len(logs) == 3 + system_message = ( + {"content": WEATHER_TOOL_PROMPT[0]["content"]} + if expect_content + else None + ) + assert_message_in_logs( + logs[0], "gen_ai.system.message", system_message, spans[0] + ) -def assert_message_in_logs(log, event_name, expected_content, parent_span): - assert log.log_record.event_name == event_name - assert ( - log.log_record.attributes[GenAIAttributes.GEN_AI_SYSTEM] - == GenAIAttributes.GenAiSystemValues.OPENAI.value - ) + user_message = ( + { + "content": "What's the weather in Seattle and San Francisco today?" + } + if expect_content + else None + ) + assert_message_in_logs( + logs[1], "gen_ai.user.message", user_message, spans[0] + ) - if not expected_content: - assert not log.log_record.body - else: - assert log.log_record.body - assert dict(log.log_record.body) == remove_none_values( - expected_content - ) - assert_log_parent(log, parent_span) - - -def get_current_weather_tool_definition(): - return { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "Get the current weather in a given location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g. Boston, MA", + choice_event = { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": tool_call_ids[0], + "type": "function", + "function": { + "name": tool_names[0], + "arguments": tool_args[0].replace("\n", "") + if expect_content + else None, + }, }, - }, - "required": ["location"], - "additionalProperties": False, + { + "id": tool_call_ids[1], + "type": "function", + "function": { + "name": tool_names[1], + "arguments": tool_args[1].replace("\n", "") + if expect_content + else None, + }, + }, + ], }, - }, - } + } + assert_message_in_logs( + logs[2], "gen_ai.choice", choice_event, spans[0] + ) def assert_no_invalid_type_warning(caplog): diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_metrics.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_metrics.py index ffcd99c5b4..a81b6094f5 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_metrics.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_chat_metrics.py @@ -1,12 +1,17 @@ import pytest +from tests.test_utils import DEFAULT_MODEL, USER_ONLY_PROMPT from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) from opentelemetry.semconv._incubating.metrics import gen_ai_metrics +from opentelemetry.util.genai.utils import is_experimental_mode _DURATION_BUCKETS = ( 0.01, @@ -42,15 +47,21 @@ ) -def assert_all_metric_attributes(data_point): +def assert_all_metric_attributes(data_point, latest_experimental_enabled): 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 + + provider_name_attr_name = ( + "gen_ai.provider.name" + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_SYSTEM + ) + assert provider_name_attr_name in data_point.attributes assert ( - data_point.attributes[GenAIAttributes.GEN_AI_SYSTEM] + data_point.attributes[provider_name_attr_name] == GenAIAttributes.GenAiSystemValues.OPENAI.value ) assert GenAIAttributes.GEN_AI_REQUEST_MODEL in data_point.attributes @@ -63,37 +74,44 @@ def assert_all_metric_attributes(data_point): 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" + + system_fingerprint_attr_key = ( + OpenAIAttributes.OPENAI_RESPONSE_SYSTEM_FINGERPRINT + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SYSTEM_FINGERPRINT ) - assert ( - GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER - in data_point.attributes + response_service_tier_attr_key = ( + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER ) + request_service_tier_attr_key = ( + OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER + ) + + assert system_fingerprint_attr_key in data_point.attributes assert ( - data_point.attributes[ - GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER - ] - == "default" + data_point.attributes[system_fingerprint_attr_key] == "fp_0ba0d124f1" ) + assert request_service_tier_attr_key not in data_point.attributes + assert response_service_tier_attr_key in data_point.attributes + assert data_point.attributes[response_service_tier_attr_key] == "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 + metric_reader, openai_client, instrument_with_content, vcr ): - 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 - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_chat_completion_metrics.yaml"): + openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) metrics = metric_reader.get_metrics_data().resource_metrics assert len(metrics) == 1 @@ -113,7 +131,7 @@ def test_chat_completion_metrics( duration_point = duration_metric.data.data_points[0] assert duration_point.sum > 0 - assert_all_metric_attributes(duration_point) + assert_all_metric_attributes(duration_point, latest_experimental_enabled) assert duration_point.explicit_bounds == _DURATION_BUCKETS token_usage_metric = next( @@ -140,7 +158,9 @@ def test_chat_completion_metrics( assert input_token_usage.explicit_bounds == _TOKEN_USAGE_BUCKETS assert input_token_usage.bucket_counts[2] == 1 - assert_all_metric_attributes(input_token_usage) + assert_all_metric_attributes( + input_token_usage, latest_experimental_enabled + ) output_token_usage = next( ( @@ -155,20 +175,20 @@ def test_chat_completion_metrics( 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) + assert_all_metric_attributes( + output_token_usage, latest_experimental_enabled + ) -@pytest.mark.vcr() @pytest.mark.asyncio() async def test_async_chat_completion_metrics( - metric_reader, async_openai_client, instrument_with_content + metric_reader, async_openai_client, instrument_with_content, vcr ): - 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 - ) + latest_experimental_enabled = is_experimental_mode() + with vcr.use_cassette("test_async_chat_completion_metrics.yaml"): + await async_openai_client.chat.completions.create( + messages=USER_ONLY_PROMPT, model=DEFAULT_MODEL, stream=False + ) metrics = metric_reader.get_metrics_data().resource_metrics assert len(metrics) == 1 @@ -186,7 +206,9 @@ async def test_async_chat_completion_metrics( ) 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]) + assert_all_metric_attributes( + duration_metric.data.data_points[0], latest_experimental_enabled + ) token_usage_metric = next( ( @@ -210,7 +232,9 @@ async def test_async_chat_completion_metrics( assert input_token_usage is not None assert input_token_usage.sum == 12 - assert_all_metric_attributes(input_token_usage) + assert_all_metric_attributes( + input_token_usage, latest_experimental_enabled + ) output_token_usage = next( ( @@ -224,4 +248,6 @@ async def test_async_chat_completion_metrics( assert output_token_usage is not None assert output_token_usage.sum == 12 - assert_all_metric_attributes(output_token_usage) + assert_all_metric_attributes( + output_token_usage, latest_experimental_enabled + ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_embeddings.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_embeddings.py index 184d372def..5f6f063119 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_embeddings.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_embeddings.py @@ -27,7 +27,6 @@ except ImportError: not_given = NOT_GIVEN -from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.semconv._incubating.attributes import ( error_attributes as ErrorAttributes, ) @@ -38,52 +37,67 @@ server_attributes as ServerAttributes, ) from opentelemetry.semconv._incubating.metrics import gen_ai_metrics +from opentelemetry.util.genai.utils import is_experimental_mode -from .test_utils import assert_all_attributes +from .test_utils import ( + DEFAULT_EMBEDDING_MODEL, + assert_all_attributes, + assert_embedding_attributes, +) -@pytest.mark.vcr() def test_embeddings_no_content( - span_exporter, log_exporter, openai_client, instrument_no_content + span_exporter, log_exporter, openai_client, instrument_no_content, vcr ): """Test creating embeddings with content capture disabled""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for embeddings" - response = openai_client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_embeddings_no_content.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # No logs should be emitted when content capture is disabled logs = log_exporter.get_finished_logs() assert len(logs) == 0 -@pytest.mark.vcr() def test_embeddings_with_dimensions( - span_exporter, metric_reader, openai_client, instrument_no_content + span_exporter, metric_reader, openai_client, instrument_no_content, vcr ): """Test creating embeddings with custom dimensions parameter""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for embeddings with dimensions" dimensions = 512 # Using a smaller dimension than default - response = openai_client.embeddings.create( - model=model_name, - input=input_text, - dimensions=dimensions, - ) + with vcr.use_cassette("test_embeddings_with_dimensions.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + dimensions=dimensions, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify dimensions attribute is set correctly assert ( @@ -120,51 +134,62 @@ def test_embeddings_with_dimensions( assert False, "Dimensions attribute not found in metrics" -@pytest.mark.vcr() def test_embeddings_with_batch_input( - span_exporter, metric_reader, openai_client, instrument_with_content + span_exporter, metric_reader, openai_client, instrument_with_content, vcr ): """Test creating embeddings with batch input (list of strings)""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() + input_texts = [ "This is the first test string for embeddings", "This is the second test string for embeddings", "This is the third test string for embeddings", ] - response = openai_client.embeddings.create( - model=model_name, - input=input_texts, - ) + with vcr.use_cassette("test_embeddings_with_batch_input.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_texts, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify results contain the same number of embeddings as input texts assert len(response.data) == len(input_texts) -@pytest.mark.vcr() def test_embeddings_with_encoding_format( - span_exporter, metric_reader, openai_client, instrument_no_content + span_exporter, metric_reader, openai_client, instrument_no_content, vcr ): """Test creating embeddings with different encoding format""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for embeddings with encoding format" encoding_format = "base64" - response = openai_client.embeddings.create( - model=model_name, - input=input_text, - encoding_format=encoding_format, - ) + with vcr.use_cassette("test_embeddings_with_encoding_format.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + encoding_format=encoding_format, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify encoding_format attribute is set correctly assert spans[0].attributes["gen_ai.request.encoding_formats"] == ( @@ -173,55 +198,63 @@ def test_embeddings_with_encoding_format( @pytest.mark.parametrize("not_given_value", [NOT_GIVEN, not_given]) -@pytest.mark.vcr() def test_embeddings_with_not_given_values( span_exporter, metric_reader, openai_client, instrument_no_content, not_given_value, + vcr, ): """Test creating embeddings with NOT_GIVEN and not_given values""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() + input_text = "This is a test for embeddings with encoding format" - response = openai_client.embeddings.create( - model=model_name, - input=input_text, - dimensions=not_given_value, - ) + with vcr.use_cassette("test_embeddings_with_not_given_values.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + dimensions=not_given_value, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) assert "gen_ai.request.dimensions" not in spans[0].attributes -@pytest.mark.vcr() def test_embeddings_bad_endpoint( - span_exporter, metric_reader, instrument_no_content + span_exporter, metric_reader, instrument_no_content, vcr ): """Test error handling for bad endpoint""" - model_name = "text-embedding-3-small" + latest_experimental_enabled = is_experimental_mode() input_text = "This is a test for embeddings with bad endpoint" client = OpenAI(base_url="http://localhost:4242") - with pytest.raises(APIConnectionError): - client.embeddings.create( - model=model_name, - input=input_text, - timeout=0.1, - ) + with vcr.use_cassette("test_embeddings_bad_endpoint.yaml"): + with pytest.raises(APIConnectionError): + client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + timeout=0.1, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 assert_all_attributes( spans[0], - model_name, + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, operation_name="embeddings", server_address="localhost", ) @@ -253,24 +286,30 @@ def test_embeddings_bad_endpoint( ) -@pytest.mark.vcr() def test_embeddings_model_not_found( - span_exporter, metric_reader, openai_client, instrument_no_content + span_exporter, metric_reader, openai_client, instrument_no_content, vcr ): """Test error handling for non-existent model""" + latest_experimental_enabled = is_experimental_mode() model_name = "non-existent-embedding-model" input_text = "This is a test for embeddings with bad model" - with pytest.raises(NotFoundError): - openai_client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_embeddings_model_not_found.yaml"): + with pytest.raises(NotFoundError): + openai_client.embeddings.create( + model=model_name, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_all_attributes(spans[0], model_name, operation_name="embeddings") + assert_all_attributes( + spans[0], + model_name, + latest_experimental_enabled, + operation_name="embeddings", + ) assert "NotFoundError" == spans[0].attributes[ErrorAttributes.ERROR_TYPE] # Verify metrics @@ -296,23 +335,30 @@ def test_embeddings_model_not_found( ) -@pytest.mark.vcr() def test_embeddings_token_metrics( - span_exporter, metric_reader, openai_client, instrument_no_content + span_exporter, metric_reader, openai_client, instrument_no_content, vcr ): """Test that token usage metrics are correctly recorded""" - model_name = "text-embedding-3-small" + + latest_experimental_enabled = is_experimental_mode() + input_text = "This is a test for embeddings token metrics" - response = openai_client.embeddings.create( - model=model_name, - input=input_text, - ) + with vcr.use_cassette("test_embeddings_token_metrics.yaml"): + response = openai_client.embeddings.create( + model=DEFAULT_EMBEDDING_MODEL, + input=input_text, + ) # Verify spans spans = span_exporter.get_finished_spans() assert len(spans) == 1 - assert_embedding_attributes(spans[0], model_name, response) + assert_embedding_attributes( + spans[0], + DEFAULT_EMBEDDING_MODEL, + latest_experimental_enabled, + response, + ) # Verify metrics metrics = metric_reader.get_metrics_data().resource_metrics @@ -356,31 +402,3 @@ def test_embeddings_token_metrics( # Verify the token counts match what was reported in the response assert input_token_point.sum == response.usage.prompt_tokens - - -def assert_embedding_attributes( - span: ReadableSpan, - request_model: str, - response, -): - """Assert that the span contains all required attributes for embeddings operation""" - # Use the common assertion function - assert_all_attributes( - span, - request_model, - response_id=None, # Embeddings don't have a response ID - response_model=response.model, - input_tokens=response.usage.prompt_tokens, - operation_name="embeddings", - server_address="api.openai.com", - ) - - # Assert embeddings-specific attributes - if ( - hasattr(span, "attributes") - and "gen_ai.embeddings.dimension.count" in span.attributes - ): - # If dimensions were specified, verify that they match the actual dimensions - assert span.attributes["gen_ai.embeddings.dimension.count"] == len( - response.data[0].embedding - ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_utils.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_utils.py index d4d64c4027..211fa3739d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/test_utils.py @@ -14,16 +14,62 @@ """Shared test utilities for OpenAI instrumentation tests.""" -from typing import Optional +import json +from typing import Any, Optional from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) +DEFAULT_MODEL = "gpt-4o-mini" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" +USER_ONLY_PROMPT = [{"role": "user", "content": "Say this is a test"}] +USER_ONLY_EXPECTED_INPUT_MESSAGES = [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": USER_ONLY_PROMPT[0]["content"], + } + ], + } +] +WEATHER_TOOL_PROMPT = [ + {"role": "system", "content": "You're a helpful assistant."}, + { + "role": "user", + "content": "What's the weather in Seattle and San Francisco today?", + }, +] +WEATHER_TOOL_EXPECTED_INPUT_MESSAGES = [ + { + "role": "system", + "parts": [ + { + "type": "text", + "content": WEATHER_TOOL_PROMPT[0]["content"], + } + ], + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": WEATHER_TOOL_PROMPT[1]["content"], + } + ], + }, +] + def _assert_optional_attribute(span, attribute_name, expected_value): """Helper to assert optional span attributes.""" @@ -36,6 +82,7 @@ def _assert_optional_attribute(span, attribute_name, expected_value): def assert_all_attributes( span: ReadableSpan, request_model: str, + latest_experimental_enabled: bool, response_id: str = None, response_model: str = None, input_tokens: Optional[int] = None, @@ -51,9 +98,16 @@ def assert_all_attributes( operation_name == span.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] ) + + provider_name_attr_name = ( + "gen_ai.provider.name" + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_SYSTEM + ) + assert ( - GenAIAttributes.GenAiSystemValues.OPENAI.value - == span.attributes[GenAIAttributes.GEN_AI_SYSTEM] + GenAIAttributes.GenAiProviderNameValues.OPENAI.value + == span.attributes[provider_name_attr_name] ) assert ( request_model == span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] @@ -73,18 +127,28 @@ def assert_all_attributes( ) assert server_address == span.attributes[ServerAttributes.SERVER_ADDRESS] - if server_port != 443 and server_port > 0: assert server_port == span.attributes[ServerAttributes.SERVER_PORT] + request_service_tier_attr_name = ( + OpenAIAttributes.OPENAI_REQUEST_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER + ) _assert_optional_attribute( span, - GenAIAttributes.GEN_AI_OPENAI_REQUEST_SERVICE_TIER, + request_service_tier_attr_name, request_service_tier, ) + + response_service_tier_attr_name = ( + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER + if latest_experimental_enabled + else GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER + ) _assert_optional_attribute( span, - GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER, + response_service_tier_attr_name, response_service_tier, ) @@ -99,6 +163,27 @@ def assert_log_parent(log, span): ) +def get_current_weather_tool_definition(): + return { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. Boston, MA", + }, + }, + "required": ["location"], + "additionalProperties": False, + }, + }, + } + + def remove_none_values(body): """Remove None values from a dictionary recursively""" result = {} @@ -115,3 +200,92 @@ def remove_none_values(body): else: result[key] = value return result + + +def assert_completion_attributes( + span: ReadableSpan, + request_model: str, + response: Any, + latest_experimental_enabled: bool, + operation_name: str = "chat", + server_address: str = "api.openai.com", +): + return assert_all_attributes( + span, + request_model, + latest_experimental_enabled, + response.id, + response.model, + response.usage.prompt_tokens, + response.usage.completion_tokens, + operation_name, + server_address, + ) + + +def assert_messages_attribute(actual, expected): + assert json.loads(actual) == expected + + +def format_simple_expected_output_message( + content: str, finish_reason: str = "stop" +): + return [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": content, + } + ], + "finish_reason": finish_reason, + } + ] + + +def assert_message_in_logs(log, event_name, expected_content, parent_span): + assert log.log_record.event_name == event_name + assert ( + log.log_record.attributes[GenAIAttributes.GEN_AI_SYSTEM] + == GenAIAttributes.GenAiSystemValues.OPENAI.value + ) + + if not expected_content: + assert not log.log_record.body + else: + assert log.log_record.body + assert dict(log.log_record.body) == remove_none_values( + expected_content + ) + assert_log_parent(log, parent_span) + + +def assert_embedding_attributes( + span: ReadableSpan, + request_model: str, + latest_experimental_enabled: bool, + response, +): + """Assert that the span contains all required attributes for embeddings operation""" + # Use the common assertion function + assert_all_attributes( + span, + request_model, + latest_experimental_enabled, + response_id=None, # Embeddings don't have a response ID + response_model=response.model, + input_tokens=response.usage.prompt_tokens, + operation_name="embeddings", + server_address="api.openai.com", + ) + + # Assert embeddings-specific attributes + if ( + hasattr(span, "attributes") + and "gen_ai.embeddings.dimension.count" in span.attributes + ): + # If dimensions were specified, verify that they match the actual dimensions + assert span.attributes["gen_ai.embeddings.dimension.count"] == len( + response.data[0].embedding + ) diff --git a/uv.lock b/uv.lock index d75bd7e73c..6b2e6e21ed 100644 --- a/uv.lock +++ b/uv.lock @@ -3574,6 +3574,7 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, ] [package.optional-dependencies] @@ -3587,6 +3588,7 @@ requires-dist = [ { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, ] provides-extras = ["instruments"] From 9b71c46eafafb519edea65c40bc037c9c950051f Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 4 Mar 2026 14:45:46 +0100 Subject: [PATCH 22/41] CHANGELOG: fix stale action entry (#4291) --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb7aefc82..b7d5ed08ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Add stale PR GitHub Action - ([#4220](https://github.com/open-telemetry/opentelemetry-python/pull/4220)) - ### Added - Add Python 3.14 support @@ -62,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4139](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4139)) - `opentelemetry-instrumentation-logging`: Move there the SDK LoggingHandler ([#4210](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4210)) +- Add stale PR GitHub Action + ([#4220](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4220)) ### Fixed From a2612bad543f87df466e395df9ace56df91cea13 Mon Sep 17 00:00:00 2001 From: "otelbot[bot]" <197425009+otelbot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:05:55 +0000 Subject: [PATCH 23/41] Update version to 1.41.0.dev/0.62b0.dev (#4294) * Update version to 1.41.0.dev/0.62b0.dev * Apply suggestions from code review --------- Co-authored-by: otelbot <197425009+otelbot@users.noreply.github.com> Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 2 + _template/version.py | 2 +- eachdist.ini | 4 +- .../gcp_credential_provider/version.py | 2 +- .../prometheus_remote_write/version.py | 2 +- .../pyproject.toml | 2 +- .../exporter/richconsole/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/aio_pika/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/aiohttp_client/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/aiohttp_server/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/aiokafka/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/aiopg/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/asgi/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/asyncclick/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/asyncio/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/asyncpg/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/aws_lambda/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/boto/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/boto3sqs/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/botocore/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/cassandra/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/celery/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/click/version.py | 2 +- .../pyproject.toml | 2 +- .../confluent_kafka/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/dbapi/version.py | 2 +- .../pyproject.toml | 10 +- .../instrumentation/django/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/elasticsearch/version.py | 2 +- .../pyproject.toml | 8 +- .../instrumentation/falcon/version.py | 2 +- .../pyproject.toml | 8 +- .../instrumentation/fastapi/version.py | 2 +- .../pyproject.toml | 8 +- .../instrumentation/flask/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/grpc/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/httpx/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/jinja2/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/kafka/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/logging/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/mysql/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/mysqlclient/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/pika/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/psycopg/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/psycopg2/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/pymemcache/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/pymongo/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/pymssql/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/pymysql/version.py | 2 +- .../pyproject.toml | 8 +- .../instrumentation/pyramid/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/redis/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/remoulade/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/requests/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/sqlalchemy/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/sqlite3/version.py | 2 +- .../pyproject.toml | 8 +- .../instrumentation/starlette/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/system_metrics/version.py | 2 +- .../pyproject.toml | 2 +- .../instrumentation/threading/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/tornado/version.py | 2 +- .../pyproject.toml | 4 +- .../instrumentation/tortoiseorm/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/urllib/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/urllib3/version.py | 2 +- .../pyproject.toml | 6 +- .../instrumentation/wsgi/version.py | 2 +- .../pyproject.toml | 102 ++++++++--------- .../contrib-instrumentations/version.py | 2 +- opentelemetry-distro/pyproject.toml | 4 +- .../src/opentelemetry/distro/version.py | 2 +- opentelemetry-instrumentation/pyproject.toml | 2 +- .../instrumentation/bootstrap_gen.py | 108 +++++++++--------- .../opentelemetry/instrumentation/version.py | 2 +- .../processor/baggage/version.py | 2 +- .../propagators/ot_trace/version.py | 2 +- .../resource/detector/containerid/version.py | 2 +- .../src/opentelemetry/util/http/version.py | 2 +- 120 files changed, 292 insertions(+), 290 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d5ed08ef..61e4827020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## Version 1.40.0/0.61b0 (2026-03-04) + ### Added - Add Python 3.14 support diff --git a/_template/version.py b/_template/version.py index c099e9440e..ed89ddd1cc 100644 --- a/_template/version.py +++ b/_template/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/eachdist.ini b/eachdist.ini index f570632bd7..cc2419975e 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -16,7 +16,7 @@ sortfirst= ext/* [stable] -version=1.40.0.dev +version=1.41.0.dev packages= opentelemetry-sdk @@ -35,7 +35,7 @@ packages= opentelemetry-exporter-credential-provider-gcp [prerelease] -version=0.61b0.dev +version=0.62b0.dev packages= all diff --git a/exporter/opentelemetry-exporter-credential-provider-gcp/src/opentelemetry/gcp_credential_provider/version.py b/exporter/opentelemetry-exporter-credential-provider-gcp/src/opentelemetry/gcp_credential_provider/version.py index c099e9440e..ed89ddd1cc 100644 --- a/exporter/opentelemetry-exporter-credential-provider-gcp/src/opentelemetry/gcp_credential_provider/version.py +++ b/exporter/opentelemetry-exporter-credential-provider-gcp/src/opentelemetry/gcp_credential_provider/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py index c099e9440e..ed89ddd1cc 100644 --- a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/exporter/opentelemetry-exporter-richconsole/pyproject.toml b/exporter/opentelemetry-exporter-richconsole/pyproject.toml index 976157b816..57c16a81d0 100644 --- a/exporter/opentelemetry-exporter-richconsole/pyproject.toml +++ b/exporter/opentelemetry-exporter-richconsole/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ dependencies = [ "opentelemetry-api ~= 1.12", "opentelemetry-sdk ~= 1.12", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "rich>=10.0.0", ] diff --git a/exporter/opentelemetry-exporter-richconsole/src/opentelemetry/exporter/richconsole/version.py b/exporter/opentelemetry-exporter-richconsole/src/opentelemetry/exporter/richconsole/version.py index c099e9440e..ed89ddd1cc 100644 --- a/exporter/opentelemetry-exporter-richconsole/src/opentelemetry/exporter/richconsole/version.py +++ b/exporter/opentelemetry-exporter-richconsole/src/opentelemetry/exporter/richconsole/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aio-pika/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aio-pika/pyproject.toml index 17a109f436..0f777b75e4 100644 --- a/instrumentation/opentelemetry-instrumentation-aio-pika/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aio-pika/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.5", - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-aio-pika/src/opentelemetry/instrumentation/aio_pika/version.py b/instrumentation/opentelemetry-instrumentation-aio-pika/src/opentelemetry/instrumentation/aio_pika/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-aio-pika/src/opentelemetry/instrumentation/aio_pika/version.py +++ b/instrumentation/opentelemetry-instrumentation-aio-pika/src/opentelemetry/instrumentation/aio_pika/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aiohttp-client/pyproject.toml index 6a11c89c6d..8f2c02638b 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/version.py b/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/version.py index a618d89e49..1fbc44b3e3 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/version.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aiohttp-server/pyproject.toml index bc47c79b74..ce36611a33 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/version.py b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/version.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aiokafka/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aiokafka/pyproject.toml index 59212d39b7..3bd3cc2551 100644 --- a/instrumentation/opentelemetry-instrumentation-aiokafka/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aiokafka/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.27", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "typing_extensions ~= 4.1", ] diff --git a/instrumentation/opentelemetry-instrumentation-aiokafka/src/opentelemetry/instrumentation/aiokafka/version.py b/instrumentation/opentelemetry-instrumentation-aiokafka/src/opentelemetry/instrumentation/aiokafka/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-aiokafka/src/opentelemetry/instrumentation/aiokafka/version.py +++ b/instrumentation/opentelemetry-instrumentation-aiokafka/src/opentelemetry/instrumentation/aiokafka/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aiopg/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aiopg/pyproject.toml index a20e23c106..204f10ecc8 100644 --- a/instrumentation/opentelemetry-instrumentation-aiopg/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aiopg/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-aiopg/src/opentelemetry/instrumentation/aiopg/version.py b/instrumentation/opentelemetry-instrumentation-aiopg/src/opentelemetry/instrumentation/aiopg/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-aiopg/src/opentelemetry/instrumentation/aiopg/version.py +++ b/instrumentation/opentelemetry-instrumentation-aiopg/src/opentelemetry/instrumentation/aiopg/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-asgi/pyproject.toml b/instrumentation/opentelemetry-instrumentation-asgi/pyproject.toml index 359fe6b0f8..982de74d46 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-asgi/pyproject.toml @@ -28,9 +28,9 @@ classifiers = [ dependencies = [ "asgiref ~= 3.0", "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/version.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/version.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-asyncclick/pyproject.toml b/instrumentation/opentelemetry-instrumentation-asyncclick/pyproject.toml index d1706f421e..ffc9999d12 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncclick/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-asyncclick/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt ~= 1.0", "typing_extensions ~= 4.12", ] diff --git a/instrumentation/opentelemetry-instrumentation-asyncclick/src/opentelemetry/instrumentation/asyncclick/version.py b/instrumentation/opentelemetry-instrumentation-asyncclick/src/opentelemetry/instrumentation/asyncclick/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncclick/src/opentelemetry/instrumentation/asyncclick/version.py +++ b/instrumentation/opentelemetry-instrumentation-asyncclick/src/opentelemetry/instrumentation/asyncclick/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-asyncio/pyproject.toml b/instrumentation/opentelemetry-instrumentation-asyncio/pyproject.toml index a35bedeb4c..16917716fe 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncio/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-asyncio/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.14", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/version.py b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/version.py +++ b/instrumentation/opentelemetry-instrumentation-asyncio/src/opentelemetry/instrumentation/asyncio/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-asyncpg/pyproject.toml b/instrumentation/opentelemetry-instrumentation-asyncpg/pyproject.toml index 37b404bb7d..adb72936a3 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncpg/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-asyncpg/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-asyncpg/src/opentelemetry/instrumentation/asyncpg/version.py b/instrumentation/opentelemetry-instrumentation-asyncpg/src/opentelemetry/instrumentation/asyncpg/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncpg/src/opentelemetry/instrumentation/asyncpg/version.py +++ b/instrumentation/opentelemetry-instrumentation-asyncpg/src/opentelemetry/instrumentation/asyncpg/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-aws-lambda/pyproject.toml b/instrumentation/opentelemetry-instrumentation-aws-lambda/pyproject.toml index c185dfe46e..3afe9b2131 100644 --- a/instrumentation/opentelemetry-instrumentation-aws-lambda/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-aws-lambda/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "opentelemetry-propagator-aws-xray ~= 1.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/version.py b/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/version.py +++ b/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-boto/pyproject.toml b/instrumentation/opentelemetry-instrumentation-boto/pyproject.toml index f7e855eef1..c3c20bf8ea 100644 --- a/instrumentation/opentelemetry-instrumentation-boto/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-boto/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-boto/src/opentelemetry/instrumentation/boto/version.py b/instrumentation/opentelemetry-instrumentation-boto/src/opentelemetry/instrumentation/boto/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-boto/src/opentelemetry/instrumentation/boto/version.py +++ b/instrumentation/opentelemetry-instrumentation-boto/src/opentelemetry/instrumentation/boto/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-boto3sqs/pyproject.toml b/instrumentation/opentelemetry-instrumentation-boto3sqs/pyproject.toml index d9b2e38d3b..4c8d569003 100644 --- a/instrumentation/opentelemetry-instrumentation-boto3sqs/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-boto3sqs/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-boto3sqs/src/opentelemetry/instrumentation/boto3sqs/version.py b/instrumentation/opentelemetry-instrumentation-boto3sqs/src/opentelemetry/instrumentation/boto3sqs/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-boto3sqs/src/opentelemetry/instrumentation/boto3sqs/version.py +++ b/instrumentation/opentelemetry-instrumentation-boto3sqs/src/opentelemetry/instrumentation/boto3sqs/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-botocore/pyproject.toml b/instrumentation/opentelemetry-instrumentation-botocore/pyproject.toml index 9dad8cc7b7..484e1a8cb2 100644 --- a/instrumentation/opentelemetry-instrumentation-botocore/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-botocore/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "opentelemetry-propagator-aws-xray ~= 1.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/version.py b/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/version.py +++ b/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-cassandra/pyproject.toml b/instrumentation/opentelemetry-instrumentation-cassandra/pyproject.toml index bca03b9c99..4c7c92203f 100644 --- a/instrumentation/opentelemetry-instrumentation-cassandra/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-cassandra/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-cassandra/src/opentelemetry/instrumentation/cassandra/version.py b/instrumentation/opentelemetry-instrumentation-cassandra/src/opentelemetry/instrumentation/cassandra/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-cassandra/src/opentelemetry/instrumentation/cassandra/version.py +++ b/instrumentation/opentelemetry-instrumentation-cassandra/src/opentelemetry/instrumentation/cassandra/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-celery/pyproject.toml b/instrumentation/opentelemetry-instrumentation-celery/pyproject.toml index e505a9afd6..7a105fa439 100644 --- a/instrumentation/opentelemetry-instrumentation-celery/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-celery/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-celery/src/opentelemetry/instrumentation/celery/version.py b/instrumentation/opentelemetry-instrumentation-celery/src/opentelemetry/instrumentation/celery/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-celery/src/opentelemetry/instrumentation/celery/version.py +++ b/instrumentation/opentelemetry-instrumentation-celery/src/opentelemetry/instrumentation/celery/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-click/pyproject.toml b/instrumentation/opentelemetry-instrumentation-click/pyproject.toml index 2844eb13a9..6d7c502547 100644 --- a/instrumentation/opentelemetry-instrumentation-click/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-click/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-click/src/opentelemetry/instrumentation/click/version.py b/instrumentation/opentelemetry-instrumentation-click/src/opentelemetry/instrumentation/click/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-click/src/opentelemetry/instrumentation/click/version.py +++ b/instrumentation/opentelemetry-instrumentation-click/src/opentelemetry/instrumentation/click/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-confluent-kafka/pyproject.toml b/instrumentation/opentelemetry-instrumentation-confluent-kafka/pyproject.toml index aeb659ccc3..d28eb98593 100644 --- a/instrumentation/opentelemetry-instrumentation-confluent-kafka/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-confluent-kafka/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "opentelemetry-api ~= 1.12", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/version.py b/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/version.py +++ b/instrumentation/opentelemetry-instrumentation-confluent-kafka/src/opentelemetry/instrumentation/confluent_kafka/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-dbapi/pyproject.toml b/instrumentation/opentelemetry-instrumentation-dbapi/pyproject.toml index c5a0600029..ade1be720e 100644 --- a/instrumentation/opentelemetry-instrumentation-dbapi/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-dbapi/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/version.py b/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/version.py index 076fd05e70..46b492f255 100644 --- a/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/version.py +++ b/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/version.py @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" _instruments = tuple() diff --git a/instrumentation/opentelemetry-instrumentation-django/pyproject.toml b/instrumentation/opentelemetry-instrumentation-django/pyproject.toml index a3eaee94f1..35df0c42de 100644 --- a/instrumentation/opentelemetry-instrumentation-django/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-django/pyproject.toml @@ -27,15 +27,15 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-wsgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-wsgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] asgi = [ - "opentelemetry-instrumentation-asgi == 0.61b0.dev", + "opentelemetry-instrumentation-asgi == 0.62b0.dev", ] instruments = [ "django >= 2.0", diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/version.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/version.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-elasticsearch/pyproject.toml b/instrumentation/opentelemetry-instrumentation-elasticsearch/pyproject.toml index eccf7b2e1b..9780e16b10 100644 --- a/instrumentation/opentelemetry-instrumentation-elasticsearch/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-elasticsearch/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-elasticsearch/src/opentelemetry/instrumentation/elasticsearch/version.py b/instrumentation/opentelemetry-instrumentation-elasticsearch/src/opentelemetry/instrumentation/elasticsearch/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-elasticsearch/src/opentelemetry/instrumentation/elasticsearch/version.py +++ b/instrumentation/opentelemetry-instrumentation-elasticsearch/src/opentelemetry/instrumentation/elasticsearch/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-falcon/pyproject.toml b/instrumentation/opentelemetry-instrumentation-falcon/pyproject.toml index db60516735..460e50ba85 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-falcon/pyproject.toml @@ -27,10 +27,10 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-wsgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-wsgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "packaging >= 20.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/version.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/version.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/pyproject.toml b/instrumentation/opentelemetry-instrumentation-fastapi/pyproject.toml index b8ae7d0804..ca9450f6db 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-fastapi/pyproject.toml @@ -27,10 +27,10 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-asgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-asgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/version.py b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/version.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-flask/pyproject.toml b/instrumentation/opentelemetry-instrumentation-flask/pyproject.toml index 4abef93e4e..ff7d3b60e7 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-flask/pyproject.toml @@ -27,10 +27,10 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-wsgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-wsgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "packaging >= 21.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/version.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/version.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-grpc/pyproject.toml b/instrumentation/opentelemetry-instrumentation-grpc/pyproject.toml index 67b33e64fd..9d6160919a 100644 --- a/instrumentation/opentelemetry-instrumentation-grpc/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-grpc/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-grpc/src/opentelemetry/instrumentation/grpc/version.py b/instrumentation/opentelemetry-instrumentation-grpc/src/opentelemetry/instrumentation/grpc/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-grpc/src/opentelemetry/instrumentation/grpc/version.py +++ b/instrumentation/opentelemetry-instrumentation-grpc/src/opentelemetry/instrumentation/grpc/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml b/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml index bf09909a3c..2d09960f4c 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-jinja2/pyproject.toml b/instrumentation/opentelemetry-instrumentation-jinja2/pyproject.toml index 1d11a19c95..c2b6d2592e 100644 --- a/instrumentation/opentelemetry-instrumentation-jinja2/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-jinja2/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/version.py b/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/version.py +++ b/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml b/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml index c1b1e7b8fc..f84b48299b 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.5", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/version.py b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/version.py +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-logging/pyproject.toml b/instrumentation/opentelemetry-instrumentation-logging/pyproject.toml index 38fdf3dbc5..71a9b31e4e 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-logging/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/version.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/version.py index 076fd05e70..46b492f255 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/version.py +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/version.py @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" _instruments = tuple() diff --git a/instrumentation/opentelemetry-instrumentation-mysql/pyproject.toml b/instrumentation/opentelemetry-instrumentation-mysql/pyproject.toml index de65599a12..bd143731bc 100644 --- a/instrumentation/opentelemetry-instrumentation-mysql/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-mysql/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-mysql/src/opentelemetry/instrumentation/mysql/version.py b/instrumentation/opentelemetry-instrumentation-mysql/src/opentelemetry/instrumentation/mysql/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-mysql/src/opentelemetry/instrumentation/mysql/version.py +++ b/instrumentation/opentelemetry-instrumentation-mysql/src/opentelemetry/instrumentation/mysql/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-mysqlclient/pyproject.toml b/instrumentation/opentelemetry-instrumentation-mysqlclient/pyproject.toml index fd100ba451..ac54f38f84 100644 --- a/instrumentation/opentelemetry-instrumentation-mysqlclient/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-mysqlclient/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-mysqlclient/src/opentelemetry/instrumentation/mysqlclient/version.py b/instrumentation/opentelemetry-instrumentation-mysqlclient/src/opentelemetry/instrumentation/mysqlclient/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-mysqlclient/src/opentelemetry/instrumentation/mysqlclient/version.py +++ b/instrumentation/opentelemetry-instrumentation-mysqlclient/src/opentelemetry/instrumentation/mysqlclient/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pika/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pika/pyproject.toml index 60d446294d..1658d67100 100644 --- a/instrumentation/opentelemetry-instrumentation-pika/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pika/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "opentelemetry-api ~= 1.5", "packaging >= 20.0", "wrapt >= 1.0.0, < 2.0.0", diff --git a/instrumentation/opentelemetry-instrumentation-pika/src/opentelemetry/instrumentation/pika/version.py b/instrumentation/opentelemetry-instrumentation-pika/src/opentelemetry/instrumentation/pika/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pika/src/opentelemetry/instrumentation/pika/version.py +++ b/instrumentation/opentelemetry-instrumentation-pika/src/opentelemetry/instrumentation/pika/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-psycopg/pyproject.toml b/instrumentation/opentelemetry-instrumentation-psycopg/pyproject.toml index f97fc8b8c8..910619d389 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-psycopg/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-psycopg/src/opentelemetry/instrumentation/psycopg/version.py b/instrumentation/opentelemetry-instrumentation-psycopg/src/opentelemetry/instrumentation/psycopg/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg/src/opentelemetry/instrumentation/psycopg/version.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg/src/opentelemetry/instrumentation/psycopg/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml b/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml index 813ca2be82..c18c5bb977 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/version.py b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/version.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pymemcache/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pymemcache/pyproject.toml index e0dad43eac..aa34660048 100644 --- a/instrumentation/opentelemetry-instrumentation-pymemcache/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pymemcache/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-pymemcache/src/opentelemetry/instrumentation/pymemcache/version.py b/instrumentation/opentelemetry-instrumentation-pymemcache/src/opentelemetry/instrumentation/pymemcache/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pymemcache/src/opentelemetry/instrumentation/pymemcache/version.py +++ b/instrumentation/opentelemetry-instrumentation-pymemcache/src/opentelemetry/instrumentation/pymemcache/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pymongo/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pymongo/pyproject.toml index 4b908846e1..f714361b0e 100644 --- a/instrumentation/opentelemetry-instrumentation-pymongo/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pymongo/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/version.py b/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/version.py +++ b/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pymssql/pyproject.toml index 0de68f47bf..317b68f5ad 100644 --- a/instrumentation/opentelemetry-instrumentation-pymssql/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pymssql/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py +++ b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pymysql/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pymysql/pyproject.toml index aa880ebd0b..1310aec93f 100644 --- a/instrumentation/opentelemetry-instrumentation-pymysql/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pymysql/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-pymysql/src/opentelemetry/instrumentation/pymysql/version.py b/instrumentation/opentelemetry-instrumentation-pymysql/src/opentelemetry/instrumentation/pymysql/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pymysql/src/opentelemetry/instrumentation/pymysql/version.py +++ b/instrumentation/opentelemetry-instrumentation-pymysql/src/opentelemetry/instrumentation/pymysql/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/pyproject.toml b/instrumentation/opentelemetry-instrumentation-pyramid/pyproject.toml index 5df20b8ea4..2f742f0e5b 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-pyramid/pyproject.toml @@ -28,10 +28,10 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-wsgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-wsgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/version.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/version.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml b/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml index f218d5fc68..77a2496ba6 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.12.1", ] diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/version.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/version.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-remoulade/pyproject.toml b/instrumentation/opentelemetry-instrumentation-remoulade/pyproject.toml index fa08c7987a..9aac495d33 100644 --- a/instrumentation/opentelemetry-instrumentation-remoulade/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-remoulade/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-remoulade/src/opentelemetry/instrumentation/remoulade/version.py b/instrumentation/opentelemetry-instrumentation-remoulade/src/opentelemetry/instrumentation/remoulade/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-remoulade/src/opentelemetry/instrumentation/remoulade/version.py +++ b/instrumentation/opentelemetry-instrumentation-remoulade/src/opentelemetry/instrumentation/remoulade/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-requests/pyproject.toml b/instrumentation/opentelemetry-instrumentation-requests/pyproject.toml index c8f55a4143..445e42e327 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-requests/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/version.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/version.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/pyproject.toml b/instrumentation/opentelemetry-instrumentation-sqlalchemy/pyproject.toml index 34a0f8889c..c6a4c570b5 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "packaging >= 21.0", "wrapt >= 1.11.2", ] diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/version.py b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/version.py +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-sqlite3/pyproject.toml b/instrumentation/opentelemetry-instrumentation-sqlite3/pyproject.toml index 5c3738c078..eb08c3f8b9 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlite3/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-sqlite3/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-dbapi == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-dbapi == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-sqlite3/src/opentelemetry/instrumentation/sqlite3/version.py b/instrumentation/opentelemetry-instrumentation-sqlite3/src/opentelemetry/instrumentation/sqlite3/version.py index 076fd05e70..46b492f255 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlite3/src/opentelemetry/instrumentation/sqlite3/version.py +++ b/instrumentation/opentelemetry-instrumentation-sqlite3/src/opentelemetry/instrumentation/sqlite3/version.py @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" _instruments = tuple() diff --git a/instrumentation/opentelemetry-instrumentation-starlette/pyproject.toml b/instrumentation/opentelemetry-instrumentation-starlette/pyproject.toml index cf01db1db9..96fa61939b 100644 --- a/instrumentation/opentelemetry-instrumentation-starlette/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-starlette/pyproject.toml @@ -27,10 +27,10 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-instrumentation-asgi == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-instrumentation-asgi == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/version.py b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/version.py +++ b/instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-system-metrics/pyproject.toml b/instrumentation/opentelemetry-instrumentation-system-metrics/pyproject.toml index 37bfd69491..207f1a0307 100644 --- a/instrumentation/opentelemetry-instrumentation-system-metrics/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-system-metrics/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "opentelemetry-api ~= 1.11", "psutil >= 5.9.0, < 8", ] diff --git a/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/version.py b/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/version.py +++ b/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-threading/pyproject.toml b/instrumentation/opentelemetry-instrumentation-threading/pyproject.toml index 1be41371db..7f692ade89 100644 --- a/instrumentation/opentelemetry-instrumentation-threading/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-threading/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/version.py b/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/version.py +++ b/instrumentation/opentelemetry-instrumentation-threading/src/opentelemetry/instrumentation/threading/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-tornado/pyproject.toml b/instrumentation/opentelemetry-instrumentation-tornado/pyproject.toml index 31878bedbb..3dea8189e6 100644 --- a/instrumentation/opentelemetry-instrumentation-tornado/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-tornado/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-tornado/src/opentelemetry/instrumentation/tornado/version.py b/instrumentation/opentelemetry-instrumentation-tornado/src/opentelemetry/instrumentation/tornado/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-tornado/src/opentelemetry/instrumentation/tornado/version.py +++ b/instrumentation/opentelemetry-instrumentation-tornado/src/opentelemetry/instrumentation/tornado/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-tortoiseorm/pyproject.toml b/instrumentation/opentelemetry-instrumentation-tortoiseorm/pyproject.toml index 38d6fd7935..c6fb4439b7 100644 --- a/instrumentation/opentelemetry-instrumentation-tortoiseorm/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-tortoiseorm/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-tortoiseorm/src/opentelemetry/instrumentation/tortoiseorm/version.py b/instrumentation/opentelemetry-instrumentation-tortoiseorm/src/opentelemetry/instrumentation/tortoiseorm/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-tortoiseorm/src/opentelemetry/instrumentation/tortoiseorm/version.py +++ b/instrumentation/opentelemetry-instrumentation-tortoiseorm/src/opentelemetry/instrumentation/tortoiseorm/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-urllib/pyproject.toml b/instrumentation/opentelemetry-instrumentation-urllib/pyproject.toml index 6bcba5469b..6cafe02935 100644 --- a/instrumentation/opentelemetry-instrumentation-urllib/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-urllib/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-urllib/src/opentelemetry/instrumentation/urllib/version.py b/instrumentation/opentelemetry-instrumentation-urllib/src/opentelemetry/instrumentation/urllib/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-urllib/src/opentelemetry/instrumentation/urllib/version.py +++ b/instrumentation/opentelemetry-instrumentation-urllib/src/opentelemetry/instrumentation/urllib/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-urllib3/pyproject.toml b/instrumentation/opentelemetry-instrumentation-urllib3/pyproject.toml index 440808a7c2..5ef7097c63 100644 --- a/instrumentation/opentelemetry-instrumentation-urllib3/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-urllib3/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-urllib3/src/opentelemetry/instrumentation/urllib3/version.py b/instrumentation/opentelemetry-instrumentation-urllib3/src/opentelemetry/instrumentation/urllib3/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-urllib3/src/opentelemetry/instrumentation/urllib3/version.py +++ b/instrumentation/opentelemetry-instrumentation-urllib3/src/opentelemetry/instrumentation/urllib3/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/pyproject.toml b/instrumentation/opentelemetry-instrumentation-wsgi/pyproject.toml index 39b58ae1e3..a63f5eee26 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-wsgi/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", - "opentelemetry-semantic-conventions == 0.61b0.dev", - "opentelemetry-util-http == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", + "opentelemetry-util-http == 0.62b0.dev", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/version.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/version.py index c099e9440e..ed89ddd1cc 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/version.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 13c1ccbf1c..0b4a0c8398 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -30,57 +30,57 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation-aio-pika==0.61b0.dev", - "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", - "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", - "opentelemetry-instrumentation-aiokafka==0.61b0.dev", - "opentelemetry-instrumentation-aiopg==0.61b0.dev", - "opentelemetry-instrumentation-asgi==0.61b0.dev", - "opentelemetry-instrumentation-asyncclick==0.61b0.dev", - "opentelemetry-instrumentation-asyncio==0.61b0.dev", - "opentelemetry-instrumentation-asyncpg==0.61b0.dev", - "opentelemetry-instrumentation-aws-lambda==0.61b0.dev", - "opentelemetry-instrumentation-boto==0.61b0.dev", - "opentelemetry-instrumentation-boto3sqs==0.61b0.dev", - "opentelemetry-instrumentation-botocore==0.61b0.dev", - "opentelemetry-instrumentation-cassandra==0.61b0.dev", - "opentelemetry-instrumentation-celery==0.61b0.dev", - "opentelemetry-instrumentation-click==0.61b0.dev", - "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev", - "opentelemetry-instrumentation-dbapi==0.61b0.dev", - "opentelemetry-instrumentation-django==0.61b0.dev", - "opentelemetry-instrumentation-elasticsearch==0.61b0.dev", - "opentelemetry-instrumentation-falcon==0.61b0.dev", - "opentelemetry-instrumentation-fastapi==0.61b0.dev", - "opentelemetry-instrumentation-flask==0.61b0.dev", - "opentelemetry-instrumentation-grpc==0.61b0.dev", - "opentelemetry-instrumentation-httpx==0.61b0.dev", - "opentelemetry-instrumentation-jinja2==0.61b0.dev", - "opentelemetry-instrumentation-kafka-python==0.61b0.dev", - "opentelemetry-instrumentation-logging==0.61b0.dev", - "opentelemetry-instrumentation-mysql==0.61b0.dev", - "opentelemetry-instrumentation-mysqlclient==0.61b0.dev", - "opentelemetry-instrumentation-pika==0.61b0.dev", - "opentelemetry-instrumentation-psycopg==0.61b0.dev", - "opentelemetry-instrumentation-psycopg2==0.61b0.dev", - "opentelemetry-instrumentation-pymemcache==0.61b0.dev", - "opentelemetry-instrumentation-pymongo==0.61b0.dev", - "opentelemetry-instrumentation-pymssql==0.61b0.dev", - "opentelemetry-instrumentation-pymysql==0.61b0.dev", - "opentelemetry-instrumentation-pyramid==0.61b0.dev", - "opentelemetry-instrumentation-redis==0.61b0.dev", - "opentelemetry-instrumentation-remoulade==0.61b0.dev", - "opentelemetry-instrumentation-requests==0.61b0.dev", - "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev", - "opentelemetry-instrumentation-sqlite3==0.61b0.dev", - "opentelemetry-instrumentation-starlette==0.61b0.dev", - "opentelemetry-instrumentation-system-metrics==0.61b0.dev", - "opentelemetry-instrumentation-threading==0.61b0.dev", - "opentelemetry-instrumentation-tornado==0.61b0.dev", - "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", - "opentelemetry-instrumentation-urllib==0.61b0.dev", - "opentelemetry-instrumentation-urllib3==0.61b0.dev", - "opentelemetry-instrumentation-wsgi==0.61b0.dev", + "opentelemetry-instrumentation-aio-pika==0.62b0.dev", + "opentelemetry-instrumentation-aiohttp-client==0.62b0.dev", + "opentelemetry-instrumentation-aiohttp-server==0.62b0.dev", + "opentelemetry-instrumentation-aiokafka==0.62b0.dev", + "opentelemetry-instrumentation-aiopg==0.62b0.dev", + "opentelemetry-instrumentation-asgi==0.62b0.dev", + "opentelemetry-instrumentation-asyncclick==0.62b0.dev", + "opentelemetry-instrumentation-asyncio==0.62b0.dev", + "opentelemetry-instrumentation-asyncpg==0.62b0.dev", + "opentelemetry-instrumentation-aws-lambda==0.62b0.dev", + "opentelemetry-instrumentation-boto==0.62b0.dev", + "opentelemetry-instrumentation-boto3sqs==0.62b0.dev", + "opentelemetry-instrumentation-botocore==0.62b0.dev", + "opentelemetry-instrumentation-cassandra==0.62b0.dev", + "opentelemetry-instrumentation-celery==0.62b0.dev", + "opentelemetry-instrumentation-click==0.62b0.dev", + "opentelemetry-instrumentation-confluent-kafka==0.62b0.dev", + "opentelemetry-instrumentation-dbapi==0.62b0.dev", + "opentelemetry-instrumentation-django==0.62b0.dev", + "opentelemetry-instrumentation-elasticsearch==0.62b0.dev", + "opentelemetry-instrumentation-falcon==0.62b0.dev", + "opentelemetry-instrumentation-fastapi==0.62b0.dev", + "opentelemetry-instrumentation-flask==0.62b0.dev", + "opentelemetry-instrumentation-grpc==0.62b0.dev", + "opentelemetry-instrumentation-httpx==0.62b0.dev", + "opentelemetry-instrumentation-jinja2==0.62b0.dev", + "opentelemetry-instrumentation-kafka-python==0.62b0.dev", + "opentelemetry-instrumentation-logging==0.62b0.dev", + "opentelemetry-instrumentation-mysql==0.62b0.dev", + "opentelemetry-instrumentation-mysqlclient==0.62b0.dev", + "opentelemetry-instrumentation-pika==0.62b0.dev", + "opentelemetry-instrumentation-psycopg==0.62b0.dev", + "opentelemetry-instrumentation-psycopg2==0.62b0.dev", + "opentelemetry-instrumentation-pymemcache==0.62b0.dev", + "opentelemetry-instrumentation-pymongo==0.62b0.dev", + "opentelemetry-instrumentation-pymssql==0.62b0.dev", + "opentelemetry-instrumentation-pymysql==0.62b0.dev", + "opentelemetry-instrumentation-pyramid==0.62b0.dev", + "opentelemetry-instrumentation-redis==0.62b0.dev", + "opentelemetry-instrumentation-remoulade==0.62b0.dev", + "opentelemetry-instrumentation-requests==0.62b0.dev", + "opentelemetry-instrumentation-sqlalchemy==0.62b0.dev", + "opentelemetry-instrumentation-sqlite3==0.62b0.dev", + "opentelemetry-instrumentation-starlette==0.62b0.dev", + "opentelemetry-instrumentation-system-metrics==0.62b0.dev", + "opentelemetry-instrumentation-threading==0.62b0.dev", + "opentelemetry-instrumentation-tornado==0.62b0.dev", + "opentelemetry-instrumentation-tortoiseorm==0.62b0.dev", + "opentelemetry-instrumentation-urllib==0.62b0.dev", + "opentelemetry-instrumentation-urllib3==0.62b0.dev", + "opentelemetry-instrumentation-wsgi==0.62b0.dev", ] [project.urls] diff --git a/opentelemetry-contrib-instrumentations/src/opentelemetry/contrib-instrumentations/version.py b/opentelemetry-contrib-instrumentations/src/opentelemetry/contrib-instrumentations/version.py index c099e9440e..ed89ddd1cc 100644 --- a/opentelemetry-contrib-instrumentations/src/opentelemetry/contrib-instrumentations/version.py +++ b/opentelemetry-contrib-instrumentations/src/opentelemetry/contrib-instrumentations/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/opentelemetry-distro/pyproject.toml b/opentelemetry-distro/pyproject.toml index cb2094d13e..d57eb6c31c 100644 --- a/opentelemetry-distro/pyproject.toml +++ b/opentelemetry-distro/pyproject.toml @@ -28,13 +28,13 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation == 0.61b0.dev", + "opentelemetry-instrumentation == 0.62b0.dev", "opentelemetry-sdk ~= 1.13", ] [project.optional-dependencies] otlp = [ - "opentelemetry-exporter-otlp == 1.40.0.dev", + "opentelemetry-exporter-otlp == 1.41.0.dev", ] [project.entry-points.opentelemetry_configurator] diff --git a/opentelemetry-distro/src/opentelemetry/distro/version.py b/opentelemetry-distro/src/opentelemetry/distro/version.py index c099e9440e..ed89ddd1cc 100644 --- a/opentelemetry-distro/src/opentelemetry/distro/version.py +++ b/opentelemetry-distro/src/opentelemetry/distro/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/opentelemetry-instrumentation/pyproject.toml b/opentelemetry-instrumentation/pyproject.toml index f7a6b03a49..e628f6cd65 100644 --- a/opentelemetry-instrumentation/pyproject.toml +++ b/opentelemetry-instrumentation/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.4", - "opentelemetry-semantic-conventions == 0.61b0.dev", + "opentelemetry-semantic-conventions == 0.62b0.dev", "wrapt >= 1.0.0, < 2.0.0", "packaging >= 18.0", ] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 47ed388c32..ee73007fca 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -26,199 +26,199 @@ }, { "library": "aio_pika >= 7.2.0, < 10.0.0", - "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aio-pika==0.62b0.dev", }, { "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.62b0.dev", }, { "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.62b0.dev", }, { "library": "aiokafka >= 0.8, < 1.0", - "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiokafka==0.62b0.dev", }, { "library": "aiopg >= 0.13.0, < 2.0.0", - "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiopg==0.62b0.dev", }, { "library": "asgiref ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asgi==0.62b0.dev", }, { "library": "asyncclick ~= 8.0", - "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asyncclick==0.62b0.dev", }, { "library": "asyncpg >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asyncpg==0.62b0.dev", }, { "library": "boto~=2.0", - "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-boto==0.62b0.dev", }, { "library": "boto3 ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.62b0.dev", }, { "library": "botocore ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-botocore==0.62b0.dev", }, { "library": "cassandra-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.62b0.dev", }, { "library": "scylla-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.62b0.dev", }, { "library": "celery >= 4.0, < 6.0", - "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-celery==0.62b0.dev", }, { "library": "click >= 8.1.3, < 9.0.0", - "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-click==0.62b0.dev", }, { "library": "confluent-kafka >= 1.8.2, <= 2.13.0", - "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.62b0.dev", }, { "library": "django >= 2.0", - "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-django==0.62b0.dev", }, { "library": "elasticsearch >= 6.0", - "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.62b0.dev", }, { "library": "falcon >= 1.4.1, < 5.0.0", - "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-falcon==0.62b0.dev", }, { "library": "fastapi ~= 0.92", - "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-fastapi==0.62b0.dev", }, { "library": "flask >= 1.0", - "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-flask==0.62b0.dev", }, { "library": "grpcio >= 1.42.0", - "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-grpc==0.62b0.dev", }, { "library": "httpx >= 0.18.0", - "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-httpx==0.62b0.dev", }, { "library": "jinja2 >= 2.7, < 4.0", - "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-jinja2==0.62b0.dev", }, { "library": "kafka-python >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.62b0.dev", }, { "library": "kafka-python-ng >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.62b0.dev", }, { "library": "mysql-connector-python >= 8.0, < 10.0", - "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-mysql==0.62b0.dev", }, { "library": "mysqlclient < 3", - "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.62b0.dev", }, { "library": "pika >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pika==0.62b0.dev", }, { "library": "psycopg >= 3.1.0", - "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg==0.62b0.dev", }, { "library": "psycopg2 >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.62b0.dev", }, { "library": "psycopg2-binary >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.62b0.dev", }, { "library": "pymemcache >= 1.3.5, < 5", - "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymemcache==0.62b0.dev", }, { "library": "pymongo >= 3.1, < 5.0", - "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymongo==0.62b0.dev", }, { "library": "pymssql >= 2.1.5, < 3", - "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymssql==0.62b0.dev", }, { "library": "PyMySQL < 2", - "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymysql==0.62b0.dev", }, { "library": "pyramid >= 1.7", - "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pyramid==0.62b0.dev", }, { "library": "redis >= 2.6", - "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-redis==0.62b0.dev", }, { "library": "remoulade >= 0.50", - "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-remoulade==0.62b0.dev", }, { "library": "requests ~= 2.0", - "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-requests==0.62b0.dev", }, { "library": "sqlalchemy >= 1.0.0, < 2.1.0", - "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.62b0.dev", }, { "library": "starlette >= 0.13", - "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-starlette==0.62b0.dev", }, { "library": "psutil >= 5", - "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-system-metrics==0.62b0.dev", }, { "library": "tornado >= 5.1.1", - "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tornado==0.62b0.dev", }, { "library": "tortoise-orm >= 0.17.0", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.62b0.dev", }, { "library": "pydantic >= 1.10.2", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.62b0.dev", }, { "library": "urllib3 >= 1.0.0, < 3.0.0", - "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-urllib3==0.62b0.dev", }, ] default_instrumentations = [ - "opentelemetry-instrumentation-asyncio==0.61b0.dev", - "opentelemetry-instrumentation-dbapi==0.61b0.dev", - "opentelemetry-instrumentation-logging==0.61b0.dev", - "opentelemetry-instrumentation-sqlite3==0.61b0.dev", - "opentelemetry-instrumentation-threading==0.61b0.dev", - "opentelemetry-instrumentation-urllib==0.61b0.dev", - "opentelemetry-instrumentation-wsgi==0.61b0.dev", + "opentelemetry-instrumentation-asyncio==0.62b0.dev", + "opentelemetry-instrumentation-dbapi==0.62b0.dev", + "opentelemetry-instrumentation-logging==0.62b0.dev", + "opentelemetry-instrumentation-sqlite3==0.62b0.dev", + "opentelemetry-instrumentation-threading==0.62b0.dev", + "opentelemetry-instrumentation-urllib==0.62b0.dev", + "opentelemetry-instrumentation-wsgi==0.62b0.dev", ] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py index c099e9440e..ed89ddd1cc 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/processor/opentelemetry-processor-baggage/src/opentelemetry/processor/baggage/version.py b/processor/opentelemetry-processor-baggage/src/opentelemetry/processor/baggage/version.py index c099e9440e..ed89ddd1cc 100644 --- a/processor/opentelemetry-processor-baggage/src/opentelemetry/processor/baggage/version.py +++ b/processor/opentelemetry-processor-baggage/src/opentelemetry/processor/baggage/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/propagator/opentelemetry-propagator-ot-trace/src/opentelemetry/propagators/ot_trace/version.py b/propagator/opentelemetry-propagator-ot-trace/src/opentelemetry/propagators/ot_trace/version.py index c099e9440e..ed89ddd1cc 100644 --- a/propagator/opentelemetry-propagator-ot-trace/src/opentelemetry/propagators/ot_trace/version.py +++ b/propagator/opentelemetry-propagator-ot-trace/src/opentelemetry/propagators/ot_trace/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/resource/opentelemetry-resource-detector-containerid/src/opentelemetry/resource/detector/containerid/version.py b/resource/opentelemetry-resource-detector-containerid/src/opentelemetry/resource/detector/containerid/version.py index c099e9440e..ed89ddd1cc 100644 --- a/resource/opentelemetry-resource-detector-containerid/src/opentelemetry/resource/detector/containerid/version.py +++ b/resource/opentelemetry-resource-detector-containerid/src/opentelemetry/resource/detector/containerid/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/version.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/version.py index c099e9440e..ed89ddd1cc 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/version.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.61b0.dev" +__version__ = "0.62b0.dev" From da7d578f4bd0eb8b9aa209c1857e225c86a7a1cc Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 4 Mar 2026 17:58:23 +0100 Subject: [PATCH 24/41] eachdist: add missing genai packages in independently released packages (#4295) Namely opentelemetry-instrumentation-anthropic and opentelemetry-instrumentation-claude-agent-sdk --- eachdist.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eachdist.ini b/eachdist.ini index cc2419975e..14b4b00fca 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -51,6 +51,8 @@ packages= opentelemetry-resource-detector-azure opentelemetry-sdk-extension-aws opentelemetry-propagator-aws-xray + opentelemetry-instrumentation-anthropic + opentelemetry-instrumentation-claude-agent-sdk opentelemetry-instrumentation-google-genai opentelemetry-instrumentation-vertexai opentelemetry-instrumentation-openai-v2 From 76f40ddd70466a68efca2caf5df1624a8bc88742 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 5 Mar 2026 05:56:47 -0800 Subject: [PATCH 25/41] Refactoring attributes for embedding type --- util/opentelemetry-util-genai/pyproject.toml | 4 +- .../src/opentelemetry/util/genai/handler.py | 167 +++++++++++------- .../opentelemetry/util/genai/span_utils.py | 4 +- .../src/opentelemetry/util/genai/types.py | 5 +- 4 files changed, 108 insertions(+), 72 deletions(-) diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index f6fdf4624d..5f5c7153fb 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.61b0.dev", - "opentelemetry-semantic-conventions ~= 0.61b0.dev", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-api>=1.31.0", ] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 974c1cb658..be258c5416 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -62,7 +62,7 @@ import timeit from contextlib import contextmanager -from typing import Iterator +from typing import Iterator, TypeVar from opentelemetry import context as otel_context from opentelemetry._logs import ( @@ -78,6 +78,7 @@ get_tracer, set_span_in_context, ) +from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.genai.metrics import InvocationMetricsRecorder from opentelemetry.util.genai.span_utils import ( _apply_embedding_finish_attributes, @@ -88,10 +89,13 @@ from opentelemetry.util.genai.types import ( EmbeddingInvocation, Error, + GenAIInvocation, LLMInvocation, ) from opentelemetry.util.genai.version import __version__ +_T = TypeVar("_T", bound=GenAIInvocation) + class TelemetryHandler: """ @@ -148,14 +152,10 @@ def _record_embedding_metrics( # metric support is added. return - def start_llm( - self, - invocation: LLMInvocation, - ) -> LLMInvocation: - """Start an LLM invocation and create a pending span entry.""" - # Create a span and attach it as current; keep the token to detach later + def _start(self, invocation: _T) -> _T: + """Start a GenAI invocation and create a pending span entry.""" span = self._tracer.start_span( - name=f"{invocation.operation_name} {invocation.request_model}", + name=invocation.operation_name or "", kind=SpanKind.CLIENT, ) # Record a monotonic start timestamp (seconds) for duration @@ -167,40 +167,108 @@ def start_llm( ) return invocation - def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disable=no-self-use - """Finalize an LLM invocation successfully and end its span.""" + def _stop(self, invocation: _T) -> _T: + """Finalize a GenAI invocation successfully and end its span.""" if invocation.context_token is None or invocation.span is None: # TODO: Provide feedback that this invocation was not started return invocation span = invocation.span - _apply_llm_finish_attributes(span, invocation) - self._record_llm_metrics(invocation, span) - _maybe_emit_llm_event(self._logger, span, invocation) - # Detach context and end span - otel_context.detach(invocation.context_token) - span.end() + try: + if isinstance(invocation, LLMInvocation): + _apply_llm_finish_attributes(span, invocation) + self._record_llm_metrics(invocation, span) + _maybe_emit_llm_event(self._logger, span, invocation) + elif isinstance(invocation, EmbeddingInvocation): + _apply_embedding_finish_attributes(span, invocation) + self._record_embedding_metrics(invocation, span) + else: + span.set_status( + Status( + StatusCode.ERROR, + f"Unsupported invocation type: {type(invocation)!r}", + ) + ) + raise TypeError( + f"Unsupported invocation type: {type(invocation)!r}" + ) + finally: + # Detach context and end span even if finishing fails + otel_context.detach(invocation.context_token) + span.end() return invocation - def fail_llm( # pylint: disable=no-self-use - self, invocation: LLMInvocation, error: Error - ) -> LLMInvocation: - """Fail an LLM invocation and end its span with error status.""" + def _fail(self, invocation: _T, error: Error) -> _T: + """Fail a GenAI invocation and end its span with error status.""" if invocation.context_token is None or invocation.span is None: # TODO: Provide feedback that this invocation was not started return invocation span = invocation.span - _apply_llm_finish_attributes(invocation.span, invocation) - _apply_error_attributes(invocation.span, error) - error_type = getattr(error.type, "__qualname__", None) - self._record_llm_metrics(invocation, span, error_type=error_type) - _maybe_emit_llm_event(self._logger, span, invocation, error) - # Detach context and end span - otel_context.detach(invocation.context_token) - span.end() + try: + if isinstance(invocation, LLMInvocation): + _apply_llm_finish_attributes(span, invocation) + _apply_error_attributes(span, error) + error_type = getattr(error.type, "__qualname__", None) + self._record_llm_metrics( + invocation, span, error_type=error_type + ) + _maybe_emit_llm_event(self._logger, span, invocation, error) + elif isinstance(invocation, EmbeddingInvocation): + _apply_embedding_finish_attributes(span, invocation) + _apply_error_attributes(span, error) + error_type = getattr(error.type, "__qualname__", None) + self._record_embedding_metrics( + invocation, span, error_type=error_type + ) + else: + span.set_status( + Status( + StatusCode.ERROR, + f"Unsupported invocation type: {type(invocation)!r}", + ) + ) + raise TypeError( + f"Unsupported invocation type: {type(invocation)!r}" + ) + finally: + # Detach context and end span even if finishing fails + otel_context.detach(invocation.context_token) + span.end() return invocation + def start( + self, + invocation: _T, + ) -> _T: + """Start a GenAI invocation and create a pending span entry.""" + return self._start(invocation) + + def stop(self, invocation: _T) -> _T: + """Finalize a GenAI invocation successfully and end its span.""" + return self._stop(invocation) + + def fail(self, invocation: _T, error: Error) -> _T: + """Fail a GenAI invocation and end its span with error status.""" + return self._fail(invocation, error) + + def start_llm( + self, + invocation: LLMInvocation, + ) -> LLMInvocation: + """Start an LLM invocation and create a pending span entry.""" + return self.start(invocation) + + def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disable=no-self-use + """Finalize an LLM invocation successfully and end its span.""" + return self.stop(invocation) + + def fail_llm( # pylint: disable=no-self-use + self, invocation: LLMInvocation, error: Error + ) -> LLMInvocation: + """Fail an LLM invocation and end its span with error status.""" + return self.fail(invocation, error) + @contextmanager def llm( self, invocation: LLMInvocation | None = None @@ -253,56 +321,19 @@ def start_embedding( self, invocation: EmbeddingInvocation ) -> EmbeddingInvocation: """Start an embedding invocation and create a pending span entry.""" - - span = self._tracer.start_span( - name=f"{invocation.operation_name} {invocation.request_model}", - kind=SpanKind.CLIENT, - ) - invocation.span = span - invocation.context_token = otel_context.attach( - set_span_in_context(span) - ) - return invocation + return self.start(invocation) def stop_embedding( self, invocation: EmbeddingInvocation ) -> EmbeddingInvocation: """Finalize an embedding invocation successfully and end its span.""" - if invocation.context_token is None or invocation.span is None: - # TODO: Provide feedback that this invocation was not started - return invocation - - span = invocation.span - try: - _apply_embedding_finish_attributes(span, invocation) - self._record_embedding_metrics(invocation, span) - finally: - # Detach context and end span even if finishing fails - otel_context.detach(invocation.context_token) - span.end() - return invocation + return self.stop(invocation) def fail_embedding( self, invocation: EmbeddingInvocation, error: Error ) -> EmbeddingInvocation: """Fail an embedding invocation and end its span with error status.""" - if invocation.context_token is None or invocation.span is None: - # TODO: Provide feedback that this invocation was not started - return invocation - - span = invocation.span - try: - _apply_embedding_finish_attributes(invocation.span, invocation) - _apply_error_attributes(invocation.span, error) - error_type = getattr(error.type, "__qualname__", None) - self._record_embedding_metrics( - invocation, span, error_type=error_type - ) - finally: - # Detach context and end span even if finishing fails - otel_context.detach(invocation.context_token) - span.end() - return invocation + return self.fail(invocation, error) def get_telemetry_handler( diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 90310643e7..bc9d19a11f 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -93,7 +93,8 @@ def _get_span_name( invocation: GenAIInvocation, ) -> str: """Get the span name for a GenAI invocation.""" - return f"{invocation.operation_name} {invocation.request_model}".strip() + request_model = getattr(invocation, "request_model", None) or "" + return f"{invocation.operation_name} {request_model}".strip() def _get_llm_span_name(invocation: LLMInvocation) -> str: @@ -350,6 +351,7 @@ def _get_embedding_response_attributes( ) -> dict[str, Any]: """Get GenAI response semantic convention attributes.""" optional_attrs = ( + (GenAI.GEN_AI_RESPONSE_MODEL, invocation.response_model_name), (GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens), ) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 06467389f3..be80ed4d81 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -201,7 +201,7 @@ class GenAIInvocation: context_token: ContextToken | None = None span: Span | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) - request_model: str | None = None + operation_name: str | None = None # Monotonic start time in seconds (from timeit.default_timer) used # for duration calculations to avoid mixing clock sources. This is @@ -221,6 +221,7 @@ def __post_init__(self) -> None: if self.operation_name is None: self.operation_name = GenAI.GenAiOperationNameValues.CHAT.value + request_model: str | None = None input_messages: list[InputMessage] = field( default_factory=_new_input_messages ) @@ -273,6 +274,7 @@ def __post_init__(self) -> None: GenAI.GenAiOperationNameValues.EMBEDDINGS.value ) + request_model: str | None = None provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock server_address: str | None = None server_port: int | None = None @@ -282,6 +284,7 @@ def __post_init__(self) -> None: encoding_formats: list[str] | None = None input_tokens: int | None = None dimension_count: int | None = None + response_model_name: str | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) """ From 1384bfdb8daf6e12a4eb547845ac6a87571da962 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 5 Mar 2026 09:14:00 -0800 Subject: [PATCH 26/41] Removing unnecessary comments --- .../src/opentelemetry/util/genai/handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index be258c5416..2e0d6bec46 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -259,11 +259,11 @@ def start_llm( """Start an LLM invocation and create a pending span entry.""" return self.start(invocation) - def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disable=no-self-use + def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: """Finalize an LLM invocation successfully and end its span.""" return self.stop(invocation) - def fail_llm( # pylint: disable=no-self-use + def fail_llm( self, invocation: LLMInvocation, error: Error ) -> LLMInvocation: """Fail an LLM invocation and end its span with error status.""" From 1621711002dd6fc50590b74d20be1f9c442be242 Mon Sep 17 00:00:00 2001 From: shuningc Date: Mon, 9 Mar 2026 09:33:55 -0700 Subject: [PATCH 27/41] Adding error path unit test, moving operation name back to each invocation types --- .../opentelemetry/util/genai/span_utils.py | 2 +- .../src/opentelemetry/util/genai/types.py | 21 ++++------ .../tests/test_utils.py | 41 +++++++++++++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index bc9d19a11f..6048bf762c 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -304,7 +304,7 @@ def _get_embedding_request_attributes( """Get GenAI request semantic convention attributes.""" optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model), - (GenAI.GEN_AI_EMBEDDINGS_DIMENSION_COUNT, invocation.dimension_count), + (GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT, invocation.dimension_count), (GenAI.GEN_AI_REQUEST_ENCODING_FORMATS, invocation.encoding_formats), ) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index be80ed4d81..6e7168dffa 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -202,11 +202,12 @@ class GenAIInvocation: span: Span | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) - operation_name: str | None = None - # Monotonic start time in seconds (from timeit.default_timer) used - # for duration calculations to avoid mixing clock sources. This is - # populated by the TelemetryHandler when starting an invocation. monotonic_start_s: float | None = None + """ + Monotonic start time in seconds (from timeit.default_timer) used for + duration calculations to avoid mixing clock sources. This is populated + by the TelemetryHandler when starting an invocation. + """ @dataclass @@ -217,10 +218,7 @@ class LLMInvocation(GenAIInvocation): set by the TelemetryHandler. """ - def __post_init__(self) -> None: - if self.operation_name is None: - self.operation_name = GenAI.GenAiOperationNameValues.CHAT.value - + operation_name: str = GenAI.GenAiOperationNameValues.CHAT.value request_model: str | None = None input_messages: list[InputMessage] = field( default_factory=_new_input_messages @@ -268,12 +266,7 @@ class EmbeddingInvocation(GenAIInvocation): and context_token attributes are set by the TelemetryHandler. """ - def __post_init__(self) -> None: - if self.operation_name is None: - self.operation_name = ( - GenAI.GenAiOperationNameValues.EMBEDDINGS.value - ) - + operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value request_model: str | None = None provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock server_address: str | None = None diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index cf951b3f0b..0e11def669 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -625,6 +625,47 @@ class BoomError(RuntimeError): }, ) + def test_embedding_context_manager_error_path_records_error_status_and_attrs( + self, + ): + class BoomError(RuntimeError): + pass + + invocation = EmbeddingInvocation( + request_model="embed-model", + provider="test-provider", + dimension_count=1536, + input_tokens=7, + server_address="embed.example.com", + server_port=443, + attributes={"custom_embed_attr": "value"}, + ) + + with self.assertRaises(BoomError): + with self.telemetry_handler.embedding(invocation): + invocation.response_model_name = "embed-response-model" + raise BoomError("embedding boom") + + span = _get_single_span(self.span_exporter) + assert span.status.status_code == StatusCode.ERROR + _assert_span_time_order(span) + span_attrs = _get_span_attributes(span) + _assert_span_attributes( + span_attrs, + { + GenAI.GEN_AI_OPERATION_NAME: "embeddings", + GenAI.GEN_AI_REQUEST_MODEL: "embed-model", + GenAI.GEN_AI_PROVIDER_NAME: "test-provider", + GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT: 1536, + GenAI.GEN_AI_USAGE_INPUT_TOKENS: 7, + GenAI.GEN_AI_RESPONSE_MODEL: "embed-response-model", + server_attributes.SERVER_ADDRESS: "embed.example.com", + server_attributes.SERVER_PORT: 443, + "custom_embed_attr": "value", + error_attributes.ERROR_TYPE: BoomError.__qualname__, + }, + ) + @patch_env_vars( stability_mode="gen_ai_latest_experimental", content_capturing="EVENT_ONLY", From d49085cd75a5c9bdc27177359b8603de62f03967 Mon Sep 17 00:00:00 2001 From: shuningc Date: Mon, 9 Mar 2026 09:40:23 -0700 Subject: [PATCH 28/41] Making operation name immutable for each type --- .../src/opentelemetry/util/genai/types.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 6e7168dffa..42e539ca47 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -209,6 +209,11 @@ class GenAIInvocation: by the TelemetryHandler when starting an invocation. """ + @property + def operation_name(self) -> str: + """Defined by each concrete invocation type. Immutable.""" + raise NotImplementedError + @dataclass class LLMInvocation(GenAIInvocation): @@ -218,7 +223,10 @@ class LLMInvocation(GenAIInvocation): set by the TelemetryHandler. """ - operation_name: str = GenAI.GenAiOperationNameValues.CHAT.value + @property + def operation_name(self) -> str: + return GenAI.GenAiOperationNameValues.CHAT.value + request_model: str | None = None input_messages: list[InputMessage] = field( default_factory=_new_input_messages @@ -266,7 +274,10 @@ class EmbeddingInvocation(GenAIInvocation): and context_token attributes are set by the TelemetryHandler. """ - operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value + @property + def operation_name(self) -> str: + return GenAI.GenAiOperationNameValues.EMBEDDINGS.value + request_model: str | None = None provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock server_address: str | None = None From bb21996601d3a2e2cec3f98a2a389ab1c91d904e Mon Sep 17 00:00:00 2001 From: shuningc Date: Tue, 10 Mar 2026 07:09:55 -0700 Subject: [PATCH 29/41] Revert "Making operation name immutable for each type" This reverts commit d49085cd75a5c9bdc27177359b8603de62f03967. --- .../src/opentelemetry/util/genai/types.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 42e539ca47..6e7168dffa 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -209,11 +209,6 @@ class GenAIInvocation: by the TelemetryHandler when starting an invocation. """ - @property - def operation_name(self) -> str: - """Defined by each concrete invocation type. Immutable.""" - raise NotImplementedError - @dataclass class LLMInvocation(GenAIInvocation): @@ -223,10 +218,7 @@ class LLMInvocation(GenAIInvocation): set by the TelemetryHandler. """ - @property - def operation_name(self) -> str: - return GenAI.GenAiOperationNameValues.CHAT.value - + operation_name: str = GenAI.GenAiOperationNameValues.CHAT.value request_model: str | None = None input_messages: list[InputMessage] = field( default_factory=_new_input_messages @@ -274,10 +266,7 @@ class EmbeddingInvocation(GenAIInvocation): and context_token attributes are set by the TelemetryHandler. """ - @property - def operation_name(self) -> str: - return GenAI.GenAiOperationNameValues.EMBEDDINGS.value - + operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value request_model: str | None = None provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock server_address: str | None = None From 7bc42330f00aa3cf4e619df9023a24a635080482 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 06:47:01 -0700 Subject: [PATCH 30/41] Updating dependencies for different instrumentation packages, fixing embedding attribute typo --- .../pyproject.toml | 17 +++++------------ .../tests/requirements.oldest.txt | 6 +++--- .../pyproject.toml | 16 +++++----------- .../tests/requirements.oldest.txt | 6 +++--- .../pyproject.toml | 15 +++++---------- .../tests/requirements.oldest.txt | 6 +++--- util/opentelemetry-util-genai/CHANGELOG.md | 6 ++++-- .../src/opentelemetry/util/genai/span_utils.py | 2 +- 8 files changed, 29 insertions(+), 45 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml index 74c411a1a5..99c4ce8cb5 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml @@ -26,16 +26,14 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] [project.optional-dependencies] -instruments = [ - "anthropic >= 0.51.0", -] +instruments = ["anthropic >= 0.51.0"] [project.entry-points.opentelemetry_instrumentor] anthropic = "opentelemetry.instrumentation.anthropic:AnthropicInstrumentor" @@ -48,15 +46,10 @@ Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" path = "src/opentelemetry/instrumentation/anthropic/version.py" [tool.hatch.build.targets.sdist] -include = [ - "/src", - "/tests", - "/examples", -] +include = ["/src", "/tests", "/examples"] [tool.hatch.build.targets.wheel] packages = ["src/opentelemetry"] [tool.pytest.ini_options] testpaths = ["tests"] - diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt index 0b77b1a7cb..cb676448db 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.37 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.37 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.58b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-anthropic diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml index 3149629fac..fb38e61589 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml @@ -24,16 +24,14 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] [project.optional-dependencies] -instruments = [ - "claude-agent-sdk >= 0.1.14", -] +instruments = ["claude-agent-sdk >= 0.1.14"] [project.entry-points.opentelemetry_instrumentor] claude-agent-sdk = "opentelemetry.instrumentation.claude_agent_sdk:ClaudeAgentSDKInstrumentor" @@ -46,11 +44,7 @@ Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" path = "src/opentelemetry/instrumentation/claude_agent_sdk/version.py" [tool.hatch.build.targets.sdist] -include = [ - "/src", - "/tests", - "/examples", -] +include = ["/src", "/tests", "/examples"] [tool.hatch.build.targets.wheel] packages = ["src/opentelemetry"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt index adfadef283..833e05fb1d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.37 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.37 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.58b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml index fc5939985b..1793eca2af 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml @@ -26,16 +26,14 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai", ] [project.optional-dependencies] -instruments = [ - "openai >= 1.26.0", -] +instruments = ["openai >= 1.26.0"] [project.entry-points.opentelemetry_instrumentor] openai = "opentelemetry.instrumentation.openai_v2:OpenAIInstrumentor" @@ -48,10 +46,7 @@ Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" path = "src/opentelemetry/instrumentation/openai_v2/version.py" [tool.hatch.build.targets.sdist] -include = [ - "/src", - "/tests", -] +include = ["/src", "/tests"] [tool.hatch.build.targets.wheel] packages = ["src/opentelemetry"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt index 2644ba47e8..3584b23193 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt @@ -29,9 +29,9 @@ pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 opentelemetry-exporter-otlp-proto-http~=1.30 -opentelemetry-api==1.37 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.37 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.58b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 -e util/opentelemetry-util-genai \ No newline at end of file diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index 3e9cfdf664..607b2240b6 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add EmbeddingInvocation span lifecycle support + ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4219](#4219)) + ## Version 0.3b0 (2026-02-20) - Add `gen_ai.tool_definitions` to completion hook ([#4181](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4181)) @@ -25,8 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3891](#3891)) - Add parent class genAI invocation ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3889](#3889)) -- Add EmbeddingInvocation span lifecycle support - ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4219](#4219)) ## Version 0.2b0 (2025-10-14) @@ -53,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3752](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3752)) ([#3759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3759)) ([#3763](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3763)) + - Add a utility to parse the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. Add `gen_ai_latest_experimental` as a new value to the Sem Conv stability flag ([#3716](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3716)). diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 6048bf762c..bc9d19a11f 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -304,7 +304,7 @@ def _get_embedding_request_attributes( """Get GenAI request semantic convention attributes.""" optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model), - (GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT, invocation.dimension_count), + (GenAI.GEN_AI_EMBEDDINGS_DIMENSION_COUNT, invocation.dimension_count), (GenAI.GEN_AI_REQUEST_ENCODING_FORMATS, invocation.encoding_formats), ) From 7ab292d8c64647d102532ffeb9d67cff5440218f Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 08:05:56 -0700 Subject: [PATCH 31/41] Fixing conflict --- util/opentelemetry-util-genai/tests/test_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 0a2d007407..e0f8cc22d6 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -26,9 +26,6 @@ from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import ( InMemoryLogExporter, - InMemoryLogExporter as InMemoryLogRecordExporter, -) -from opentelemetry.sdk._logs.export import ( SimpleLogRecordProcessor, ) from opentelemetry.sdk.trace import ReadableSpan, TracerProvider From 28a7d931c2f46b67668101f912613789b7cb0f38 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 08:52:20 -0700 Subject: [PATCH 32/41] Fixing failed tests out of dependencies and typo --- .../opentelemetry-instrumentation-anthropic/pyproject.toml | 2 +- .../opentelemetry-instrumentation-openai-v2/pyproject.toml | 2 +- .../tests/requirements.oldest.txt | 4 ++-- util/opentelemetry-util-genai/tests/test_utils.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml index 99c4ce8cb5..5cc6754ef7 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.40", + "opentelemetry-api ~= 1.39", "opentelemetry-instrumentation ~= 0.60b0", "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml index 1793eca2af..7b4fcce224 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.40", + "opentelemetry-api ~= 1.39", "opentelemetry-instrumentation ~= 0.60b0", "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai", diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt index 3584b23193..45339fb438 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt @@ -29,8 +29,8 @@ pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 opentelemetry-exporter-otlp-proto-http~=1.30 -opentelemetry-api==1.40 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index e0f8cc22d6..0ba27676c2 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -677,7 +677,7 @@ class BoomError(RuntimeError): GenAI.GEN_AI_OPERATION_NAME: "embeddings", GenAI.GEN_AI_REQUEST_MODEL: "embed-model", GenAI.GEN_AI_PROVIDER_NAME: "test-provider", - GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT: 1536, + GenAI.GEN_AI_EMBEDDINGS_DIMENSION_COUNT: 1536, GenAI.GEN_AI_USAGE_INPUT_TOKENS: 7, GenAI.GEN_AI_RESPONSE_MODEL: "embed-response-model", server_attributes.SERVER_ADDRESS: "embed.example.com", From 3a9045df3a5eb3119214fb302977d219e1b96895 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 09:35:05 -0700 Subject: [PATCH 33/41] Fixing failed tests by splitting large files and updating dependencies --- .../tests/requirements.oldest.txt | 4 +- .../src/opentelemetry/util/genai/handler.py | 2 +- .../tests/test_utils.py | 314 --------------- .../tests/test_utils_events.py | 380 ++++++++++++++++++ 4 files changed, 383 insertions(+), 317 deletions(-) create mode 100644 util/opentelemetry-util-genai/tests/test_utils_events.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt index cb676448db..1b1d2b1994 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.40 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-anthropic diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 701f16878d..7bf9fd8325 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -143,8 +143,8 @@ def _record_llm_metrics( error_type=error_type, ) + @staticmethod def _record_embedding_metrics( - self, invocation: EmbeddingInvocation, span: Span | None = None, *, diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 0ba27676c2..4bb6fa86d7 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -50,7 +50,6 @@ from opentelemetry.util.genai.types import ( ContentCapturingMode, EmbeddingInvocation, - Error, InputMessage, LLMInvocation, MessagePart, @@ -687,319 +686,6 @@ class BoomError(RuntimeError): }, ) - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="true", - ) - def test_emits_llm_event(self): - invocation = LLMInvocation( - request_model="event-model", - input_messages=[_create_input_message("test query")], - system_instruction=_create_system_instruction(), - provider="test-provider", - temperature=0.7, - max_tokens=100, - response_model_name="response-model", - response_id="event-response-id", - input_tokens=10, - output_tokens=20, - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [_create_output_message("test response")] - self.telemetry_handler.stop_llm(invocation) - - # Check that event was emitted - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - - # Verify event name - self.assertEqual( - log_record.event_name, "gen_ai.client.inference.operation.details" - ) - - # Verify event attributes - attrs = log_record.attributes - self.assertIsNotNone(attrs) - self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "chat") - self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MODEL], "event-model") - self.assertEqual(attrs[GenAI.GEN_AI_PROVIDER_NAME], "test-provider") - self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_TEMPERATURE], 0.7) - self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MAX_TOKENS], 100) - self.assertEqual(attrs[GenAI.GEN_AI_RESPONSE_MODEL], "response-model") - self.assertEqual(attrs[GenAI.GEN_AI_RESPONSE_ID], "event-response-id") - self.assertEqual(attrs[GenAI.GEN_AI_USAGE_INPUT_TOKENS], 10) - self.assertEqual(attrs[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS], 20) - - # Verify messages are in structured format (not JSON string) - # OpenTelemetry may convert lists to tuples, so we normalize - input_msg = _normalize_to_dict( - _normalize_to_list(attrs[GenAI.GEN_AI_INPUT_MESSAGES])[0] - ) - self.assertEqual(input_msg["role"], "Human") - self.assertEqual( - _normalize_to_list(input_msg["parts"])[0]["content"], "test query" - ) - - output_msg = _normalize_to_dict( - _normalize_to_list(attrs[GenAI.GEN_AI_OUTPUT_MESSAGES])[0] - ) - self.assertEqual(output_msg["role"], "AI") - self.assertEqual( - _normalize_to_list(output_msg["parts"])[0]["content"], - "test response", - ) - self.assertEqual(output_msg["finish_reason"], "stop") - - # Verify system instruction is present in event in structured format - sys_instr = _normalize_to_dict( - _normalize_to_list(attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS])[0] - ) - self.assertEqual(sys_instr["content"], "You are a helpful assistant.") - self.assertEqual(sys_instr["type"], "text") - - # Verify event context matches span context - span = _get_single_span(self.span_exporter) - self.assertIsNotNone(log_record.trace_id) - self.assertIsNotNone(log_record.span_id) - self.assertIsNotNone(span.context) - self.assertEqual(log_record.trace_id, span.context.trace_id) - self.assertEqual(log_record.span_id, span.context.span_id) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="SPAN_AND_EVENT", - emit_event="true", - ) - def test_emits_llm_event_and_span(self): - message = _create_input_message("combined test") - chat_generation = _create_output_message("combined response") - system_instruction = _create_system_instruction("System prompt here") - - invocation = LLMInvocation( - request_model="combined-model", - input_messages=[message], - system_instruction=system_instruction, - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) - - # Check span was created - span = _get_single_span(self.span_exporter) - span_attrs = _get_span_attributes(span) - self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, span_attrs) - - # Check event was emitted - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - self.assertEqual( - log_record.event_name, "gen_ai.client.inference.operation.details" - ) - self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, log_record.attributes) - # Verify system instruction in both span and event - self.assertIn(GenAI.GEN_AI_SYSTEM_INSTRUCTIONS, span_attrs) - span_system = json.loads(span_attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS]) - self.assertEqual(span_system[0]["content"], "System prompt here") - event_attrs = log_record.attributes - self.assertIn(GenAI.GEN_AI_SYSTEM_INSTRUCTIONS, event_attrs) - event_system = event_attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] - event_system_list = ( - list(event_system) - if isinstance(event_system, tuple) - else event_system - ) - event_sys_instr = ( - dict(event_system_list[0]) - if isinstance(event_system_list[0], tuple) - else event_system_list[0] - ) - self.assertEqual(event_sys_instr["content"], "System prompt here") - # Verify event context matches span context - span = _get_single_span(self.span_exporter) - self.assertIsNotNone(log_record.trace_id) - self.assertIsNotNone(log_record.span_id) - self.assertIsNotNone(span.context) - self.assertEqual(log_record.trace_id, span.context.trace_id) - self.assertEqual(log_record.span_id, span.context.span_id) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="true", - ) - def test_emits_llm_event_with_error(self): - class TestError(RuntimeError): - pass - - message = _create_input_message("error test") - invocation = LLMInvocation( - request_model="error-model", - input_messages=[message], - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - error = Error(message="Test error occurred", type=TestError) - self.telemetry_handler.fail_llm(invocation, error) - - # Check event was emitted - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - attrs = log_record.attributes - - # Verify error attribute is present - self.assertEqual( - attrs[error_attributes.ERROR_TYPE], TestError.__qualname__ - ) - self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "chat") - self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MODEL], "error-model") - # Verify event context matches span context - span = _get_single_span(self.span_exporter) - self.assertIsNotNone(log_record.trace_id) - self.assertIsNotNone(log_record.span_id) - self.assertIsNotNone(span.context) - self.assertEqual(log_record.trace_id, span.context.trace_id) - self.assertEqual(log_record.span_id, span.context.span_id) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="false", - ) - def test_does_not_emit_llm_event_when_emit_event_false(self): - message = _create_input_message("emit false test") - chat_generation = _create_output_message("emit false response") - - invocation = LLMInvocation( - request_model="emit-false-model", - input_messages=[message], - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) - - # Check no event was emitted - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 0) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="NO_CONTENT", - emit_event="", - ) - def test_does_not_emit_llm_event_by_default_for_no_content(self): - """Test that event is not emitted by default when content_capturing is NO_CONTENT and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" - invocation = LLMInvocation( - request_model="default-model", - input_messages=[_create_input_message("default test")], - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [ - _create_output_message("default response") - ] - self.telemetry_handler.stop_llm(invocation) - - # Check that no event was emitted (NO_CONTENT defaults to False) - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 0) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="SPAN_ONLY", - emit_event="", - ) - def test_does_not_emit_llm_event_by_default_for_span_only(self): - """Test that event is not emitted by default when content_capturing is SPAN_ONLY and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" - invocation = LLMInvocation( - request_model="default-model", - input_messages=[_create_input_message("default test")], - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [ - _create_output_message("default response") - ] - self.telemetry_handler.stop_llm(invocation) - - # Check that no event was emitted (SPAN_ONLY defaults to False) - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 0) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="EVENT_ONLY", - emit_event="", - ) - def test_emits_llm_event_by_default_for_event_only(self): - """Test that event is emitted by default when content_capturing is EVENT_ONLY and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" - invocation = LLMInvocation( - request_model="default-model", - input_messages=[_create_input_message("default test")], - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [ - _create_output_message("default response") - ] - self.telemetry_handler.stop_llm(invocation) - - # Check that event was emitted (EVENT_ONLY defaults to True) - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - self.assertEqual( - log_record.event_name, "gen_ai.client.inference.operation.details" - ) - - @patch_env_vars( - stability_mode="gen_ai_latest_experimental", - content_capturing="SPAN_AND_EVENT", - emit_event="", - ) - def test_emits_llm_event_by_default_for_span_and_event(self): - """Test that event is emitted by default when content_capturing is SPAN_AND_EVENT and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" - message = _create_input_message("span and event test") - chat_generation = _create_output_message("span and event response") - system_instruction = _create_system_instruction("System prompt") - - invocation = LLMInvocation( - request_model="span-and-event-model", - input_messages=[message], - system_instruction=system_instruction, - provider="test-provider", - ) - - self.telemetry_handler.start_llm(invocation) - invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) - - # Check span was created - span = _get_single_span(self.span_exporter) - span_attrs = _get_span_attributes(span) - self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, span_attrs) - - # Check that event was emitted (SPAN_AND_EVENT defaults to True) - logs = self.log_exporter.get_finished_logs() - self.assertEqual(len(logs), 1) - log_record = logs[0].log_record - self.assertEqual( - log_record.event_name, "gen_ai.client.inference.operation.details" - ) - self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, log_record.attributes) - @patch_env_vars( stability_mode="gen_ai_latest_experimental", content_capturing="SPAN_ONLY", diff --git a/util/opentelemetry-util-genai/tests/test_utils_events.py b/util/opentelemetry-util-genai/tests/test_utils_events.py new file mode 100644 index 0000000000..20b3300c62 --- /dev/null +++ b/util/opentelemetry-util-genai/tests/test_utils_events.py @@ -0,0 +1,380 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +import json +import unittest + +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.semconv.attributes import error_attributes +from opentelemetry.util.genai.handler import get_telemetry_handler +from opentelemetry.util.genai.types import Error, LLMInvocation + +from .test_utils import ( + _create_input_message, + _create_output_message, + _create_system_instruction, + _get_single_span, + _get_span_attributes, + _normalize_to_dict, + _normalize_to_list, + patch_env_vars, +) + + +class TestTelemetryHandlerEvents(unittest.TestCase): + def setUp(self): + self.span_exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + self.log_exporter = InMemoryLogExporter() + logger_provider = LoggerProvider() + logger_provider.add_log_record_processor( + SimpleLogRecordProcessor(self.log_exporter) + ) + self.telemetry_handler = get_telemetry_handler( + tracer_provider=tracer_provider, logger_provider=logger_provider + ) + + def tearDown(self): + self.span_exporter.clear() + self.log_exporter.clear() + if hasattr(get_telemetry_handler, "_default_handler"): + delattr(get_telemetry_handler, "_default_handler") + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="true", + ) + def test_emits_llm_event(self): + invocation = LLMInvocation( + request_model="event-model", + input_messages=[_create_input_message("test query")], + system_instruction=_create_system_instruction(), + provider="test-provider", + temperature=0.7, + max_tokens=100, + response_model_name="response-model", + response_id="event-response-id", + input_tokens=10, + output_tokens=20, + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [_create_output_message("test response")] + self.telemetry_handler.stop_llm(invocation) + + # Check that event was emitted + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + + # Verify event name + self.assertEqual( + log_record.event_name, "gen_ai.client.inference.operation.details" + ) + + # Verify event attributes + attrs = log_record.attributes + self.assertIsNotNone(attrs) + self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "chat") + self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MODEL], "event-model") + self.assertEqual(attrs[GenAI.GEN_AI_PROVIDER_NAME], "test-provider") + self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_TEMPERATURE], 0.7) + self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MAX_TOKENS], 100) + self.assertEqual(attrs[GenAI.GEN_AI_RESPONSE_MODEL], "response-model") + self.assertEqual(attrs[GenAI.GEN_AI_RESPONSE_ID], "event-response-id") + self.assertEqual(attrs[GenAI.GEN_AI_USAGE_INPUT_TOKENS], 10) + self.assertEqual(attrs[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS], 20) + + # Verify messages are in structured format (not JSON string) + # OpenTelemetry may convert lists to tuples, so we normalize + input_msg = _normalize_to_dict( + _normalize_to_list(attrs[GenAI.GEN_AI_INPUT_MESSAGES])[0] + ) + self.assertEqual(input_msg["role"], "Human") + self.assertEqual( + _normalize_to_list(input_msg["parts"])[0]["content"], "test query" + ) + + output_msg = _normalize_to_dict( + _normalize_to_list(attrs[GenAI.GEN_AI_OUTPUT_MESSAGES])[0] + ) + self.assertEqual(output_msg["role"], "AI") + self.assertEqual( + _normalize_to_list(output_msg["parts"])[0]["content"], + "test response", + ) + self.assertEqual(output_msg["finish_reason"], "stop") + + # Verify system instruction is present in event in structured format + sys_instr = _normalize_to_dict( + _normalize_to_list(attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS])[0] + ) + self.assertEqual(sys_instr["content"], "You are a helpful assistant.") + self.assertEqual(sys_instr["type"], "text") + + # Verify event context matches span context + span = _get_single_span(self.span_exporter) + self.assertIsNotNone(log_record.trace_id) + self.assertIsNotNone(log_record.span_id) + self.assertIsNotNone(span.context) + self.assertEqual(log_record.trace_id, span.context.trace_id) + self.assertEqual(log_record.span_id, span.context.span_id) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_AND_EVENT", + emit_event="true", + ) + def test_emits_llm_event_and_span(self): + message = _create_input_message("combined test") + chat_generation = _create_output_message("combined response") + system_instruction = _create_system_instruction("System prompt here") + + invocation = LLMInvocation( + request_model="combined-model", + input_messages=[message], + system_instruction=system_instruction, + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [chat_generation] + self.telemetry_handler.stop_llm(invocation) + + # Check span was created + span = _get_single_span(self.span_exporter) + span_attrs = _get_span_attributes(span) + self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, span_attrs) + + # Check event was emitted + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + self.assertEqual( + log_record.event_name, "gen_ai.client.inference.operation.details" + ) + self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, log_record.attributes) + # Verify system instruction in both span and event + self.assertIn(GenAI.GEN_AI_SYSTEM_INSTRUCTIONS, span_attrs) + span_system = json.loads(span_attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS]) + self.assertEqual(span_system[0]["content"], "System prompt here") + event_attrs = log_record.attributes + self.assertIn(GenAI.GEN_AI_SYSTEM_INSTRUCTIONS, event_attrs) + event_system = event_attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] + event_system_list = ( + list(event_system) + if isinstance(event_system, tuple) + else event_system + ) + event_sys_instr = ( + dict(event_system_list[0]) + if isinstance(event_system_list[0], tuple) + else event_system_list[0] + ) + self.assertEqual(event_sys_instr["content"], "System prompt here") + # Verify event context matches span context + span = _get_single_span(self.span_exporter) + self.assertIsNotNone(log_record.trace_id) + self.assertIsNotNone(log_record.span_id) + self.assertIsNotNone(span.context) + self.assertEqual(log_record.trace_id, span.context.trace_id) + self.assertEqual(log_record.span_id, span.context.span_id) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="true", + ) + def test_emits_llm_event_with_error(self): + class TestError(RuntimeError): + pass + + message = _create_input_message("error test") + invocation = LLMInvocation( + request_model="error-model", + input_messages=[message], + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + error = Error(message="Test error occurred", type=TestError) + self.telemetry_handler.fail_llm(invocation, error) + + # Check event was emitted + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + attrs = log_record.attributes + + # Verify error attribute is present + self.assertEqual( + attrs[error_attributes.ERROR_TYPE], TestError.__qualname__ + ) + self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "chat") + self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MODEL], "error-model") + # Verify event context matches span context + span = _get_single_span(self.span_exporter) + self.assertIsNotNone(log_record.trace_id) + self.assertIsNotNone(log_record.span_id) + self.assertIsNotNone(span.context) + self.assertEqual(log_record.trace_id, span.context.trace_id) + self.assertEqual(log_record.span_id, span.context.span_id) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="false", + ) + def test_does_not_emit_llm_event_when_emit_event_false(self): + message = _create_input_message("emit false test") + chat_generation = _create_output_message("emit false response") + + invocation = LLMInvocation( + request_model="emit-false-model", + input_messages=[message], + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [chat_generation] + self.telemetry_handler.stop_llm(invocation) + + # Check no event was emitted + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 0) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="NO_CONTENT", + emit_event="", + ) + def test_does_not_emit_llm_event_by_default_for_no_content(self): + """Test that event is not emitted by default when content_capturing is NO_CONTENT and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" + invocation = LLMInvocation( + request_model="default-model", + input_messages=[_create_input_message("default test")], + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [ + _create_output_message("default response") + ] + self.telemetry_handler.stop_llm(invocation) + + # Check that no event was emitted (NO_CONTENT defaults to False) + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 0) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_ONLY", + emit_event="", + ) + def test_does_not_emit_llm_event_by_default_for_span_only(self): + """Test that event is not emitted by default when content_capturing is SPAN_ONLY and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" + invocation = LLMInvocation( + request_model="default-model", + input_messages=[_create_input_message("default test")], + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [ + _create_output_message("default response") + ] + self.telemetry_handler.stop_llm(invocation) + + # Check that no event was emitted (SPAN_ONLY defaults to False) + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 0) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="EVENT_ONLY", + emit_event="", + ) + def test_emits_llm_event_by_default_for_event_only(self): + """Test that event is emitted by default when content_capturing is EVENT_ONLY and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" + invocation = LLMInvocation( + request_model="default-model", + input_messages=[_create_input_message("default test")], + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [ + _create_output_message("default response") + ] + self.telemetry_handler.stop_llm(invocation) + + # Check that event was emitted (EVENT_ONLY defaults to True) + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + self.assertEqual( + log_record.event_name, "gen_ai.client.inference.operation.details" + ) + + @patch_env_vars( + stability_mode="gen_ai_latest_experimental", + content_capturing="SPAN_AND_EVENT", + emit_event="", + ) + def test_emits_llm_event_by_default_for_span_and_event(self): + """Test that event is emitted by default when content_capturing is SPAN_AND_EVENT and OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT is not set.""" + message = _create_input_message("span and event test") + chat_generation = _create_output_message("span and event response") + system_instruction = _create_system_instruction("System prompt") + + invocation = LLMInvocation( + request_model="span-and-event-model", + input_messages=[message], + system_instruction=system_instruction, + provider="test-provider", + ) + + self.telemetry_handler.start_llm(invocation) + invocation.output_messages = [chat_generation] + self.telemetry_handler.stop_llm(invocation) + + # Check span was created + span = _get_single_span(self.span_exporter) + span_attrs = _get_span_attributes(span) + self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, span_attrs) + + # Check that event was emitted (SPAN_AND_EVENT defaults to True) + logs = self.log_exporter.get_finished_logs() + self.assertEqual(len(logs), 1) + log_record = logs[0].log_record + self.assertEqual( + log_record.event_name, "gen_ai.client.inference.operation.details" + ) + self.assertIn(GenAI.GEN_AI_INPUT_MESSAGES, log_record.attributes) From 9b78bc2bc2cdf2e2b2e5a140640fc7a49fbf7d08 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 12:36:47 -0700 Subject: [PATCH 34/41] Keeping only generic start,stop and fail methods for all invocation types --- .../pyproject.toml | 6 +- .../tests/requirements.oldest.txt | 6 +- .../pyproject.toml | 6 +- .../tests/requirements.oldest.txt | 6 +- .../pyproject.toml | 6 +- .../tests/requirements.oldest.txt | 6 +- .../src/opentelemetry/util/genai/handler.py | 86 ++++--------------- .../opentelemetry/util/genai/span_utils.py | 3 +- 8 files changed, 38 insertions(+), 87 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml index 5cc6754ef7..3988ec226b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.39", - "opentelemetry-instrumentation ~= 0.60b0", - "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.61b0", + "opentelemetry-semantic-conventions ~= 0.61b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt index 1b1d2b1994..22b0f02c9e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.39 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-anthropic diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml index fb38e61589..014797ccbd 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-api ~= 1.39", - "opentelemetry-instrumentation ~= 0.60b0", - "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.61b0", + "opentelemetry-semantic-conventions ~= 0.61b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt index 833e05fb1d..0d37595c24 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.39 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml index 7b4fcce224..3b604f22d1 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.39", - "opentelemetry-instrumentation ~= 0.60b0", - "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.61b0", + "opentelemetry-semantic-conventions ~= 0.61b0", "opentelemetry-util-genai", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt index 45339fb438..f72f0be88b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt @@ -29,9 +29,9 @@ pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 opentelemetry-exporter-otlp-proto-http~=1.30 -opentelemetry-api==1.39 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 -e util/opentelemetry-util-genai \ No newline at end of file diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 7bf9fd8325..666150ba14 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -47,15 +47,15 @@ ) # Start the invocation (opens a span) - handler.start_llm(invocation) + handler.start(invocation) # Populate outputs and any additional attributes, then stop (closes the span) invocation.output_messages = [...] invocation.attributes.update({"more": "attrs"}) - handler.stop_llm(invocation) + handler.stop(invocation) # Or, in case of error - handler.fail_llm(invocation, Error(type="...", message="...")) + handler.fail(invocation, Error(type="...", message="...")) """ from __future__ import annotations @@ -78,12 +78,13 @@ get_tracer, set_span_in_context, ) -from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.genai.metrics import InvocationMetricsRecorder from opentelemetry.util.genai.span_utils import ( _apply_embedding_finish_attributes, _apply_error_attributes, _apply_llm_finish_attributes, + _get_embedding_span_name, + _get_llm_span_name, _maybe_emit_llm_event, ) from opentelemetry.util.genai.types import ( @@ -157,8 +158,14 @@ def _record_embedding_metrics( def _start(self, invocation: _T) -> _T: """Start a GenAI invocation and create a pending span entry.""" + if isinstance(invocation, LLMInvocation): + span_name = _get_llm_span_name(invocation) + elif isinstance(invocation, EmbeddingInvocation): + span_name = _get_embedding_span_name(invocation) + else: + span_name = "" span = self._tracer.start_span( - name=invocation.operation_name or "", + name=span_name, kind=SpanKind.CLIENT, ) # Record a monotonic start timestamp (seconds) for duration @@ -185,16 +192,6 @@ def _stop(self, invocation: _T) -> _T: elif isinstance(invocation, EmbeddingInvocation): _apply_embedding_finish_attributes(span, invocation) self._record_embedding_metrics(invocation, span) - else: - span.set_status( - Status( - StatusCode.ERROR, - f"Unsupported invocation type: {type(invocation)!r}", - ) - ) - raise TypeError( - f"Unsupported invocation type: {type(invocation)!r}" - ) finally: # Detach context and end span even if finishing fails otel_context.detach(invocation.context_token) @@ -224,16 +221,6 @@ def _fail(self, invocation: _T, error: Error) -> _T: self._record_embedding_metrics( invocation, span, error_type=error_type ) - else: - span.set_status( - Status( - StatusCode.ERROR, - f"Unsupported invocation type: {type(invocation)!r}", - ) - ) - raise TypeError( - f"Unsupported invocation type: {type(invocation)!r}" - ) finally: # Detach context and end span even if finishing fails otel_context.detach(invocation.context_token) @@ -255,23 +242,6 @@ def fail(self, invocation: _T, error: Error) -> _T: """Fail a GenAI invocation and end its span with error status.""" return self._fail(invocation, error) - def start_llm( - self, - invocation: LLMInvocation, - ) -> LLMInvocation: - """Start an LLM invocation and create a pending span entry.""" - return self.start(invocation) - - def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: - """Finalize an LLM invocation successfully and end its span.""" - return self.stop(invocation) - - def fail_llm( - self, invocation: LLMInvocation, error: Error - ) -> LLMInvocation: - """Fail an LLM invocation and end its span with error status.""" - return self.fail(invocation, error) - @contextmanager def llm( self, invocation: LLMInvocation | None = None @@ -288,13 +258,13 @@ def llm( invocation = LLMInvocation( request_model="", ) - self.start_llm(invocation) + self.start(invocation) try: yield invocation except Exception as exc: - self.fail_llm(invocation, Error(message=str(exc), type=type(exc))) + self.fail(invocation, Error(message=str(exc), type=type(exc))) raise - self.stop_llm(invocation) + self.stop(invocation) @contextmanager def embedding( @@ -310,33 +280,13 @@ def embedding( """ if invocation is None: invocation = EmbeddingInvocation() - self.start_embedding(invocation) + self.start(invocation) try: yield invocation except Exception as exc: - self.fail_embedding( - invocation, Error(message=str(exc), type=type(exc)) - ) + self.fail(invocation, Error(message=str(exc), type=type(exc))) raise - self.stop_embedding(invocation) - - def start_embedding( - self, invocation: EmbeddingInvocation - ) -> EmbeddingInvocation: - """Start an embedding invocation and create a pending span entry.""" - return self.start(invocation) - - def stop_embedding( - self, invocation: EmbeddingInvocation - ) -> EmbeddingInvocation: - """Finalize an embedding invocation successfully and end its span.""" - return self.stop(invocation) - - def fail_embedding( - self, invocation: EmbeddingInvocation, error: Error - ) -> EmbeddingInvocation: - """Fail an embedding invocation and end its span with error status.""" - return self.fail(invocation, error) + self.stop(invocation) def get_telemetry_handler( diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index bc9d19a11f..851f468cfa 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -94,7 +94,8 @@ def _get_span_name( ) -> str: """Get the span name for a GenAI invocation.""" request_model = getattr(invocation, "request_model", None) or "" - return f"{invocation.operation_name} {request_model}".strip() + operation_name = getattr(invocation, "operation_name", None) or "" + return f"{operation_name} {request_model}".strip() def _get_llm_span_name(invocation: LLMInvocation) -> str: From 1e5f50c4ff66cd731d37bd51b8b08520721e676b Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 12 Mar 2026 13:03:34 -0700 Subject: [PATCH 35/41] Simplified error type calculation and refactor tests --- util/opentelemetry-util-genai/pyproject.toml | 6 +-- .../src/opentelemetry/util/genai/handler.py | 11 ++--- .../opentelemetry/util/genai/span_utils.py | 12 +++--- .../tests/test_handler_metrics.py | 12 +++--- .../tests/test_utils.py | 40 +++++++++---------- .../tests/test_utils_events.py | 32 +++++++-------- 6 files changed, 56 insertions(+), 57 deletions(-) diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index 5f5c7153fb..1ff3fdc0ef 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.60b0", - "opentelemetry-semantic-conventions ~= 0.60b0", - "opentelemetry-api>=1.31.0", + "opentelemetry-instrumentation ~= 0.61b0", + "opentelemetry-semantic-conventions ~= 0.61b0", + "opentelemetry-api>=1.40", ] [project.entry-points.opentelemetry_genai_completion_hook] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 666150ba14..6a249b4605 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -205,19 +205,20 @@ def _fail(self, invocation: _T, error: Error) -> _T: return invocation span = invocation.span + error_type = error.type.__qualname__ try: if isinstance(invocation, LLMInvocation): _apply_llm_finish_attributes(span, invocation) - _apply_error_attributes(span, error) - error_type = getattr(error.type, "__qualname__", None) + _apply_error_attributes(span, error, error_type) self._record_llm_metrics( invocation, span, error_type=error_type ) - _maybe_emit_llm_event(self._logger, span, invocation, error) + _maybe_emit_llm_event( + self._logger, span, invocation, error_type + ) elif isinstance(invocation, EmbeddingInvocation): _apply_embedding_finish_attributes(span, invocation) - _apply_error_attributes(span, error) - error_type = getattr(error.type, "__qualname__", None) + _apply_error_attributes(span, error, error_type) self._record_embedding_metrics( invocation, span, error_type=error_type ) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 851f468cfa..c8c8cb184e 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -186,7 +186,7 @@ def _maybe_emit_llm_event( logger: Logger | None, span: Span, invocation: LLMInvocation, - error: Error | None = None, + error_type: str | None = None, ) -> None: """Emit a gen_ai.client.inference.operation.details event to the logger. @@ -214,8 +214,8 @@ def _maybe_emit_llm_event( ) # Add error.type if operation ended in error - if error is not None: - attributes[error_attributes.ERROR_TYPE] = error.type.__qualname__ + if error_type is not None: + attributes[error_attributes.ERROR_TYPE] = error_type # Create and emit the event context = set_span_in_context(span, get_current()) @@ -273,13 +273,11 @@ def _apply_embedding_finish_attributes( span.set_attributes(attributes) -def _apply_error_attributes(span: Span, error: Error) -> None: +def _apply_error_attributes(span: Span, error: Error, error_type: str) -> None: """Apply status and error attributes common to error() paths.""" span.set_status(Status(StatusCode.ERROR, error.message)) if span.is_recording(): - span.set_attribute( - error_attributes.ERROR_TYPE, error.type.__qualname__ - ) + span.set_attribute(error_attributes.ERROR_TYPE, error_type) def _get_llm_request_attributes( diff --git a/util/opentelemetry-util-genai/tests/test_handler_metrics.py b/util/opentelemetry-util-genai/tests/test_handler_metrics.py index 6db12f0cd2..18c234161b 100644 --- a/util/opentelemetry-util-genai/tests/test_handler_metrics.py +++ b/util/opentelemetry-util-genai/tests/test_handler_metrics.py @@ -43,14 +43,14 @@ def test_stop_llm_records_duration_and_tokens(self) -> None: invocation.output_tokens = 7 # Patch default_timer during start to ensure monotonic_start_s with patch("timeit.default_timer", return_value=1000.0): - handler.start_llm(invocation) + handler.start(invocation) # Simulate 2 seconds of elapsed monotonic time (seconds) with patch( "timeit.default_timer", return_value=1002.0, ): - handler.stop_llm(invocation) + handler.stop(invocation) metrics, resource_metrics = self._harvest_metrics() self._assert_metric_scope_schema_urls( @@ -103,12 +103,12 @@ def test_stop_llm_records_duration_and_tokens_with_additional_attributes( invocation.output_tokens = 7 invocation.server_address = "custom.server.com" invocation.server_port = 42 - handler.start_llm(invocation) + handler.start(invocation) invocation.metric_attributes = { "custom.attribute": "custom_value", } invocation.attributes = {"should not be on metrics": "value"} - handler.stop_llm(invocation) + handler.stop(invocation) metrics, resource_metrics = self._harvest_metrics() self._assert_metric_scope_schema_urls( @@ -139,14 +139,14 @@ def test_fail_llm_records_error_and_available_tokens(self) -> None: invocation.input_tokens = 11 # Patch default_timer during start to ensure monotonic_start_s with patch("timeit.default_timer", return_value=2000.0): - handler.start_llm(invocation) + handler.start(invocation) error = Error(message="boom", type=ValueError) with patch( "timeit.default_timer", return_value=2001.0, ): - handler.fail_llm(invocation, error) + handler.fail(invocation, error) metrics, resource_metrics = self._harvest_metrics() self._assert_metric_scope_schema_urls( diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 4bb6fa86d7..e3b5223ab8 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -338,11 +338,11 @@ def test_llm_manual_start_and_stop_creates_span(self): attributes={"manual": True}, ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None invocation.output_messages = [chat_generation] invocation.attributes.update({"extra_manual": "yes"}) - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) assert span.name == "chat manual-model" @@ -375,9 +375,9 @@ def test_llm_span_finish_reasons_without_output_messages(self): output_tokens=34, ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) _assert_span_time_order(span) @@ -403,9 +403,9 @@ def test_llm_span_finish_reasons_deduplicated_from_invocation(self): finish_reasons=["stop", "length", "stop"], ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) attrs = _get_span_attributes(span) @@ -420,14 +420,14 @@ def test_llm_span_finish_reasons_deduplicated_from_output_messages(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None invocation.output_messages = [ _create_output_message("response-1", finish_reason="stop"), _create_output_message("response-2", finish_reason="length"), _create_output_message("response-3", finish_reason="stop"), ] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) attrs = _get_span_attributes(span) @@ -442,9 +442,9 @@ def test_llm_span_uses_expected_schema_url(self): provider="schema-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) instrumentation = getattr(span, "instrumentation_scope", None) @@ -468,9 +468,9 @@ def test_llm_log_uses_expected_schema_url(self): provider="schema-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [_create_output_message()] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) logs = self.log_exporter.get_finished_logs() self.assertEqual(len(logs), 1) @@ -538,12 +538,12 @@ def test_embedding_parent_child_span_relationship(self): input_tokens=5, ) - self.telemetry_handler.start_embedding(parent_invocation) + self.telemetry_handler.start(parent_invocation) assert parent_invocation.span is not None - self.telemetry_handler.start_embedding(child_invocation) + self.telemetry_handler.start(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop_embedding(child_invocation) - self.telemetry_handler.stop_embedding(parent_invocation) + self.telemetry_handler.stop(child_invocation) + self.telemetry_handler.stop(parent_invocation) spans = self.span_exporter.get_finished_spans() assert len(spans) == 2 @@ -580,9 +580,9 @@ def test_llm_parent_embedding_child_span_relationship(self): "provider": "test-provider", }.items(): setattr(parent_invocation, attr, value) - self.telemetry_handler.start_embedding(child_invocation) + self.telemetry_handler.start(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop_embedding(child_invocation) + self.telemetry_handler.stop(child_invocation) parent_invocation.output_messages = [chat_generation] spans = self.span_exporter.get_finished_spans() @@ -703,11 +703,11 @@ def test_embedding_manual_start_and_stop_creates_span(self): attributes={"custom_embed_attr": "value"}, ) - self.telemetry_handler.start_embedding(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None invocation.attributes.update({"extra_embed": "info"}) invocation.metric_attributes = {"should not be on span": "value"} - self.telemetry_handler.stop_embedding(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) self.assertEqual(span.name, "embeddings embed-model") diff --git a/util/opentelemetry-util-genai/tests/test_utils_events.py b/util/opentelemetry-util-genai/tests/test_utils_events.py index 20b3300c62..06580ceca5 100644 --- a/util/opentelemetry-util-genai/tests/test_utils_events.py +++ b/util/opentelemetry-util-genai/tests/test_utils_events.py @@ -85,9 +85,9 @@ def test_emits_llm_event(self): output_tokens=20, ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [_create_output_message("test response")] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check that event was emitted logs = self.log_exporter.get_finished_logs() @@ -164,9 +164,9 @@ def test_emits_llm_event_and_span(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check span was created span = _get_single_span(self.span_exporter) @@ -223,9 +223,9 @@ class TestError(RuntimeError): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) error = Error(message="Test error occurred", type=TestError) - self.telemetry_handler.fail_llm(invocation, error) + self.telemetry_handler.fail(invocation, error) # Check event was emitted logs = self.log_exporter.get_finished_logs() @@ -262,9 +262,9 @@ def test_does_not_emit_llm_event_when_emit_event_false(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check no event was emitted logs = self.log_exporter.get_finished_logs() @@ -283,11 +283,11 @@ def test_does_not_emit_llm_event_by_default_for_no_content(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check that no event was emitted (NO_CONTENT defaults to False) logs = self.log_exporter.get_finished_logs() @@ -306,11 +306,11 @@ def test_does_not_emit_llm_event_by_default_for_span_only(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check that no event was emitted (SPAN_ONLY defaults to False) logs = self.log_exporter.get_finished_logs() @@ -329,11 +329,11 @@ def test_emits_llm_event_by_default_for_event_only(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check that event was emitted (EVENT_ONLY defaults to True) logs = self.log_exporter.get_finished_logs() @@ -361,9 +361,9 @@ def test_emits_llm_event_by_default_for_span_and_event(self): provider="test-provider", ) - self.telemetry_handler.start_llm(invocation) + self.telemetry_handler.start(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop_llm(invocation) + self.telemetry_handler.stop(invocation) # Check span was created span = _get_single_span(self.span_exporter) From dbce15fb6c9e92792c804cdd823290affeec84db Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Mar 2026 07:03:00 -0700 Subject: [PATCH 36/41] Reverting start_[type] methods removals, fixing some related tests --- .../src/opentelemetry/util/genai/handler.py | 64 ++++++++++++++----- .../opentelemetry/util/genai/span_utils.py | 4 +- .../tests/test_handler_metrics.py | 12 ++-- .../tests/test_utils.py | 40 ++++++------ .../tests/test_utils_events.py | 32 +++++----- 5 files changed, 91 insertions(+), 61 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 6a249b4605..4632ab59e6 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -47,15 +47,15 @@ ) # Start the invocation (opens a span) - handler.start(invocation) + handler.start_llm(invocation) # Populate outputs and any additional attributes, then stop (closes the span) invocation.output_messages = [...] invocation.attributes.update({"more": "attrs"}) - handler.stop(invocation) + handler.stop_llm(invocation) # Or, in case of error - handler.fail(invocation, Error(type="...", message="...")) + handler.fail_llm(invocation, Error(type="...", message="...")) """ from __future__ import annotations @@ -83,8 +83,6 @@ _apply_embedding_finish_attributes, _apply_error_attributes, _apply_llm_finish_attributes, - _get_embedding_span_name, - _get_llm_span_name, _maybe_emit_llm_event, ) from opentelemetry.util.genai.types import ( @@ -158,12 +156,9 @@ def _record_embedding_metrics( def _start(self, invocation: _T) -> _T: """Start a GenAI invocation and create a pending span entry.""" - if isinstance(invocation, LLMInvocation): - span_name = _get_llm_span_name(invocation) - elif isinstance(invocation, EmbeddingInvocation): - span_name = _get_embedding_span_name(invocation) - else: - span_name = "" + operation_name = getattr(invocation, "operation_name", "") + request_model = getattr(invocation, "request_model", "") + span_name = f"{operation_name} {request_model}".strip() span = self._tracer.start_span( name=span_name, kind=SpanKind.CLIENT, @@ -243,6 +238,21 @@ def fail(self, invocation: _T, error: Error) -> _T: """Fail a GenAI invocation and end its span with error status.""" return self._fail(invocation, error) + # LLM-specific convenience methods + def start_llm(self, invocation: LLMInvocation) -> LLMInvocation: + """Start an LLM invocation and create a pending span entry.""" + return self._start(invocation) + + def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: + """Finalize an LLM invocation successfully and end its span.""" + return self._stop(invocation) + + def fail_llm( + self, invocation: LLMInvocation, error: Error + ) -> LLMInvocation: + """Fail an LLM invocation and end its span with error status.""" + return self._fail(invocation, error) + @contextmanager def llm( self, invocation: LLMInvocation | None = None @@ -259,13 +269,13 @@ def llm( invocation = LLMInvocation( request_model="", ) - self.start(invocation) + self.start_llm(invocation) try: yield invocation except Exception as exc: - self.fail(invocation, Error(message=str(exc), type=type(exc))) + self.fail_llm(invocation, Error(message=str(exc), type=type(exc))) raise - self.stop(invocation) + self.stop_llm(invocation) @contextmanager def embedding( @@ -281,13 +291,33 @@ def embedding( """ if invocation is None: invocation = EmbeddingInvocation() - self.start(invocation) + self.start_embedding(invocation) try: yield invocation except Exception as exc: - self.fail(invocation, Error(message=str(exc), type=type(exc))) + self.fail_embedding( + invocation, Error(message=str(exc), type=type(exc)) + ) raise - self.stop(invocation) + self.stop_embedding(invocation) + + def start_embedding( + self, invocation: EmbeddingInvocation + ) -> EmbeddingInvocation: + """Start an embedding invocation and create a pending span entry.""" + return self.start(invocation) + + def stop_embedding( + self, invocation: EmbeddingInvocation + ) -> EmbeddingInvocation: + """Finalize an embedding invocation successfully and end its span.""" + return self.stop(invocation) + + def fail_embedding( + self, invocation: EmbeddingInvocation, error: Error + ) -> EmbeddingInvocation: + """Fail an embedding invocation and end its span with error status.""" + return self.fail(invocation, error) def get_telemetry_handler( diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index c8c8cb184e..0c4334d7a4 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -93,8 +93,8 @@ def _get_span_name( invocation: GenAIInvocation, ) -> str: """Get the span name for a GenAI invocation.""" - request_model = getattr(invocation, "request_model", None) or "" - operation_name = getattr(invocation, "operation_name", None) or "" + operation_name = getattr(invocation, "operation_name", "") + request_model = getattr(invocation, "request_model", None) return f"{operation_name} {request_model}".strip() diff --git a/util/opentelemetry-util-genai/tests/test_handler_metrics.py b/util/opentelemetry-util-genai/tests/test_handler_metrics.py index bd549491eb..b45383abfe 100644 --- a/util/opentelemetry-util-genai/tests/test_handler_metrics.py +++ b/util/opentelemetry-util-genai/tests/test_handler_metrics.py @@ -27,14 +27,14 @@ def test_stop_llm_records_duration_and_tokens(self) -> None: invocation.output_tokens = 7 # Patch default_timer during start to ensure monotonic_start_s with patch("timeit.default_timer", return_value=1000.0): - handler.start(invocation) + handler.start_llm(invocation) # Simulate 2 seconds of elapsed monotonic time (seconds) with patch( "timeit.default_timer", return_value=1002.0, ): - handler.stop(invocation) + handler.stop_llm(invocation) self._assert_metric_scope_schema_urls(_DEFAULT_SCHEMA_URL) metrics = self._harvest_metrics() @@ -85,12 +85,12 @@ def test_stop_llm_records_duration_and_tokens_with_additional_attributes( invocation.output_tokens = 7 invocation.server_address = "custom.server.com" invocation.server_port = 42 - handler.start(invocation) + handler.start_llm(invocation) invocation.metric_attributes = { "custom.attribute": "custom_value", } invocation.attributes = {"should not be on metrics": "value"} - handler.stop(invocation) + handler.stop_llm(invocation) self._assert_metric_scope_schema_urls(_DEFAULT_SCHEMA_URL) metrics = self._harvest_metrics() @@ -119,14 +119,14 @@ def test_fail_llm_records_error_and_available_tokens(self) -> None: invocation.input_tokens = 11 # Patch default_timer during start to ensure monotonic_start_s with patch("timeit.default_timer", return_value=2000.0): - handler.start(invocation) + handler.start_llm(invocation) error = Error(message="boom", type=ValueError) with patch( "timeit.default_timer", return_value=2001.0, ): - handler.fail(invocation, error) + handler.fail_llm(invocation, error) self._assert_metric_scope_schema_urls(_DEFAULT_SCHEMA_URL) metrics = self._harvest_metrics() diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index e3b5223ab8..4bb6fa86d7 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -338,11 +338,11 @@ def test_llm_manual_start_and_stop_creates_span(self): attributes={"manual": True}, ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) assert invocation.span is not None invocation.output_messages = [chat_generation] invocation.attributes.update({"extra_manual": "yes"}) - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) span = _get_single_span(self.span_exporter) assert span.name == "chat manual-model" @@ -375,9 +375,9 @@ def test_llm_span_finish_reasons_without_output_messages(self): output_tokens=34, ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) assert invocation.span is not None - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) span = _get_single_span(self.span_exporter) _assert_span_time_order(span) @@ -403,9 +403,9 @@ def test_llm_span_finish_reasons_deduplicated_from_invocation(self): finish_reasons=["stop", "length", "stop"], ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) assert invocation.span is not None - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) span = _get_single_span(self.span_exporter) attrs = _get_span_attributes(span) @@ -420,14 +420,14 @@ def test_llm_span_finish_reasons_deduplicated_from_output_messages(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) assert invocation.span is not None invocation.output_messages = [ _create_output_message("response-1", finish_reason="stop"), _create_output_message("response-2", finish_reason="length"), _create_output_message("response-3", finish_reason="stop"), ] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) span = _get_single_span(self.span_exporter) attrs = _get_span_attributes(span) @@ -442,9 +442,9 @@ def test_llm_span_uses_expected_schema_url(self): provider="schema-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) assert invocation.span is not None - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) span = _get_single_span(self.span_exporter) instrumentation = getattr(span, "instrumentation_scope", None) @@ -468,9 +468,9 @@ def test_llm_log_uses_expected_schema_url(self): provider="schema-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [_create_output_message()] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) logs = self.log_exporter.get_finished_logs() self.assertEqual(len(logs), 1) @@ -538,12 +538,12 @@ def test_embedding_parent_child_span_relationship(self): input_tokens=5, ) - self.telemetry_handler.start(parent_invocation) + self.telemetry_handler.start_embedding(parent_invocation) assert parent_invocation.span is not None - self.telemetry_handler.start(child_invocation) + self.telemetry_handler.start_embedding(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop(child_invocation) - self.telemetry_handler.stop(parent_invocation) + self.telemetry_handler.stop_embedding(child_invocation) + self.telemetry_handler.stop_embedding(parent_invocation) spans = self.span_exporter.get_finished_spans() assert len(spans) == 2 @@ -580,9 +580,9 @@ def test_llm_parent_embedding_child_span_relationship(self): "provider": "test-provider", }.items(): setattr(parent_invocation, attr, value) - self.telemetry_handler.start(child_invocation) + self.telemetry_handler.start_embedding(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop(child_invocation) + self.telemetry_handler.stop_embedding(child_invocation) parent_invocation.output_messages = [chat_generation] spans = self.span_exporter.get_finished_spans() @@ -703,11 +703,11 @@ def test_embedding_manual_start_and_stop_creates_span(self): attributes={"custom_embed_attr": "value"}, ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_embedding(invocation) assert invocation.span is not None invocation.attributes.update({"extra_embed": "info"}) invocation.metric_attributes = {"should not be on span": "value"} - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_embedding(invocation) span = _get_single_span(self.span_exporter) self.assertEqual(span.name, "embeddings embed-model") diff --git a/util/opentelemetry-util-genai/tests/test_utils_events.py b/util/opentelemetry-util-genai/tests/test_utils_events.py index 06580ceca5..20b3300c62 100644 --- a/util/opentelemetry-util-genai/tests/test_utils_events.py +++ b/util/opentelemetry-util-genai/tests/test_utils_events.py @@ -85,9 +85,9 @@ def test_emits_llm_event(self): output_tokens=20, ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [_create_output_message("test response")] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check that event was emitted logs = self.log_exporter.get_finished_logs() @@ -164,9 +164,9 @@ def test_emits_llm_event_and_span(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check span was created span = _get_single_span(self.span_exporter) @@ -223,9 +223,9 @@ class TestError(RuntimeError): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) error = Error(message="Test error occurred", type=TestError) - self.telemetry_handler.fail(invocation, error) + self.telemetry_handler.fail_llm(invocation, error) # Check event was emitted logs = self.log_exporter.get_finished_logs() @@ -262,9 +262,9 @@ def test_does_not_emit_llm_event_when_emit_event_false(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check no event was emitted logs = self.log_exporter.get_finished_logs() @@ -283,11 +283,11 @@ def test_does_not_emit_llm_event_by_default_for_no_content(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check that no event was emitted (NO_CONTENT defaults to False) logs = self.log_exporter.get_finished_logs() @@ -306,11 +306,11 @@ def test_does_not_emit_llm_event_by_default_for_span_only(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check that no event was emitted (SPAN_ONLY defaults to False) logs = self.log_exporter.get_finished_logs() @@ -329,11 +329,11 @@ def test_emits_llm_event_by_default_for_event_only(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [ _create_output_message("default response") ] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check that event was emitted (EVENT_ONLY defaults to True) logs = self.log_exporter.get_finished_logs() @@ -361,9 +361,9 @@ def test_emits_llm_event_by_default_for_span_and_event(self): provider="test-provider", ) - self.telemetry_handler.start(invocation) + self.telemetry_handler.start_llm(invocation) invocation.output_messages = [chat_generation] - self.telemetry_handler.stop(invocation) + self.telemetry_handler.stop_llm(invocation) # Check span was created span = _get_single_span(self.span_exporter) From a58b540d19dc7c0b0f49862700a69910b31e8313 Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Mar 2026 11:44:28 -0700 Subject: [PATCH 37/41] Removing start_embedding, stop_embedding and fail_embedding methods --- util/opentelemetry-util-genai/README.rst | 3 +-- .../src/opentelemetry/util/genai/handler.py | 26 +++---------------- .../tests/test_utils.py | 16 ++++++------ 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/util/opentelemetry-util-genai/README.rst b/util/opentelemetry-util-genai/README.rst index 5e3cf82c26..94806e0159 100644 --- a/util/opentelemetry-util-genai/README.rst +++ b/util/opentelemetry-util-genai/README.rst @@ -36,8 +36,7 @@ This package provides these span attributes: - `gen_ai.output.messages`: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]') - `gen_ai.system_instructions`: Str('[{"content": "You are a helpful assistant.", "type": "text"}]') (when system instruction is provided) -This package also supports embedding invocation spans via -`EmbeddingInvocation` and `TelemetryHandler.start_embedding/stop_embedding/fail_embedding`. +This package also supports embedding invocation spans via the `embedding` context manager. For embedding invocations, common attributes include: - `gen_ai.provider.name`: Str(openai) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 4632ab59e6..aaebdb7e87 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -291,33 +291,13 @@ def embedding( """ if invocation is None: invocation = EmbeddingInvocation() - self.start_embedding(invocation) + self.start(invocation) try: yield invocation except Exception as exc: - self.fail_embedding( - invocation, Error(message=str(exc), type=type(exc)) - ) + self.fail(invocation, Error(message=str(exc), type=type(exc))) raise - self.stop_embedding(invocation) - - def start_embedding( - self, invocation: EmbeddingInvocation - ) -> EmbeddingInvocation: - """Start an embedding invocation and create a pending span entry.""" - return self.start(invocation) - - def stop_embedding( - self, invocation: EmbeddingInvocation - ) -> EmbeddingInvocation: - """Finalize an embedding invocation successfully and end its span.""" - return self.stop(invocation) - - def fail_embedding( - self, invocation: EmbeddingInvocation, error: Error - ) -> EmbeddingInvocation: - """Fail an embedding invocation and end its span with error status.""" - return self.fail(invocation, error) + self.stop(invocation) def get_telemetry_handler( diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 4bb6fa86d7..ee0e63d852 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -538,12 +538,12 @@ def test_embedding_parent_child_span_relationship(self): input_tokens=5, ) - self.telemetry_handler.start_embedding(parent_invocation) + self.telemetry_handler.start(parent_invocation) assert parent_invocation.span is not None - self.telemetry_handler.start_embedding(child_invocation) + self.telemetry_handler.start(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop_embedding(child_invocation) - self.telemetry_handler.stop_embedding(parent_invocation) + self.telemetry_handler.stop(child_invocation) + self.telemetry_handler.stop(parent_invocation) spans = self.span_exporter.get_finished_spans() assert len(spans) == 2 @@ -580,9 +580,9 @@ def test_llm_parent_embedding_child_span_relationship(self): "provider": "test-provider", }.items(): setattr(parent_invocation, attr, value) - self.telemetry_handler.start_embedding(child_invocation) + self.telemetry_handler.start(child_invocation) assert child_invocation.span is not None - self.telemetry_handler.stop_embedding(child_invocation) + self.telemetry_handler.stop(child_invocation) parent_invocation.output_messages = [chat_generation] spans = self.span_exporter.get_finished_spans() @@ -703,11 +703,11 @@ def test_embedding_manual_start_and_stop_creates_span(self): attributes={"custom_embed_attr": "value"}, ) - self.telemetry_handler.start_embedding(invocation) + self.telemetry_handler.start(invocation) assert invocation.span is not None invocation.attributes.update({"extra_embed": "info"}) invocation.metric_attributes = {"should not be on span": "value"} - self.telemetry_handler.stop_embedding(invocation) + self.telemetry_handler.stop(invocation) span = _get_single_span(self.span_exporter) self.assertEqual(span.name, "embeddings embed-model") From 6dfa5f0e4993bb62e2938f881ffd29399ab330cd Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Mar 2026 12:00:15 -0700 Subject: [PATCH 38/41] Adding one missing change --- .../src/opentelemetry/util/genai/handler.py | 11 ++++++++--- .../src/opentelemetry/util/genai/span_utils.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index aaebdb7e87..80b801e9a1 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -83,6 +83,8 @@ _apply_embedding_finish_attributes, _apply_error_attributes, _apply_llm_finish_attributes, + _get_embedding_span_name, + _get_llm_span_name, _maybe_emit_llm_event, ) from opentelemetry.util.genai.types import ( @@ -156,9 +158,12 @@ def _record_embedding_metrics( def _start(self, invocation: _T) -> _T: """Start a GenAI invocation and create a pending span entry.""" - operation_name = getattr(invocation, "operation_name", "") - request_model = getattr(invocation, "request_model", "") - span_name = f"{operation_name} {request_model}".strip() + if isinstance(invocation, LLMInvocation): + span_name = _get_llm_span_name(invocation) + elif isinstance(invocation, EmbeddingInvocation): + span_name = _get_embedding_span_name(invocation) + else: + span_name = "" span = self._tracer.start_span( name=span_name, kind=SpanKind.CLIENT, diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 0c4334d7a4..ac099e3dae 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -93,8 +93,8 @@ def _get_span_name( invocation: GenAIInvocation, ) -> str: """Get the span name for a GenAI invocation.""" - operation_name = getattr(invocation, "operation_name", "") - request_model = getattr(invocation, "request_model", None) + operation_name = getattr(invocation, "operation_name", None) or "" + request_model = getattr(invocation, "request_model", None) or "" return f"{operation_name} {request_model}".strip() From c5eb2f5014421b23db507e406b0d124ae289f8eb Mon Sep 17 00:00:00 2001 From: shuningc Date: Thu, 19 Mar 2026 12:16:44 -0700 Subject: [PATCH 39/41] ruff format fix --- .../tests/asyncpg/test_asyncpg_functional.py | 92 +++++-------------- .../tests/celery/test_celery_functional.py | 76 ++++----------- .../tests/mysql/test_mysql_functional.py | 16 +--- .../tests/postgres/test_aiopg_functional.py | 40 ++------ .../tests/postgres/test_psycopg_functional.py | 20 +--- .../tests/pymongo/test_pymongo_functional.py | 12 +-- .../tests/pymysql/test_pymysql_functional.py | 16 +--- .../tests/redis/test_redis_functional.py | 84 +++++------------ .../tests/sqlalchemy_tests/mixins.py | 4 +- .../tests/sqlalchemy_tests/test_mssql.py | 8 +- .../tests/sqlalchemy_tests/test_mysql.py | 8 +- .../tests/sqlalchemy_tests/test_postgres.py | 4 +- .../tests/sqlalchemy_tests/test_sqlite.py | 4 +- 13 files changed, 93 insertions(+), 291 deletions(-) diff --git a/tests/opentelemetry-docker-tests/tests/asyncpg/test_asyncpg_functional.py b/tests/opentelemetry-docker-tests/tests/asyncpg/test_asyncpg_functional.py index 8242353ae7..6a2499fdb4 100644 --- a/tests/opentelemetry-docker-tests/tests/asyncpg/test_asyncpg_functional.py +++ b/tests/opentelemetry-docker-tests/tests/asyncpg/test_asyncpg_functional.py @@ -40,21 +40,11 @@ def async_call(coro): class CheckSpanMixin: def check_span(self, span, expected_db_name=POSTGRES_DB_NAME): - self.assertEqual( - span.attributes[DB_SYSTEM], "postgresql" - ) - self.assertEqual( - span.attributes[DB_NAME], expected_db_name - ) - self.assertEqual( - span.attributes[DB_USER], POSTGRES_USER - ) - self.assertEqual( - span.attributes[NET_PEER_NAME], POSTGRES_HOST - ) - self.assertEqual( - span.attributes[NET_PEER_PORT], POSTGRES_PORT - ) + self.assertEqual(span.attributes[DB_SYSTEM], "postgresql") + self.assertEqual(span.attributes[DB_NAME], expected_db_name) + self.assertEqual(span.attributes[DB_USER], POSTGRES_USER) + self.assertEqual(span.attributes[NET_PEER_NAME], POSTGRES_HOST) + self.assertEqual(span.attributes[NET_PEER_PORT], POSTGRES_PORT) class TestFunctionalAsyncPG(TestBase, CheckSpanMixin): @@ -84,9 +74,7 @@ def test_instrumented_execute_method_without_arguments(self, *_, **__): self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT 42;") def test_instrumented_execute_method_error(self, *_, **__): """Should create an error span for execute() with the database name as the span name.""" @@ -107,9 +95,7 @@ def test_instrumented_fetch_method_without_arguments(self, *_, **__): self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT 42;") def test_instrumented_fetch_method_empty_query(self, *_, **__): """Should create an error span for fetch() with the database name as the span name.""" @@ -129,9 +115,7 @@ def test_instrumented_fetchval_method_without_arguments(self, *_, **__): self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT 42;") def test_instrumented_fetchval_method_empty_query(self, *_, **__): """Should create an error span for fetchval() with the database name as the span name.""" @@ -151,9 +135,7 @@ def test_instrumented_fetchrow_method_without_arguments(self, *_, **__): self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT 42;") def test_instrumented_fetchrow_method_empty_query(self, *_, **__): """Should create an error span for fetchrow() with the database name as the span name.""" @@ -182,9 +164,7 @@ async def _cursor_execute(): self.check_span(spans[0]) self.assertEqual(spans[0].name, "BEGIN;") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "BEGIN;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "BEGIN;") self.assertIs(StatusCode.UNSET, spans[0].status.status_code) for span in spans[1:-1]: @@ -198,9 +178,7 @@ async def _cursor_execute(): self.check_span(spans[-1]) self.assertEqual(spans[-1].name, "COMMIT;") - self.assertEqual( - spans[-1].attributes[DB_STATEMENT], "COMMIT;" - ) + self.assertEqual(spans[-1].attributes[DB_STATEMENT], "COMMIT;") def test_instrumented_cursor_execute_method_empty_query(self, *_, **__): """Should create spans for the transaction and cursor fetches with the database name as the span name.""" @@ -215,9 +193,7 @@ async def _cursor_execute(): self.assertEqual(len(spans), 3) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "BEGIN;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "BEGIN;") self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[1]) @@ -226,9 +202,7 @@ async def _cursor_execute(): self.assertIs(StatusCode.UNSET, spans[1].status.status_code) self.check_span(spans[2]) - self.assertEqual( - spans[2].attributes[DB_STATEMENT], "COMMIT;" - ) + self.assertEqual(spans[2].attributes[DB_STATEMENT], "COMMIT;") def test_instrumented_remove_comments(self, *_, **__): """Should remove comments from the query and set the span name correctly.""" @@ -275,21 +249,15 @@ async def _transaction_execute(): spans = self.memory_exporter.get_finished_spans() self.assertEqual(3, len(spans)) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "BEGIN;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "BEGIN;") self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[1]) - self.assertEqual( - spans[1].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[1].attributes[DB_STATEMENT], "SELECT 42;") self.assertIs(StatusCode.UNSET, spans[1].status.status_code) self.check_span(spans[2]) - self.assertEqual( - spans[2].attributes[DB_STATEMENT], "COMMIT;" - ) + self.assertEqual(spans[2].attributes[DB_STATEMENT], "COMMIT;") self.assertIs(StatusCode.UNSET, spans[2].status.status_code) def test_instrumented_failed_transaction_method(self, *_, **__): @@ -306,9 +274,7 @@ async def _transaction_execute(): self.assertEqual(3, len(spans)) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "BEGIN;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "BEGIN;") self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[1]) @@ -319,9 +285,7 @@ async def _transaction_execute(): self.assertEqual(StatusCode.ERROR, spans[1].status.status_code) self.check_span(spans[2]) - self.assertEqual( - spans[2].attributes[DB_STATEMENT], "ROLLBACK;" - ) + self.assertEqual(spans[2].attributes[DB_STATEMENT], "ROLLBACK;") self.assertIs(StatusCode.UNSET, spans[2].status.status_code) def test_instrumented_method_doesnt_capture_parameters(self, *_, **__): @@ -331,9 +295,7 @@ def test_instrumented_method_doesnt_capture_parameters(self, *_, **__): self.assertEqual(len(spans), 1) self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT $1;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT $1;") class TestFunctionalAsyncPG_CaptureParameters(TestBase, CheckSpanMixin): @@ -366,9 +328,7 @@ def test_instrumented_execute_method_with_arguments(self, *_, **__): self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT $1;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT $1;") self.assertEqual( spans[0].attributes["db.statement.parameters"], "('1',)" ) @@ -381,9 +341,7 @@ def test_instrumented_fetch_method_with_arguments(self, *_, **__): self.check_span(spans[0]) self.assertEqual(spans[0].name, "SELECT") - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT $1;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT $1;") self.assertEqual( spans[0].attributes["db.statement.parameters"], "('1',)" ) @@ -395,9 +353,7 @@ def test_instrumented_executemany_method_with_arguments(self, *_, **__): self.assertEqual(len(spans), 1) self.assertIs(StatusCode.UNSET, spans[0].status.status_code) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT $1;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT $1;") self.assertEqual( spans[0].attributes["db.statement.parameters"], "([['1'], ['2']],)" ) @@ -410,9 +366,7 @@ def test_instrumented_execute_interface_error_method(self, *_, **__): self.assertEqual(len(spans), 1) self.assertIs(StatusCode.ERROR, spans[0].status.status_code) self.check_span(spans[0]) - self.assertEqual( - spans[0].attributes[DB_STATEMENT], "SELECT 42;" - ) + self.assertEqual(spans[0].attributes[DB_STATEMENT], "SELECT 42;") self.assertEqual( spans[0].attributes["db.statement.parameters"], "(1, 2, 3)" ) diff --git a/tests/opentelemetry-docker-tests/tests/celery/test_celery_functional.py b/tests/opentelemetry-docker-tests/tests/celery/test_celery_functional.py index cf030e1e04..b1b17204ec 100644 --- a/tests/opentelemetry-docker-tests/tests/celery/test_celery_functional.py +++ b/tests/opentelemetry-docker-tests/tests/celery/test_celery_functional.py @@ -111,9 +111,7 @@ def fn_task(): assert span.status.is_ok is True assert span.name == "run/test_celery_functional.fn_task" - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) == t.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == t.task_id assert ( span.attributes.get("celery.task_name") == "test_celery_functional.fn_task" @@ -138,9 +136,7 @@ def fn_task(self): assert span.status.is_ok is True assert span.name == "run/test_celery_functional.fn_task" - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) == t.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == t.task_id assert ( span.attributes.get("celery.task_name") == "test_celery_functional.fn_task" @@ -173,10 +169,7 @@ def fn_task_parameters(user, force_logout=False): == "apply_async/test_celery_functional.fn_task_parameters" ) assert async_span.attributes.get("celery.action") == "apply_async" - assert ( - async_span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert async_span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert ( async_span.attributes.get("celery.task_name") == "test_celery_functional.fn_task_parameters" @@ -186,10 +179,7 @@ def fn_task_parameters(user, force_logout=False): assert run_span.name == "test_celery_functional.fn_task_parameters" assert run_span.attributes.get("celery.action") == "run" assert run_span.attributes.get("celery.state") == "SUCCESS" - assert ( - run_span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert run_span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert ( run_span.attributes.get("celery.task_name") == "test_celery_functional.fn_task_parameters" @@ -234,10 +224,7 @@ def fn_task_parameters(user, force_logout=False): == "apply_async/test_celery_functional.fn_task_parameters" ) assert async_span.attributes.get("celery.action") == "apply_async" - assert ( - async_span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert async_span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert ( async_span.attributes.get("celery.task_name") == "test_celery_functional.fn_task_parameters" @@ -247,10 +234,7 @@ def fn_task_parameters(user, force_logout=False): assert run_span.name == "run/test_celery_functional.fn_task_parameters" assert run_span.attributes.get("celery.action") == "run" assert run_span.attributes.get("celery.state") == "SUCCESS" - assert ( - run_span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert run_span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert ( run_span.attributes.get("celery.task_name") == "test_celery_functional.fn_task_parameters" @@ -287,10 +271,7 @@ def fn_exception(): assert event.name == "exception" assert event.attributes[EXCEPTION_TYPE] == "Exception" assert EXCEPTION_MESSAGE in event.attributes - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert "Task class is failing" in span.status.description @@ -318,10 +299,7 @@ def fn_exception(): span.attributes.get("celery.task_name") == "test_celery_functional.fn_exception" ) - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id def test_fn_retry_exception(celery_app, memory_exporter): @@ -348,10 +326,7 @@ def fn_exception(): span.attributes.get("celery.task_name") == "test_celery_functional.fn_exception" ) - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id def test_class_task(celery_app, memory_exporter): @@ -383,10 +358,7 @@ def run(self): ) assert span.attributes.get("celery.action") == "run" assert span.attributes.get("celery.state") == "SUCCESS" - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id def test_class_task_exception(celery_app, memory_exporter): @@ -419,10 +391,7 @@ def run(self): assert span.attributes.get("celery.action") == "run" assert span.attributes.get("celery.state") == "FAILURE" assert span.status.status_code == StatusCode.ERROR - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert "Task class is failing" in span.status.description @@ -454,10 +423,7 @@ def run(self): assert span.name == "run/test_celery_functional.BaseTask" assert span.attributes.get("celery.action") == "run" assert span.attributes.get("celery.state") == "FAILURE" - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id def test_shared_task(celery_app, memory_exporter): @@ -482,10 +448,7 @@ def add(x, y): ) assert span.attributes.get("celery.action") == "run" assert span.attributes.get("celery.state") == "SUCCESS" - assert ( - span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id @mark.skip(reason="inconsistent test results") @@ -532,10 +495,7 @@ class CelerySubClass(CelerySuperClass): ) assert run_span.attributes.get("celery.action") == "run" assert run_span.attributes.get("celery.state") == "SUCCESS" - assert ( - run_span.attributes.get(MESSAGING_MESSAGE_ID) - == result.task_id - ) + assert run_span.attributes.get(MESSAGING_MESSAGE_ID) == result.task_id assert async_run_span.status.is_ok is True assert async_run_span.name == "run/test_celery_functional.CelerySubClass" @@ -546,8 +506,7 @@ class CelerySubClass(CelerySuperClass): assert async_run_span.attributes.get("celery.action") == "run" assert async_run_span.attributes.get("celery.state") == "SUCCESS" assert ( - async_run_span.attributes.get(MESSAGING_MESSAGE_ID) - != result.task_id + async_run_span.attributes.get(MESSAGING_MESSAGE_ID) != result.task_id ) assert async_span.status.is_ok is True @@ -559,10 +518,7 @@ class CelerySubClass(CelerySuperClass): == "test_celery_functional.CelerySubClass" ) assert async_span.attributes.get("celery.action") == "apply_async" - assert ( - async_span.attributes.get(MESSAGING_MESSAGE_ID) - != result.task_id - ) + assert async_span.attributes.get(MESSAGING_MESSAGE_ID) != result.task_id assert async_span.attributes.get( MESSAGING_MESSAGE_ID ) == async_run_span.attributes.get(MESSAGING_MESSAGE_ID) diff --git a/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_functional.py b/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_functional.py index 105cca0fa4..30bcc6a298 100644 --- a/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_functional.py +++ b/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_functional.py @@ -74,18 +74,10 @@ def validate_spans(self, span_name): self.assertIs(db_span.parent, root_span.get_span_context()) self.assertIs(db_span.kind, trace_api.SpanKind.CLIENT) self.assertEqual(db_span.attributes[DB_SYSTEM], "mysql") - self.assertEqual( - db_span.attributes[DB_NAME], MYSQL_DB_NAME - ) - self.assertEqual( - db_span.attributes[DB_USER], MYSQL_USER - ) - self.assertEqual( - db_span.attributes[NET_PEER_NAME], MYSQL_HOST - ) - self.assertEqual( - db_span.attributes[NET_PEER_PORT], MYSQL_PORT - ) + self.assertEqual(db_span.attributes[DB_NAME], MYSQL_DB_NAME) + self.assertEqual(db_span.attributes[DB_USER], MYSQL_USER) + self.assertEqual(db_span.attributes[NET_PEER_NAME], MYSQL_HOST) + self.assertEqual(db_span.attributes[NET_PEER_PORT], MYSQL_PORT) def test_execute(self): """Should create a child span for execute""" diff --git a/tests/opentelemetry-docker-tests/tests/postgres/test_aiopg_functional.py b/tests/opentelemetry-docker-tests/tests/postgres/test_aiopg_functional.py index 84ff4e891f..437a4332f1 100644 --- a/tests/opentelemetry-docker-tests/tests/postgres/test_aiopg_functional.py +++ b/tests/opentelemetry-docker-tests/tests/postgres/test_aiopg_functional.py @@ -82,21 +82,11 @@ def validate_spans(self, span_name): self.assertIsNotNone(child_span.parent) self.assertIs(child_span.parent, root_span.get_span_context()) self.assertIs(child_span.kind, trace_api.SpanKind.CLIENT) - self.assertEqual( - child_span.attributes[DB_SYSTEM], "postgresql" - ) - self.assertEqual( - child_span.attributes[DB_NAME], POSTGRES_DB_NAME - ) - self.assertEqual( - child_span.attributes[DB_USER], POSTGRES_USER - ) - self.assertEqual( - child_span.attributes[NET_PEER_NAME], POSTGRES_HOST - ) - self.assertEqual( - child_span.attributes[NET_PEER_PORT], POSTGRES_PORT - ) + self.assertEqual(child_span.attributes[DB_SYSTEM], "postgresql") + self.assertEqual(child_span.attributes[DB_NAME], POSTGRES_DB_NAME) + self.assertEqual(child_span.attributes[DB_USER], POSTGRES_USER) + self.assertEqual(child_span.attributes[NET_PEER_NAME], POSTGRES_HOST) + self.assertEqual(child_span.attributes[NET_PEER_PORT], POSTGRES_PORT) def test_execute(self): """Should create a child span for execute method""" @@ -169,21 +159,11 @@ def validate_spans(self, span_name): self.assertIsNotNone(child_span.parent) self.assertIs(child_span.parent, root_span.get_span_context()) self.assertIs(child_span.kind, trace_api.SpanKind.CLIENT) - self.assertEqual( - child_span.attributes[DB_SYSTEM], "postgresql" - ) - self.assertEqual( - child_span.attributes[DB_NAME], POSTGRES_DB_NAME - ) - self.assertEqual( - child_span.attributes[DB_USER], POSTGRES_USER - ) - self.assertEqual( - child_span.attributes[NET_PEER_NAME], POSTGRES_HOST - ) - self.assertEqual( - child_span.attributes[NET_PEER_PORT], POSTGRES_PORT - ) + self.assertEqual(child_span.attributes[DB_SYSTEM], "postgresql") + self.assertEqual(child_span.attributes[DB_NAME], POSTGRES_DB_NAME) + self.assertEqual(child_span.attributes[DB_USER], POSTGRES_USER) + self.assertEqual(child_span.attributes[NET_PEER_NAME], POSTGRES_HOST) + self.assertEqual(child_span.attributes[NET_PEER_PORT], POSTGRES_PORT) def test_execute(self): """Should create a child span for execute method""" diff --git a/tests/opentelemetry-docker-tests/tests/postgres/test_psycopg_functional.py b/tests/opentelemetry-docker-tests/tests/postgres/test_psycopg_functional.py index ab7bcad650..6cc800dd04 100644 --- a/tests/opentelemetry-docker-tests/tests/postgres/test_psycopg_functional.py +++ b/tests/opentelemetry-docker-tests/tests/postgres/test_psycopg_functional.py @@ -76,21 +76,11 @@ def validate_spans(self, span_name): self.assertIsNotNone(child_span.parent) self.assertIs(child_span.parent, root_span.get_span_context()) self.assertIs(child_span.kind, trace_api.SpanKind.CLIENT) - self.assertEqual( - child_span.attributes[DB_SYSTEM], "postgresql" - ) - self.assertEqual( - child_span.attributes[DB_NAME], POSTGRES_DB_NAME - ) - self.assertEqual( - child_span.attributes[DB_USER], POSTGRES_USER - ) - self.assertEqual( - child_span.attributes[NET_PEER_NAME], POSTGRES_HOST - ) - self.assertEqual( - child_span.attributes[NET_PEER_PORT], POSTGRES_PORT - ) + self.assertEqual(child_span.attributes[DB_SYSTEM], "postgresql") + self.assertEqual(child_span.attributes[DB_NAME], POSTGRES_DB_NAME) + self.assertEqual(child_span.attributes[DB_USER], POSTGRES_USER) + self.assertEqual(child_span.attributes[NET_PEER_NAME], POSTGRES_HOST) + self.assertEqual(child_span.attributes[NET_PEER_PORT], POSTGRES_PORT) def test_execute(self): """Should create a child span for execute method""" diff --git a/tests/opentelemetry-docker-tests/tests/pymongo/test_pymongo_functional.py b/tests/opentelemetry-docker-tests/tests/pymongo/test_pymongo_functional.py index bd157a0188..94745a7692 100644 --- a/tests/opentelemetry-docker-tests/tests/pymongo/test_pymongo_functional.py +++ b/tests/opentelemetry-docker-tests/tests/pymongo/test_pymongo_functional.py @@ -68,15 +68,9 @@ def validate_spans(self, expected_db_statement): self.assertIsNotNone(pymongo_span.parent) self.assertIs(pymongo_span.parent, root_span.get_span_context()) self.assertIs(pymongo_span.kind, trace_api.SpanKind.CLIENT) - self.assertEqual( - pymongo_span.attributes[DB_NAME], MONGODB_DB_NAME - ) - self.assertEqual( - pymongo_span.attributes[NET_PEER_NAME], MONGODB_HOST - ) - self.assertEqual( - pymongo_span.attributes[NET_PEER_PORT], MONGODB_PORT - ) + self.assertEqual(pymongo_span.attributes[DB_NAME], MONGODB_DB_NAME) + self.assertEqual(pymongo_span.attributes[NET_PEER_NAME], MONGODB_HOST) + self.assertEqual(pymongo_span.attributes[NET_PEER_PORT], MONGODB_PORT) self.assertEqual( pymongo_span.attributes[DB_MONGODB_COLLECTION], MONGODB_COLLECTION_NAME, diff --git a/tests/opentelemetry-docker-tests/tests/pymysql/test_pymysql_functional.py b/tests/opentelemetry-docker-tests/tests/pymysql/test_pymysql_functional.py index dfcf4f2f18..e33b6c0f88 100644 --- a/tests/opentelemetry-docker-tests/tests/pymysql/test_pymysql_functional.py +++ b/tests/opentelemetry-docker-tests/tests/pymysql/test_pymysql_functional.py @@ -74,18 +74,10 @@ def validate_spans(self, span_name): self.assertIs(db_span.parent, root_span.get_span_context()) self.assertIs(db_span.kind, trace_api.SpanKind.CLIENT) self.assertEqual(db_span.attributes[DB_SYSTEM], "mysql") - self.assertEqual( - db_span.attributes[DB_NAME], MYSQL_DB_NAME - ) - self.assertEqual( - db_span.attributes[DB_USER], MYSQL_USER - ) - self.assertEqual( - db_span.attributes[NET_PEER_NAME], MYSQL_HOST - ) - self.assertEqual( - db_span.attributes[NET_PEER_PORT], MYSQL_PORT - ) + self.assertEqual(db_span.attributes[DB_NAME], MYSQL_DB_NAME) + self.assertEqual(db_span.attributes[DB_USER], MYSQL_USER) + self.assertEqual(db_span.attributes[NET_PEER_NAME], MYSQL_HOST) + self.assertEqual(db_span.attributes[NET_PEER_PORT], MYSQL_PORT) def test_execute(self): """Should create a child span for execute""" diff --git a/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py b/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py index f518244ac2..ed79103369 100644 --- a/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py +++ b/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py @@ -52,12 +52,8 @@ def tearDown(self): def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) - self.assertEqual( - span.attributes.get(DB_REDIS_DATABASE_INDEX), 0 - ) - self.assertEqual( - span.attributes[NET_PEER_NAME], "localhost" - ) + self.assertEqual(span.attributes.get(DB_REDIS_DATABASE_INDEX), 0) + self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") self.assertEqual(span.attributes[NET_PEER_PORT], 6379) def test_long_command_sanitized(self): @@ -71,13 +67,9 @@ def test_long_command_sanitized(self): span = spans[0] self._check_span(span, "MGET") self.assertTrue( - span.attributes.get(DB_STATEMENT).startswith( - "MGET ? ? ? ?" - ) - ) - self.assertTrue( - span.attributes.get(DB_STATEMENT).endswith("...") + span.attributes.get(DB_STATEMENT).startswith("MGET ? ? ? ?") ) + self.assertTrue(span.attributes.get(DB_STATEMENT).endswith("...")) def test_long_command(self): self.redis_client.mget(*range(1000)) @@ -87,13 +79,9 @@ def test_long_command(self): span = spans[0] self._check_span(span, "MGET") self.assertTrue( - span.attributes.get(DB_STATEMENT).startswith( - "MGET ? ? ? ?" - ) - ) - self.assertTrue( - span.attributes.get(DB_STATEMENT).endswith("...") + span.attributes.get(DB_STATEMENT).startswith("MGET ? ? ? ?") ) + self.assertTrue(span.attributes.get(DB_STATEMENT).endswith("...")) def test_basics_sanitized(self): RedisInstrumentor().uninstrument() @@ -104,9 +92,7 @@ def test_basics_sanitized(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") self.assertEqual(span.attributes.get("db.redis.args_length"), 2) def test_basics(self): @@ -115,9 +101,7 @@ def test_basics(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") self.assertEqual(span.attributes.get("db.redis.args_length"), 2) def test_pipeline_traced_sanitized(self): @@ -172,9 +156,7 @@ def test_pipeline_immediate_sanitized(self): self.assertEqual(len(spans), 2) span = spans[0] self._check_span(span, "SET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "SET ? ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "SET ? ?") def test_pipeline_immediate(self): with self.redis_client.pipeline() as pipeline: @@ -188,9 +170,7 @@ def test_pipeline_immediate(self): self.assertEqual(len(spans), 2) span = spans[0] self._check_span(span, "SET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "SET ? ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "SET ? ?") def test_parent(self): """Ensure OpenTelemetry works with redis.""" @@ -236,9 +216,7 @@ def test_basics(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") self.assertEqual(span.attributes.get("db.redis.args_length"), 2) def test_pipeline_traced(self): @@ -298,12 +276,8 @@ def tearDown(self): def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) - self.assertEqual( - span.attributes.get(DB_REDIS_DATABASE_INDEX), 0 - ) - self.assertEqual( - span.attributes[NET_PEER_NAME], "localhost" - ) + self.assertEqual(span.attributes.get(DB_REDIS_DATABASE_INDEX), 0) + self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") self.assertEqual(span.attributes[NET_PEER_PORT], 6379) def test_long_command(self): @@ -314,13 +288,9 @@ def test_long_command(self): span = spans[0] self._check_span(span, "MGET") self.assertTrue( - span.attributes.get(DB_STATEMENT).startswith( - "MGET ? ? ? ?" - ) - ) - self.assertTrue( - span.attributes.get(DB_STATEMENT).endswith("...") + span.attributes.get(DB_STATEMENT).startswith("MGET ? ? ? ?") ) + self.assertTrue(span.attributes.get(DB_STATEMENT).endswith("...")) def test_basics(self): self.assertIsNone(async_call(self.redis_client.get("cheese"))) @@ -328,9 +298,7 @@ def test_basics(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") self.assertEqual(span.attributes.get("db.redis.args_length"), 2) def test_execute_command_traced_full_time(self): @@ -422,9 +390,7 @@ async def pipeline_immediate(): self.assertEqual(len(spans), 2) span = spans[0] self._check_span(span, "SET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "SET ? ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "SET ? ?") def test_pipeline_immediate_traced_full_time(self): """Command should be traced for coroutine execution time, not creation time.""" @@ -497,9 +463,7 @@ def test_basics(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") self.assertEqual(span.attributes.get("db.redis.args_length"), 2) def test_execute_command_traced_full_time(self): @@ -611,13 +575,9 @@ def tearDown(self): def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) - self.assertEqual( - span.attributes[NET_PEER_NAME], "localhost" - ) + self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") self.assertEqual(span.attributes[NET_PEER_PORT], 6379) - self.assertEqual( - span.attributes[DB_REDIS_DATABASE_INDEX], 10 - ) + self.assertEqual(span.attributes[DB_REDIS_DATABASE_INDEX], 10) def test_get(self): self.assertIsNone(self.redis_client.get("foo")) @@ -625,9 +585,7 @@ def test_get(self): self.assertEqual(len(spans), 1) span = spans[0] self._check_span(span, "GET") - self.assertEqual( - span.attributes.get(DB_STATEMENT), "GET ?" - ) + self.assertEqual(span.attributes.get(DB_STATEMENT), "GET ?") class TestRedisearchInstrument(TestBase): diff --git a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/mixins.py b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/mixins.py index a1e542f23f..2fda993141 100644 --- a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/mixins.py +++ b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/mixins.py @@ -119,9 +119,7 @@ def _check_span(self, span, name): if self.SQL_DB: name = f"{name} {self.SQL_DB}" self.assertEqual(span.name, name) - self.assertEqual( - span.attributes.get(DB_NAME), self.SQL_DB - ) + self.assertEqual(span.attributes.get(DB_NAME), self.SQL_DB) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) self.assertGreater((span.end_time - span.start_time), 0) diff --git a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mssql.py b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mssql.py index 96ed5e558b..ce2feeaf2d 100644 --- a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mssql.py +++ b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mssql.py @@ -67,9 +67,7 @@ def check_meta(self, span): span.attributes.get(DB_NAME), MSSQL_CONFIG["database"], ) - self.assertEqual( - span.attributes.get(DB_USER), MSSQL_CONFIG["user"] - ) + self.assertEqual(span.attributes.get(DB_USER), MSSQL_CONFIG["user"]) def test_engine_execute_errors(self): # ensures that SQL errors are reported @@ -88,9 +86,7 @@ def test_engine_execute_errors(self): span.attributes.get(DB_STATEMENT), "SELECT * FROM a_wrong_table", ) - self.assertEqual( - span.attributes.get(DB_NAME), self.SQL_DB - ) + self.assertEqual(span.attributes.get(DB_NAME), self.SQL_DB) self.check_meta(span) self.assertTrue(span.end_time - span.start_time > 0) # check the error diff --git a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mysql.py b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mysql.py index ca641c6402..a6e259c103 100644 --- a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mysql.py +++ b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_mysql.py @@ -65,9 +65,7 @@ def check_meta(self, span): span.attributes.get(DB_NAME), MYSQL_CONFIG["database"], ) - self.assertEqual( - span.attributes.get(DB_USER), MYSQL_CONFIG["user"] - ) + self.assertEqual(span.attributes.get(DB_USER), MYSQL_CONFIG["user"]) def test_engine_execute_errors(self): # ensures that SQL errors are reported @@ -86,9 +84,7 @@ def test_engine_execute_errors(self): span.attributes.get(DB_STATEMENT), "SELECT * FROM a_wrong_table", ) - self.assertEqual( - span.attributes.get(DB_NAME), self.SQL_DB - ) + self.assertEqual(span.attributes.get(DB_NAME), self.SQL_DB) self.check_meta(span) self.assertTrue(span.end_time - span.start_time > 0) # check the error diff --git a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_postgres.py b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_postgres.py index ada71513a3..a384bbe8cc 100644 --- a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_postgres.py +++ b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_postgres.py @@ -80,9 +80,7 @@ def test_engine_execute_errors(self): span.attributes.get(DB_STATEMENT), "SELECT * FROM a_wrong_table", ) - self.assertEqual( - span.attributes.get(DB_NAME), self.SQL_DB - ) + self.assertEqual(span.attributes.get(DB_NAME), self.SQL_DB) self.check_meta(span) self.assertTrue(span.end_time - span.start_time > 0) # check the error diff --git a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_sqlite.py b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_sqlite.py index 02f875b22b..357970ba77 100644 --- a/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_sqlite.py +++ b/tests/opentelemetry-docker-tests/tests/sqlalchemy_tests/test_sqlite.py @@ -50,9 +50,7 @@ def test_engine_execute_errors(self): span.attributes.get(DB_STATEMENT), "SELECT * FROM a_wrong_table", ) - self.assertEqual( - span.attributes.get(DB_NAME), self.SQL_DB - ) + self.assertEqual(span.attributes.get(DB_NAME), self.SQL_DB) self.assertTrue((span.end_time - span.start_time) > 0) # check the error self.assertIs( From c5bb5c62907cd7dfb47973f4510d6a6a387f2d2f Mon Sep 17 00:00:00 2001 From: shuningc Date: Fri, 20 Mar 2026 08:22:36 -0700 Subject: [PATCH 40/41] Adding one format fix --- util/opentelemetry-util-genai/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index fb4010ad65..d779ac8efe 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -43,8 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3795](#3795)) - Make inputs / outputs / system instructions optional params to `on_completion`, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3802](#3802)). - - Use a SHA256 hash of the system instructions as it's upload filename, and check - if the file exists before re-uploading it, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3814](#3814)). +- Use a SHA256 hash of the system instructions as it's upload filename, and check + if the file exists before re-uploading it, ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3814](#3814)). ## Version 0.1b0 (2025-09-25) From 742812a875632077216a2843ac45175b1e7087d2 Mon Sep 17 00:00:00 2001 From: shuningc Date: Wed, 25 Mar 2026 13:47:40 -0700 Subject: [PATCH 41/41] Reverting opentelemetry-instrumentation version to 0.60b.0 and opentelemetry-api version to 1.39 --- .../opentelemetry-instrumentation-anthropic/pyproject.toml | 6 +++--- .../tests/requirements.oldest.txt | 6 +++--- .../pyproject.toml | 6 +++--- .../tests/requirements.oldest.txt | 6 +++--- .../opentelemetry-instrumentation-openai-v2/pyproject.toml | 6 +++--- .../tests/requirements.oldest.txt | 6 +++--- util/opentelemetry-util-genai/pyproject.toml | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml index 3988ec226b..5cc6754ef7 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.40", - "opentelemetry-instrumentation ~= 0.61b0", - "opentelemetry-semantic-conventions ~= 0.61b0", + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt index 22b0f02c9e..1b1d2b1994 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.40 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-anthropic diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml index 014797ccbd..fb38e61589 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-api ~= 1.40", - "opentelemetry-instrumentation ~= 0.61b0", - "opentelemetry-semantic-conventions ~= 0.61b0", + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai >= 0.2b0, <0.4b0", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt index 0d37595c24..833e05fb1d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt @@ -21,8 +21,8 @@ pytest==7.4.4 pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 -opentelemetry-api==1.40 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml index 3b604f22d1..7b4fcce224 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.40", - "opentelemetry-instrumentation ~= 0.61b0", - "opentelemetry-semantic-conventions ~= 0.61b0", + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", "opentelemetry-util-genai", ] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt index f72f0be88b..45339fb438 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/requirements.oldest.txt @@ -29,9 +29,9 @@ pytest-vcr==1.0.2 pytest-asyncio==0.21.0 wrapt==1.16.0 opentelemetry-exporter-otlp-proto-http~=1.30 -opentelemetry-api==1.40 # when updating, also update in pyproject.toml -opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml -opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml -e instrumentation-genai/opentelemetry-instrumentation-openai-v2 -e util/opentelemetry-util-genai \ No newline at end of file diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index 1ff3fdc0ef..f8705369c2 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.61b0", - "opentelemetry-semantic-conventions ~= 0.61b0", - "opentelemetry-api>=1.40", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-api>=1.39", ] [project.entry-points.opentelemetry_genai_completion_hook]