Skip to content

Commit 88f424c

Browse files
authored
Merge branch 'main' into psycopg2-binary
2 parents a9968da + a716949 commit 88f424c

File tree

17 files changed

+881
-113
lines changed

17 files changed

+881
-113
lines changed

Diff for: docs/nitpick-exceptions.ini

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ py-class=
4141
callable
4242
Consumer
4343
confluent_kafka.Message
44+
psycopg.Connection
45+
psycopg.AsyncConnection
4446
ObjectProxy
4547

4648
any=

Diff for: instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Add example to `opentelemetry-instrumentation-openai-v2`
1313
([#3006](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3006))
1414
- Support for `AsyncOpenAI/AsyncCompletions` ([#2984](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2984))
15+
- Add metrics ([#3180](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3180))
1516

1617
## Version 2.0b0 (2024-11-08)
1718

Diff for: instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst

+44-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ OpenTelemetry OpenAI Instrumentation
77
:target: https://pypi.org/project/opentelemetry-instrumentation-openai-v2/
88

99
This library allows tracing LLM requests and logging of messages made by the
10-
`OpenAI Python API library <https://pypi.org/project/openai/>`_.
10+
`OpenAI Python API library <https://pypi.org/project/openai/>`_. It also captures
11+
the duration of the operations and the number of tokens used as metrics.
1112

1213

1314
Installation
@@ -74,6 +75,48 @@ To uninstrument clients, call the uninstrument method:
7475
# Uninstrument all clients
7576
OpenAIInstrumentor().uninstrument()
7677
78+
Bucket Boundaries
79+
-----------------
80+
81+
This section describes the explicit bucket boundaries for metrics such as token usage and operation duration, and guides users to create Views to implement them according to the semantic conventions.
82+
83+
The bucket boundaries are defined as follows:
84+
85+
- For `gen_ai.client.token.usage`: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]
86+
- For `gen_ai.client.operation.duration`: [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]
87+
88+
To implement these bucket boundaries, you can create Views in your OpenTelemetry SDK setup. Here is an example:
89+
90+
.. code-block:: python
91+
92+
from opentelemetry.sdk.metrics import MeterProvider, View
93+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
94+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
95+
from opentelemetry.sdk.metrics.aggregation import ExplicitBucketHistogramAggregation
96+
97+
views = [
98+
View(
99+
instrument_name="gen_ai.client.token.usage",
100+
aggregation=ExplicitBucketHistogramAggregation([1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]),
101+
),
102+
View(
103+
instrument_name="gen_ai.client.operation.duration",
104+
aggregation=ExplicitBucketHistogramAggregation([0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]),
105+
),
106+
]
107+
108+
metric_exporter = OTLPMetricExporter(endpoint="http://localhost:4317")
109+
metric_reader = PeriodicExportingMetricReader(metric_exporter)
110+
provider = MeterProvider(
111+
metric_readers=[metric_reader],
112+
views=views
113+
)
114+
115+
from opentelemetry.sdk.metrics import set_meter_provider
116+
set_meter_provider(provider)
117+
118+
For more details, refer to the `OpenTelemetry GenAI Metrics documentation <https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/>`_.
119+
77120
References
78121
----------
79122
* `OpenTelemetry OpenAI Instrumentation <https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/openai/openai.html>`_

Diff for: instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,18 @@
4949
from opentelemetry.instrumentation.openai_v2.package import _instruments
5050
from opentelemetry.instrumentation.openai_v2.utils import is_content_enabled
5151
from opentelemetry.instrumentation.utils import unwrap
52+
from opentelemetry.metrics import get_meter
5253
from opentelemetry.semconv.schemas import Schemas
5354
from opentelemetry.trace import get_tracer
5455

56+
from .instruments import Instruments
5557
from .patch import async_chat_completions_create, chat_completions_create
5658

5759

5860
class OpenAIInstrumentor(BaseInstrumentor):
61+
def __init__(self):
62+
self._meter = None
63+
5964
def instrumentation_dependencies(self) -> Collection[str]:
6065
return _instruments
6166

@@ -75,20 +80,29 @@ def _instrument(self, **kwargs):
7580
schema_url=Schemas.V1_28_0.value,
7681
event_logger_provider=event_logger_provider,
7782
)
83+
meter_provider = kwargs.get("meter_provider")
84+
self._meter = get_meter(
85+
__name__,
86+
"",
87+
meter_provider,
88+
schema_url=Schemas.V1_28_0.value,
89+
)
90+
91+
instruments = Instruments(self._meter)
7892

7993
wrap_function_wrapper(
8094
module="openai.resources.chat.completions",
8195
name="Completions.create",
8296
wrapper=chat_completions_create(
83-
tracer, event_logger, is_content_enabled()
97+
tracer, event_logger, instruments, is_content_enabled()
8498
),
8599
)
86100

87101
wrap_function_wrapper(
88102
module="openai.resources.chat.completions",
89103
name="AsyncCompletions.create",
90104
wrapper=async_chat_completions_create(
91-
tracer, event_logger, is_content_enabled()
105+
tracer, event_logger, instruments, is_content_enabled()
92106
),
93107
)
94108

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from opentelemetry.semconv._incubating.metrics import gen_ai_metrics
2+
3+
4+
class Instruments:
5+
def __init__(self, meter):
6+
self.operation_duration_histogram = (
7+
gen_ai_metrics.create_gen_ai_client_operation_duration(meter)
8+
)
9+
self.token_usage_histogram = (
10+
gen_ai_metrics.create_gen_ai_client_token_usage(meter)
11+
)

Diff for: instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py

+105-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515

16+
from timeit import default_timer
1617
from typing import Optional
1718

1819
from openai import Stream
@@ -21,8 +22,12 @@
2122
from opentelemetry.semconv._incubating.attributes import (
2223
gen_ai_attributes as GenAIAttributes,
2324
)
25+
from opentelemetry.semconv._incubating.attributes import (
26+
server_attributes as ServerAttributes,
27+
)
2428
from opentelemetry.trace import Span, SpanKind, Tracer
2529

30+
from .instruments import Instruments
2631
from .utils import (
2732
choice_to_event,
2833
get_llm_request_attributes,
@@ -34,7 +39,10 @@
3439

3540

3641
def chat_completions_create(
37-
tracer: Tracer, event_logger: EventLogger, capture_content: bool
42+
tracer: Tracer,
43+
event_logger: EventLogger,
44+
instruments: Instruments,
45+
capture_content: bool,
3846
):
3947
"""Wrap the `create` method of the `ChatCompletion` class to trace it."""
4048

@@ -54,6 +62,9 @@ def traced_method(wrapped, instance, args, kwargs):
5462
message_to_event(message, capture_content)
5563
)
5664

65+
start = default_timer()
66+
result = None
67+
error_type = None
5768
try:
5869
result = wrapped(*args, **kwargs)
5970
if is_streaming(kwargs):
@@ -69,14 +80,27 @@ def traced_method(wrapped, instance, args, kwargs):
6980
return result
7081

7182
except Exception as error:
83+
error_type = type(error).__qualname__
7284
handle_span_exception(span, error)
7385
raise
86+
finally:
87+
duration = max((default_timer() - start), 0)
88+
_record_metrics(
89+
instruments,
90+
duration,
91+
result,
92+
span_attributes,
93+
error_type,
94+
)
7495

7596
return traced_method
7697

7798

7899
def async_chat_completions_create(
79-
tracer: Tracer, event_logger: EventLogger, capture_content: bool
100+
tracer: Tracer,
101+
event_logger: EventLogger,
102+
instruments: Instruments,
103+
capture_content: bool,
80104
):
81105
"""Wrap the `create` method of the `AsyncChatCompletion` class to trace it."""
82106

@@ -96,6 +120,9 @@ async def traced_method(wrapped, instance, args, kwargs):
96120
message_to_event(message, capture_content)
97121
)
98122

123+
start = default_timer()
124+
result = None
125+
error_type = None
99126
try:
100127
result = await wrapped(*args, **kwargs)
101128
if is_streaming(kwargs):
@@ -111,12 +138,88 @@ async def traced_method(wrapped, instance, args, kwargs):
111138
return result
112139

113140
except Exception as error:
141+
error_type = type(error).__qualname__
114142
handle_span_exception(span, error)
115143
raise
144+
finally:
145+
duration = max((default_timer() - start), 0)
146+
_record_metrics(
147+
instruments,
148+
duration,
149+
result,
150+
span_attributes,
151+
error_type,
152+
)
116153

117154
return traced_method
118155

119156

157+
def _record_metrics(
158+
instruments: Instruments,
159+
duration: float,
160+
result,
161+
span_attributes: dict,
162+
error_type: Optional[str],
163+
):
164+
common_attributes = {
165+
GenAIAttributes.GEN_AI_OPERATION_NAME: GenAIAttributes.GenAiOperationNameValues.CHAT.value,
166+
GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value,
167+
GenAIAttributes.GEN_AI_REQUEST_MODEL: span_attributes[
168+
GenAIAttributes.GEN_AI_REQUEST_MODEL
169+
],
170+
}
171+
172+
if error_type:
173+
common_attributes["error.type"] = error_type
174+
175+
if result and getattr(result, "model", None):
176+
common_attributes[GenAIAttributes.GEN_AI_RESPONSE_MODEL] = result.model
177+
178+
if result and getattr(result, "service_tier", None):
179+
common_attributes[
180+
GenAIAttributes.GEN_AI_OPENAI_RESPONSE_SERVICE_TIER
181+
] = result.service_tier
182+
183+
if result and getattr(result, "system_fingerprint", None):
184+
common_attributes["gen_ai.openai.response.system_fingerprint"] = (
185+
result.system_fingerprint
186+
)
187+
188+
if ServerAttributes.SERVER_ADDRESS in span_attributes:
189+
common_attributes[ServerAttributes.SERVER_ADDRESS] = span_attributes[
190+
ServerAttributes.SERVER_ADDRESS
191+
]
192+
193+
if ServerAttributes.SERVER_PORT in span_attributes:
194+
common_attributes[ServerAttributes.SERVER_PORT] = span_attributes[
195+
ServerAttributes.SERVER_PORT
196+
]
197+
198+
instruments.operation_duration_histogram.record(
199+
duration,
200+
attributes=common_attributes,
201+
)
202+
203+
if result and getattr(result, "usage", None):
204+
input_attributes = {
205+
**common_attributes,
206+
GenAIAttributes.GEN_AI_TOKEN_TYPE: GenAIAttributes.GenAiTokenTypeValues.INPUT.value,
207+
}
208+
instruments.token_usage_histogram.record(
209+
result.usage.prompt_tokens,
210+
attributes=input_attributes,
211+
)
212+
213+
completion_attributes = {
214+
**common_attributes,
215+
GenAIAttributes.GEN_AI_TOKEN_TYPE: GenAIAttributes.GenAiTokenTypeValues.COMPLETION.value,
216+
}
217+
instruments.token_usage_histogram.record(
218+
result.usage.completion_tokens,
219+
attributes=completion_attributes,
220+
)
221+
222+
120223
def _set_response_attributes(
121224
span, result, event_logger: EventLogger, capture_content: bool
122225
):

0 commit comments

Comments
 (0)