opentelemetry-instrumentation-openai 0.34.1__py3-none-any.whl → 0.49.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of opentelemetry-instrumentation-openai might be problematic. Click here for more details.

Files changed (22) hide show
  1. opentelemetry/instrumentation/openai/__init__.py +11 -6
  2. opentelemetry/instrumentation/openai/shared/__init__.py +167 -68
  3. opentelemetry/instrumentation/openai/shared/chat_wrappers.py +544 -231
  4. opentelemetry/instrumentation/openai/shared/completion_wrappers.py +143 -81
  5. opentelemetry/instrumentation/openai/shared/config.py +8 -3
  6. opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +91 -30
  7. opentelemetry/instrumentation/openai/shared/event_emitter.py +108 -0
  8. opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  9. opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +1 -1
  10. opentelemetry/instrumentation/openai/shared/span_utils.py +0 -0
  11. opentelemetry/instrumentation/openai/utils.py +42 -9
  12. opentelemetry/instrumentation/openai/v0/__init__.py +32 -11
  13. opentelemetry/instrumentation/openai/v1/__init__.py +177 -69
  14. opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +208 -109
  15. opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +41 -19
  16. opentelemetry/instrumentation/openai/v1/responses_wrappers.py +1073 -0
  17. opentelemetry/instrumentation/openai/version.py +1 -1
  18. {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/METADATA +7 -8
  19. opentelemetry_instrumentation_openai-0.49.3.dist-info/RECORD +21 -0
  20. {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/WHEEL +1 -1
  21. opentelemetry_instrumentation_openai-0.34.1.dist-info/RECORD +0 -17
  22. {opentelemetry_instrumentation_openai-0.34.1.dist-info → opentelemetry_instrumentation_openai-0.49.3.dist-info}/entry_points.txt +0 -0
@@ -1,37 +1,44 @@
1
1
  import logging
2
2
 
3
3
  from opentelemetry import context as context_api
4
-
5
- from opentelemetry.semconv_ai import (
6
- SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
7
- SpanAttributes,
8
- LLMRequestTypeValues,
9
- )
10
-
11
- from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
12
- from opentelemetry.instrumentation.openai.utils import _with_tracer_wrapper, dont_throw
4
+ from opentelemetry import trace
13
5
  from opentelemetry.instrumentation.openai.shared import (
14
6
  _set_client_attributes,
15
- _set_request_attributes,
16
- _set_span_attribute,
17
7
  _set_functions_attributes,
8
+ _set_request_attributes,
18
9
  _set_response_attributes,
10
+ _set_span_attribute,
11
+ _set_span_stream_usage,
19
12
  is_streaming_response,
20
- should_send_prompts,
21
13
  model_as_dict,
22
- should_record_stream_token_usage,
23
- get_token_count_from_string,
24
- _set_span_stream_usage,
25
14
  propagate_trace_context,
26
15
  )
27
-
28
- from opentelemetry.instrumentation.openai.utils import is_openai_v1
29
-
16
+ from opentelemetry.instrumentation.openai.shared.config import Config
17
+ from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
18
+ from opentelemetry.instrumentation.openai.shared.event_emitter import emit_event
19
+ from opentelemetry.instrumentation.openai.shared.event_models import (
20
+ ChoiceEvent,
21
+ MessageEvent,
22
+ )
23
+ from opentelemetry.instrumentation.openai.utils import (
24
+ _with_tracer_wrapper,
25
+ dont_throw,
26
+ is_openai_v1,
27
+ should_emit_events,
28
+ should_send_prompts,
29
+ )
30
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
31
+ from opentelemetry.semconv._incubating.attributes import (
32
+ gen_ai_attributes as GenAIAttributes,
33
+ )
34
+ from opentelemetry.semconv_ai import (
35
+ SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
36
+ LLMRequestTypeValues,
37
+ SpanAttributes,
38
+ )
30
39
  from opentelemetry.trace import SpanKind
31
40
  from opentelemetry.trace.status import Status, StatusCode
32
41
 
33
- from opentelemetry.instrumentation.openai.shared.config import Config
34
-
35
42
  SPAN_NAME = "openai.completion"
36
43
  LLM_REQUEST_TYPE = LLMRequestTypeValues.COMPLETION
37
44
 
@@ -52,17 +59,27 @@ def completion_wrapper(tracer, wrapped, instance, args, kwargs):
52
59
  attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value},
53
60
  )
54
61
 
55
- _handle_request(span, kwargs, instance)
56
- response = wrapped(*args, **kwargs)
62
+ # Use the span as current context to ensure events get proper trace context
63
+ with trace.use_span(span, end_on_exit=False):
64
+ _handle_request(span, kwargs, instance)
57
65
 
58
- if is_streaming_response(response):
59
- # span will be closed after the generator is done
60
- return _build_from_streaming_response(span, kwargs, response)
61
- else:
62
- _handle_response(response, span)
66
+ try:
67
+ response = wrapped(*args, **kwargs)
68
+ except Exception as e:
69
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
70
+ span.record_exception(e)
71
+ span.set_status(Status(StatusCode.ERROR, str(e)))
72
+ span.end()
73
+ raise
63
74
 
64
- span.end()
65
- return response
75
+ if is_streaming_response(response):
76
+ # span will be closed after the generator is done
77
+ return _build_from_streaming_response(span, kwargs, response)
78
+ else:
79
+ _handle_response(response, span, instance)
80
+
81
+ span.end()
82
+ return response
66
83
 
67
84
 
68
85
  @_with_tracer_wrapper
@@ -78,41 +95,66 @@ async def acompletion_wrapper(tracer, wrapped, instance, args, kwargs):
78
95
  attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value},
79
96
  )
80
97
 
81
- _handle_request(span, kwargs, instance)
82
- response = await wrapped(*args, **kwargs)
98
+ # Use the span as current context to ensure events get proper trace context
99
+ with trace.use_span(span, end_on_exit=False):
100
+ _handle_request(span, kwargs, instance)
83
101
 
84
- if is_streaming_response(response):
85
- # span will be closed after the generator is done
86
- return _abuild_from_streaming_response(span, kwargs, response)
87
- else:
88
- _handle_response(response, span)
102
+ try:
103
+ response = await wrapped(*args, **kwargs)
104
+ except Exception as e:
105
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
106
+ span.record_exception(e)
107
+ span.set_status(Status(StatusCode.ERROR, str(e)))
108
+ span.end()
109
+ raise
89
110
 
90
- span.end()
91
- return response
111
+ if is_streaming_response(response):
112
+ # span will be closed after the generator is done
113
+ return _abuild_from_streaming_response(span, kwargs, response)
114
+ else:
115
+ _handle_response(response, span, instance)
116
+
117
+ span.end()
118
+ return response
92
119
 
93
120
 
94
121
  @dont_throw
95
122
  def _handle_request(span, kwargs, instance):
96
- _set_request_attributes(span, kwargs)
97
- if should_send_prompts():
98
- _set_prompts(span, kwargs.get("prompt"))
99
- _set_functions_attributes(span, kwargs.get("functions"))
123
+ _set_request_attributes(span, kwargs, instance)
124
+ if should_emit_events():
125
+ _emit_prompts_events(kwargs)
126
+ else:
127
+ if should_send_prompts():
128
+ _set_prompts(span, kwargs.get("prompt"))
129
+ _set_functions_attributes(span, kwargs.get("functions"))
100
130
  _set_client_attributes(span, instance)
101
131
  if Config.enable_trace_context_propagation:
102
132
  propagate_trace_context(span, kwargs)
103
133
 
104
134
 
135
+ def _emit_prompts_events(kwargs):
136
+ prompt = kwargs.get("prompt")
137
+ if isinstance(prompt, list):
138
+ for p in prompt:
139
+ emit_event(MessageEvent(content=p))
140
+ elif isinstance(prompt, str):
141
+ emit_event(MessageEvent(content=prompt))
142
+
143
+
105
144
  @dont_throw
106
- def _handle_response(response, span):
145
+ def _handle_response(response, span, instance=None):
107
146
  if is_openai_v1():
108
147
  response_dict = model_as_dict(response)
109
148
  else:
110
149
  response_dict = response
111
150
 
112
151
  _set_response_attributes(span, response_dict)
113
-
114
- if should_send_prompts():
115
- _set_completions(span, response_dict.get("choices"))
152
+ if should_emit_events():
153
+ for choice in response.choices:
154
+ emit_event(_parse_choice_event(choice))
155
+ else:
156
+ if should_send_prompts():
157
+ _set_completions(span, response_dict.get("choices"))
116
158
 
117
159
 
118
160
  def _set_prompts(span, prompt):
@@ -121,7 +163,7 @@ def _set_prompts(span, prompt):
121
163
 
122
164
  _set_span_attribute(
123
165
  span,
124
- f"{SpanAttributes.LLM_PROMPTS}.0.user",
166
+ f"{GenAIAttributes.GEN_AI_PROMPT}.0.user",
125
167
  prompt[0] if isinstance(prompt, list) else prompt,
126
168
  )
127
169
 
@@ -133,7 +175,7 @@ def _set_completions(span, choices):
133
175
 
134
176
  for choice in choices:
135
177
  index = choice.get("index")
136
- prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}"
178
+ prefix = f"{GenAIAttributes.GEN_AI_COMPLETION}.{index}"
137
179
  _set_span_attribute(
138
180
  span, f"{prefix}.finish_reason", choice.get("finish_reason")
139
181
  )
@@ -142,7 +184,7 @@ def _set_completions(span, choices):
142
184
 
143
185
  @dont_throw
144
186
  def _build_from_streaming_response(span, request_kwargs, response):
145
- complete_response = {"choices": [], "model": ""}
187
+ complete_response = {"choices": [], "model": "", "id": ""}
146
188
  for item in response:
147
189
  yield item
148
190
  _accumulate_streaming_response(complete_response, item)
@@ -151,8 +193,11 @@ def _build_from_streaming_response(span, request_kwargs, response):
151
193
 
152
194
  _set_token_usage(span, request_kwargs, complete_response)
153
195
 
154
- if should_send_prompts():
155
- _set_completions(span, complete_response.get("choices"))
196
+ if should_emit_events():
197
+ _emit_streaming_response_events(complete_response)
198
+ else:
199
+ if should_send_prompts():
200
+ _set_completions(span, complete_response.get("choices"))
156
201
 
157
202
  span.set_status(Status(StatusCode.OK))
158
203
  span.end()
@@ -160,7 +205,7 @@ def _build_from_streaming_response(span, request_kwargs, response):
160
205
 
161
206
  @dont_throw
162
207
  async def _abuild_from_streaming_response(span, request_kwargs, response):
163
- complete_response = {"choices": [], "model": ""}
208
+ complete_response = {"choices": [], "model": "", "id": ""}
164
209
  async for item in response:
165
210
  yield item
166
211
  _accumulate_streaming_response(complete_response, item)
@@ -169,44 +214,42 @@ async def _abuild_from_streaming_response(span, request_kwargs, response):
169
214
 
170
215
  _set_token_usage(span, request_kwargs, complete_response)
171
216
 
172
- if should_send_prompts():
173
- _set_completions(span, complete_response.get("choices"))
217
+ if should_emit_events():
218
+ _emit_streaming_response_events(complete_response)
219
+ else:
220
+ if should_send_prompts():
221
+ _set_completions(span, complete_response.get("choices"))
174
222
 
175
223
  span.set_status(Status(StatusCode.OK))
176
224
  span.end()
177
225
 
178
226
 
179
- @dont_throw
180
- def _set_token_usage(span, request_kwargs, complete_response):
181
- # use tiktoken calculate token usage
182
- if should_record_stream_token_usage():
183
- prompt_usage = -1
184
- completion_usage = -1
185
-
186
- # prompt_usage
187
- if request_kwargs and request_kwargs.get("prompt"):
188
- prompt_content = request_kwargs.get("prompt")
189
- model_name = complete_response.get("model") or None
190
-
191
- if model_name:
192
- prompt_usage = get_token_count_from_string(prompt_content, model_name)
227
+ def _emit_streaming_response_events(complete_response):
228
+ for i, choice in enumerate(complete_response["choices"]):
229
+ emit_event(
230
+ ChoiceEvent(
231
+ index=choice.get("index", i),
232
+ message={"content": choice.get("text"), "role": "assistant"},
233
+ finish_reason=choice.get("finish_reason", "unknown"),
234
+ )
235
+ )
193
236
 
194
- # completion_usage
195
- if complete_response.get("choices"):
196
- completion_content = ""
197
- model_name = complete_response.get("model") or None
198
237
 
199
- for choice in complete_response.get("choices"):
200
- if choice.get("text"):
201
- completion_content += choice.get("text")
238
+ @dont_throw
239
+ def _set_token_usage(span, request_kwargs, complete_response):
240
+ prompt_usage = -1
241
+ completion_usage = -1
202
242
 
203
- if model_name:
204
- completion_usage = get_token_count_from_string(
205
- completion_content, model_name
206
- )
243
+ # Use token usage from API response only
244
+ if complete_response.get("usage"):
245
+ usage = complete_response["usage"]
246
+ if usage.get("prompt_tokens"):
247
+ prompt_usage = usage["prompt_tokens"]
248
+ if usage.get("completion_tokens"):
249
+ completion_usage = usage["completion_tokens"]
207
250
 
208
- # span record
209
- _set_span_stream_usage(span, prompt_usage, completion_usage)
251
+ # span record
252
+ _set_span_stream_usage(span, prompt_usage, completion_usage)
210
253
 
211
254
 
212
255
  @dont_throw
@@ -215,6 +258,11 @@ def _accumulate_streaming_response(complete_response, item):
215
258
  item = model_as_dict(item)
216
259
 
217
260
  complete_response["model"] = item.get("model")
261
+ complete_response["id"] = item.get("id")
262
+
263
+ # capture usage information from the stream chunks
264
+ if item.get("usage"):
265
+ complete_response["usage"] = item.get("usage")
218
266
 
219
267
  for choice in item.get("choices"):
220
268
  index = choice.get("index")
@@ -228,3 +276,17 @@ def _accumulate_streaming_response(complete_response, item):
228
276
  complete_choice["text"] += choice.get("text")
229
277
 
230
278
  return complete_response
279
+
280
+
281
+ def _parse_choice_event(choice) -> ChoiceEvent:
282
+ has_message = choice.text is not None
283
+ has_finish_reason = choice.finish_reason is not None
284
+
285
+ content = choice.text if has_message else None
286
+ finish_reason = choice.finish_reason if has_finish_reason else "unknown"
287
+
288
+ return ChoiceEvent(
289
+ index=choice.index,
290
+ message={"content": content, "role": "assistant"},
291
+ finish_reason=finish_reason,
292
+ )
@@ -1,10 +1,15 @@
1
- from typing import Callable
1
+ from typing import Callable, Optional
2
+
3
+ from opentelemetry._logs import Logger
2
4
 
3
5
 
4
6
  class Config:
5
- enrich_token_usage = False
6
7
  enrich_assistant = False
7
8
  exception_logger = None
8
9
  get_common_metrics_attributes: Callable[[], dict] = lambda: {}
9
- upload_base64_image: Callable[[str, str, str], str] = lambda trace_id, span_id, base64_image_url: str
10
+ upload_base64_image: Callable[[str, str, str, str], str] = (
11
+ lambda trace_id, span_id, image_name, base64_string: str
12
+ )
10
13
  enable_trace_context_propagation: bool = True
14
+ use_legacy_attributes = True
15
+ event_logger: Optional[Logger] = None
@@ -1,39 +1,49 @@
1
1
  import logging
2
2
  import time
3
+ from collections.abc import Iterable
3
4
 
4
5
  from opentelemetry import context as context_api
5
- from opentelemetry.metrics import Counter, Histogram
6
- from opentelemetry.semconv_ai import (
7
- SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
8
- SpanAttributes,
9
- LLMRequestTypeValues,
10
- )
11
-
12
- from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
13
- from opentelemetry.instrumentation.openai.utils import (
14
- dont_throw,
15
- start_as_current_span_async,
16
- _with_embeddings_telemetry_wrapper,
17
- )
18
6
  from opentelemetry.instrumentation.openai.shared import (
19
- metric_shared_attributes,
7
+ OPENAI_LLM_USAGE_TOKEN_TYPES,
8
+ _get_openai_base_url,
20
9
  _set_client_attributes,
21
10
  _set_request_attributes,
22
- _set_span_attribute,
23
11
  _set_response_attributes,
12
+ _set_span_attribute,
24
13
  _token_type,
25
- should_send_prompts,
14
+ metric_shared_attributes,
26
15
  model_as_dict,
27
- _get_openai_base_url,
28
- OPENAI_LLM_USAGE_TOKEN_TYPES,
29
16
  propagate_trace_context,
30
17
  )
31
-
32
18
  from opentelemetry.instrumentation.openai.shared.config import Config
19
+ from opentelemetry.instrumentation.openai.shared.event_emitter import emit_event
20
+ from opentelemetry.instrumentation.openai.shared.event_models import (
21
+ ChoiceEvent,
22
+ MessageEvent,
23
+ )
24
+ from opentelemetry.instrumentation.openai.utils import (
25
+ _with_embeddings_telemetry_wrapper,
26
+ dont_throw,
27
+ is_openai_v1,
28
+ should_emit_events,
29
+ should_send_prompts,
30
+ start_as_current_span_async,
31
+ )
32
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
33
+ from opentelemetry.metrics import Counter, Histogram
34
+ from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
35
+ from opentelemetry.semconv._incubating.attributes import (
36
+ gen_ai_attributes as GenAIAttributes,
37
+ )
38
+ from opentelemetry.semconv_ai import (
39
+ SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
40
+ LLMRequestTypeValues,
41
+ SpanAttributes,
42
+ )
43
+ from opentelemetry.trace import SpanKind, Status, StatusCode
33
44
 
34
- from opentelemetry.instrumentation.openai.utils import is_openai_v1
35
-
36
- from opentelemetry.trace import SpanKind
45
+ from openai._legacy_response import LegacyAPIResponse
46
+ from openai.types.create_embedding_response import CreateEmbeddingResponse
37
47
 
38
48
  SPAN_NAME = "openai.embeddings"
39
49
  LLM_REQUEST_TYPE = LLMRequestTypeValues.EMBEDDING
@@ -83,7 +93,12 @@ def embeddings_wrapper(
83
93
  if exception_counter:
84
94
  exception_counter.add(1, attributes=attributes)
85
95
 
86
- raise e
96
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
97
+ span.record_exception(e)
98
+ span.set_status(Status(StatusCode.ERROR, str(e)))
99
+ span.end()
100
+
101
+ raise
87
102
 
88
103
  duration = end_time - start_time
89
104
 
@@ -124,6 +139,7 @@ async def aembeddings_wrapper(
124
139
  attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value},
125
140
  ) as span:
126
141
  _handle_request(span, kwargs, instance)
142
+
127
143
  try:
128
144
  # record time for duration
129
145
  start_time = time.time()
@@ -142,9 +158,15 @@ async def aembeddings_wrapper(
142
158
  if exception_counter:
143
159
  exception_counter.add(1, attributes=attributes)
144
160
 
145
- raise e
161
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
162
+ span.record_exception(e)
163
+ span.set_status(Status(StatusCode.ERROR, str(e)))
164
+ span.end()
165
+
166
+ raise
146
167
 
147
168
  duration = end_time - start_time
169
+
148
170
  _handle_response(
149
171
  response,
150
172
  span,
@@ -160,10 +182,16 @@ async def aembeddings_wrapper(
160
182
 
161
183
  @dont_throw
162
184
  def _handle_request(span, kwargs, instance):
163
- _set_request_attributes(span, kwargs)
164
- if should_send_prompts():
165
- _set_prompts(span, kwargs.get("input"))
185
+ _set_request_attributes(span, kwargs, instance)
186
+
187
+ if should_emit_events():
188
+ _emit_embeddings_message_event(kwargs.get("input"))
189
+ else:
190
+ if should_send_prompts():
191
+ _set_prompts(span, kwargs.get("input"))
192
+
166
193
  _set_client_attributes(span, instance)
194
+
167
195
  if Config.enable_trace_context_propagation:
168
196
  propagate_trace_context(span, kwargs)
169
197
 
@@ -194,6 +222,10 @@ def _handle_response(
194
222
  # span attributes
195
223
  _set_response_attributes(span, response_dict)
196
224
 
225
+ # emit events
226
+ if should_emit_events():
227
+ _emit_embeddings_choice_event(response)
228
+
197
229
 
198
230
  def _set_embeddings_metrics(
199
231
  instance,
@@ -219,7 +251,7 @@ def _set_embeddings_metrics(
219
251
  continue
220
252
  attributes_with_token_type = {
221
253
  **shared_attributes,
222
- SpanAttributes.LLM_TOKEN_TYPE: _token_type(name),
254
+ GenAIAttributes.GEN_AI_TOKEN_TYPE: _token_type(name),
223
255
  }
224
256
  token_counter.record(val, attributes=attributes_with_token_type)
225
257
 
@@ -241,10 +273,39 @@ def _set_prompts(span, prompt):
241
273
 
242
274
  if isinstance(prompt, list):
243
275
  for i, p in enumerate(prompt):
244
- _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.content", p)
276
+ _set_span_attribute(span, f"{GenAIAttributes.GEN_AI_PROMPT}.{i}.content", p)
245
277
  else:
246
278
  _set_span_attribute(
247
279
  span,
248
- f"{SpanAttributes.LLM_PROMPTS}.0.content",
280
+ f"{GenAIAttributes.GEN_AI_PROMPT}.0.content",
249
281
  prompt,
250
282
  )
283
+
284
+
285
+ def _emit_embeddings_message_event(embeddings) -> None:
286
+ if isinstance(embeddings, str):
287
+ emit_event(MessageEvent(content=embeddings))
288
+ elif isinstance(embeddings, Iterable):
289
+ for i in embeddings:
290
+ emit_event(MessageEvent(content=i))
291
+
292
+
293
+ def _emit_embeddings_choice_event(response) -> None:
294
+ if isinstance(response, CreateEmbeddingResponse):
295
+ for embedding in response.data:
296
+ emit_event(
297
+ ChoiceEvent(
298
+ index=embedding.index,
299
+ message={"content": embedding.embedding, "role": "assistant"},
300
+ )
301
+ )
302
+
303
+ elif isinstance(response, LegacyAPIResponse):
304
+ parsed_response = response.parse()
305
+ for embedding in parsed_response.data:
306
+ emit_event(
307
+ ChoiceEvent(
308
+ index=embedding.index,
309
+ message={"content": embedding.embedding, "role": "assistant"},
310
+ )
311
+ )
@@ -0,0 +1,108 @@
1
+ from dataclasses import asdict
2
+ from enum import Enum
3
+ from typing import Union
4
+
5
+ from opentelemetry._logs import LogRecord
6
+ from opentelemetry.instrumentation.openai.shared.event_models import (
7
+ ChoiceEvent,
8
+ MessageEvent,
9
+ )
10
+ from opentelemetry.instrumentation.openai.utils import (
11
+ should_emit_events,
12
+ should_send_prompts,
13
+ )
14
+ from opentelemetry.semconv._incubating.attributes import (
15
+ gen_ai_attributes as GenAIAttributes,
16
+ )
17
+
18
+ from .config import Config
19
+
20
+
21
+ class Roles(Enum):
22
+ USER = "user"
23
+ ASSISTANT = "assistant"
24
+ SYSTEM = "system"
25
+ TOOL = "tool"
26
+
27
+
28
+ VALID_MESSAGE_ROLES = {role.value for role in Roles}
29
+ """The valid roles for naming the message event."""
30
+
31
+ EVENT_ATTRIBUTES = {
32
+ GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.OPENAI.value
33
+ }
34
+ """The attributes to be used for the event."""
35
+
36
+
37
+ def emit_event(event: Union[MessageEvent, ChoiceEvent]) -> None:
38
+ """
39
+ Emit an event to the OpenTelemetry SDK.
40
+
41
+ Args:
42
+ event: The event to emit.
43
+ """
44
+ if not should_emit_events():
45
+ return
46
+
47
+ if isinstance(event, MessageEvent):
48
+ _emit_message_event(event)
49
+ elif isinstance(event, ChoiceEvent):
50
+ _emit_choice_event(event)
51
+ else:
52
+ raise TypeError("Unsupported event type")
53
+
54
+
55
+ def _emit_message_event(event: MessageEvent) -> None:
56
+ body = asdict(event)
57
+
58
+ if event.role in VALID_MESSAGE_ROLES:
59
+ name = "gen_ai.{}.message".format(event.role)
60
+ # According to the semantic conventions, the role is conditionally required if available
61
+ # and not equal to the "role" in the message name. So, remove the role from the body if
62
+ # it is the same as the in the event name.
63
+ body.pop("role", None)
64
+ else:
65
+ name = "gen_ai.user.message"
66
+
67
+ # According to the semantic conventions, only the assistant role has tool call
68
+ if event.role != Roles.ASSISTANT.value and event.tool_calls is not None:
69
+ del body["tool_calls"]
70
+ elif event.tool_calls is None:
71
+ del body["tool_calls"]
72
+
73
+ if not should_send_prompts():
74
+ del body["content"]
75
+ if body.get("tool_calls") is not None:
76
+ for tool_call in body["tool_calls"]:
77
+ tool_call["function"].pop("arguments", None)
78
+
79
+ log_record = LogRecord(
80
+ body=body,
81
+ attributes=EVENT_ATTRIBUTES,
82
+ event_name=name
83
+ )
84
+ Config.event_logger.emit(log_record)
85
+
86
+
87
+ def _emit_choice_event(event: ChoiceEvent) -> None:
88
+ body = asdict(event)
89
+ if event.message["role"] == Roles.ASSISTANT.value:
90
+ # According to the semantic conventions, the role is conditionally required if available
91
+ # and not equal to "assistant", so remove the role from the body if it is "assistant".
92
+ body["message"].pop("role", None)
93
+
94
+ if event.tool_calls is None:
95
+ del body["tool_calls"]
96
+
97
+ if not should_send_prompts():
98
+ body["message"].pop("content", None)
99
+ if body.get("tool_calls") is not None:
100
+ for tool_call in body["tool_calls"]:
101
+ tool_call["function"].pop("arguments", None)
102
+
103
+ log_record = LogRecord(
104
+ body=body,
105
+ attributes=EVENT_ATTRIBUTES,
106
+ event_name="gen_ai.choice"
107
+ )
108
+ Config.event_logger.emit(log_record)
@@ -0,0 +1,41 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, List, Literal, Optional, TypedDict
3
+
4
+
5
+ class _FunctionToolCall(TypedDict):
6
+ function_name: str
7
+ arguments: Optional[dict[str, Any]]
8
+
9
+
10
+ class ToolCall(TypedDict):
11
+ """Represents a tool call in the AI model."""
12
+
13
+ id: str
14
+ function: _FunctionToolCall
15
+ type: Literal["function"]
16
+
17
+
18
+ class CompletionMessage(TypedDict):
19
+ """Represents a message in the AI model."""
20
+
21
+ content: Any
22
+ role: str = "assistant"
23
+
24
+
25
+ @dataclass
26
+ class MessageEvent:
27
+ """Represents an input event for the AI model."""
28
+
29
+ content: Any
30
+ role: str = "user"
31
+ tool_calls: Optional[List[ToolCall]] = None
32
+
33
+
34
+ @dataclass
35
+ class ChoiceEvent:
36
+ """Represents a completion event for the AI model."""
37
+
38
+ index: int
39
+ message: CompletionMessage
40
+ finish_reason: str = "unknown"
41
+ tool_calls: Optional[List[ToolCall]] = None