Skip to content

Commit 4c6d274

Browse files
fix(groq): align instrumentation with OTel GenAI semconv v1.40.0 (#4010)
1 parent dcd5cb0 commit 4c6d274

14 files changed

Lines changed: 1980 additions & 402 deletions

File tree

packages/opentelemetry-instrumentation-groq/opentelemetry/instrumentation/groq/__init__.py

Lines changed: 106 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -35,35 +35,34 @@
3535
)
3636
from opentelemetry.semconv_ai import (
3737
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
38-
LLMRequestTypeValues,
3938
Meters,
40-
SpanAttributes,
4139
)
42-
from opentelemetry.trace import SpanKind, Tracer, get_tracer
40+
from opentelemetry.trace import Span, SpanKind, Tracer, get_tracer
4341
from opentelemetry.trace.status import Status, StatusCode
4442
from wrapt import wrap_function_wrapper
4543

4644
from groq._streaming import AsyncStream, Stream
45+
from groq.types.completion_usage import CompletionUsage
4746

4847
logger = logging.getLogger(__name__)
4948

5049
_instruments = ("groq >= 0.9.0",)
5150

51+
_GROQ = GenAIAttributes.GenAiProviderNameValues.GROQ.value
52+
_CHAT = GenAIAttributes.GenAiOperationNameValues.CHAT.value
5253

5354
WRAPPED_METHODS = [
5455
{
5556
"package": "groq.resources.chat.completions",
5657
"object": "Completions",
5758
"method": "create",
58-
"span_name": "groq.chat",
5959
},
6060
]
6161
WRAPPED_AMETHODS = [
6262
{
6363
"package": "groq.resources.chat.completions",
6464
"object": "AsyncCompletions",
6565
"method": "create",
66-
"span_name": "groq.chat",
6766
},
6867
]
6968

@@ -125,53 +124,90 @@ def _create_metrics(meter: Meter):
125124

126125

127126
def _process_streaming_chunk(chunk):
128-
"""Extract content, finish_reason and usage from a streaming chunk."""
127+
"""Extract content, tool_calls_delta, finish_reasons and usage from a streaming chunk."""
129128
if not chunk.choices:
130-
return None, None, None
131-
132-
delta = chunk.choices[0].delta
133-
content = delta.content if hasattr(delta, "content") else None
134-
finish_reason = chunk.choices[0].finish_reason
129+
return None, [], [], None
130+
131+
content = ""
132+
tool_calls_delta = []
133+
finish_reasons = []
134+
for choice in chunk.choices:
135+
delta = choice.delta
136+
if delta.content:
137+
content += delta.content
138+
if delta.tool_calls:
139+
tool_calls_delta.extend(delta.tool_calls)
140+
if choice.finish_reason:
141+
finish_reasons.append(choice.finish_reason)
135142

136143
# Extract usage from x_groq if present in the final chunk
137144
usage = None
138145
if hasattr(chunk, "x_groq") and chunk.x_groq and chunk.x_groq.usage:
139146
usage = chunk.x_groq.usage
140147

141-
return content, finish_reason, usage
148+
return content, tool_calls_delta, finish_reasons, usage
149+
150+
151+
def _accumulate_tool_calls(accumulated: dict, tool_calls_delta: list) -> None:
152+
"""Merge a list of streaming tool_call delta objects into the accumulator dict.
153+
154+
The accumulator maps tool call index → {id, function: {name, arguments}}.
155+
Arguments arrive as JSON fragments and are concatenated across chunks.
156+
"""
157+
for tc in tool_calls_delta:
158+
idx = tc.index or 0
159+
tc_id = tc.id or ""
160+
fn = tc.function
161+
fn_name = (fn.name or "") if fn else ""
162+
fn_args = (fn.arguments or "") if fn else ""
163+
164+
if idx not in accumulated:
165+
accumulated[idx] = {"id": tc_id, "function": {"name": fn_name, "arguments": ""}}
166+
else:
167+
if tc_id:
168+
accumulated[idx]["id"] = tc_id
169+
if fn_name:
170+
accumulated[idx]["function"]["name"] = fn_name
171+
accumulated[idx]["function"]["arguments"] += fn_args
142172

143173

144174
def _handle_streaming_response(
145-
span, accumulated_content, finish_reason, usage, event_logger
146-
):
147-
set_model_streaming_response_attributes(span, usage)
175+
span: Span,
176+
accumulated_content: str,
177+
tool_calls: dict,
178+
finish_reasons: list[str],
179+
usage: Union[CompletionUsage, None],
180+
event_logger: Union[Logger, None],
181+
) -> None:
182+
# finish_reasons is a list; use first entry for message-level finish_reason
183+
finish_reason = finish_reasons[0] if finish_reasons else None
184+
set_model_streaming_response_attributes(span, usage, finish_reasons)
148185
if should_emit_events() and event_logger:
149-
emit_streaming_response_events(accumulated_content, finish_reason, event_logger)
186+
emit_streaming_response_events(accumulated_content, finish_reason, event_logger, tool_calls=tool_calls)
150187
else:
151-
set_streaming_response_attributes(
152-
span, accumulated_content, finish_reason, usage
153-
)
188+
set_streaming_response_attributes(span, accumulated_content, finish_reason, tool_calls=tool_calls)
154189

155190

156191
def _create_stream_processor(response, span, event_logger):
157192
"""Create a generator that processes a stream while collecting telemetry."""
158193
accumulated_content = ""
159-
finish_reason = None
194+
accumulated_tool_calls: dict = {}
195+
accumulated_finish_reasons: list = []
160196
usage = None
161197

162198
for chunk in response:
163-
content, chunk_finish_reason, chunk_usage = _process_streaming_chunk(chunk)
199+
content, tool_calls_delta, chunk_finish_reasons, chunk_usage = _process_streaming_chunk(chunk)
164200
if content:
165201
accumulated_content += content
166-
if chunk_finish_reason:
167-
finish_reason = chunk_finish_reason
202+
if tool_calls_delta:
203+
_accumulate_tool_calls(accumulated_tool_calls, tool_calls_delta)
204+
accumulated_finish_reasons.extend(chunk_finish_reasons)
168205
if chunk_usage:
169206
usage = chunk_usage
170207
yield chunk
171208

172-
_handle_streaming_response(
173-
span, accumulated_content, finish_reason, usage, event_logger
174-
)
209+
tool_calls = [accumulated_tool_calls[i] for i in sorted(accumulated_tool_calls)] or None
210+
_handle_streaming_response(span, accumulated_content, tool_calls, accumulated_finish_reasons, usage, event_logger)
175211

176212
if span.is_recording():
177213
span.set_status(Status(StatusCode.OK))
@@ -182,22 +218,23 @@ def _create_stream_processor(response, span, event_logger):
182218
async def _create_async_stream_processor(response, span, event_logger):
183219
"""Create an async generator that processes a stream while collecting telemetry."""
184220
accumulated_content = ""
185-
finish_reason = None
221+
accumulated_tool_calls: dict = {}
222+
accumulated_finish_reasons: list = []
186223
usage = None
187224

188225
async for chunk in response:
189-
content, chunk_finish_reason, chunk_usage = _process_streaming_chunk(chunk)
226+
content, tool_calls_delta, chunk_finish_reasons, chunk_usage = _process_streaming_chunk(chunk)
190227
if content:
191228
accumulated_content += content
192-
if chunk_finish_reason:
193-
finish_reason = chunk_finish_reason
229+
if tool_calls_delta:
230+
_accumulate_tool_calls(accumulated_tool_calls, tool_calls_delta)
231+
accumulated_finish_reasons.extend(chunk_finish_reasons)
194232
if chunk_usage:
195233
usage = chunk_usage
196234
yield chunk
197235

198-
_handle_streaming_response(
199-
span, accumulated_content, finish_reason, usage, event_logger
200-
)
236+
tool_calls = [accumulated_tool_calls[i] for i in sorted(accumulated_tool_calls)] or None
237+
_handle_streaming_response(span, accumulated_content, tool_calls, accumulated_finish_reasons, usage, event_logger)
201238

202239
if span.is_recording():
203240
span.set_status(Status(StatusCode.OK))
@@ -240,13 +277,14 @@ def _wrap(
240277
):
241278
return wrapped(*args, **kwargs)
242279

243-
name = to_wrap.get("span_name")
280+
llm_model = kwargs.get("model", "")
244281
span = tracer.start_span(
245-
name,
282+
f"{_CHAT} {llm_model}",
246283
kind=SpanKind.CLIENT,
247284
attributes={
248-
GenAIAttributes.GEN_AI_SYSTEM: "groq",
249-
SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value,
285+
GenAIAttributes.GEN_AI_PROVIDER_NAME: _GROQ,
286+
GenAIAttributes.GEN_AI_OPERATION_NAME: _CHAT,
287+
GenAIAttributes.GEN_AI_REQUEST_MODEL: llm_model,
250288
},
251289
)
252290

@@ -255,14 +293,17 @@ def _wrap(
255293
start_time = time.time()
256294
try:
257295
response = wrapped(*args, **kwargs)
258-
except Exception as e: # pylint: disable=broad-except
296+
except Exception as e:
259297
end_time = time.time()
260298
attributes = error_metrics_attributes(e)
261299

262300
if duration_histogram:
263301
duration = end_time - start_time
264302
duration_histogram.record(duration, attributes=attributes)
265303

304+
if span.is_recording():
305+
span.set_status(Status(StatusCode.ERROR))
306+
span.end()
266307
raise e
267308

268309
end_time = time.time()
@@ -291,7 +332,7 @@ def _wrap(
291332

292333
_handle_response(span, response, token_histogram, event_logger)
293334

294-
except Exception as ex: # pylint: disable=broad-except
335+
except Exception as ex:
295336
logger.warning(
296337
"Failed to set response attributes for groq span, error: %s",
297338
str(ex),
@@ -322,13 +363,14 @@ async def _awrap(
322363
):
323364
return await wrapped(*args, **kwargs)
324365

325-
name = to_wrap.get("span_name")
366+
llm_model = kwargs.get("model", "")
326367
span = tracer.start_span(
327-
name,
368+
f"{_CHAT} {llm_model}",
328369
kind=SpanKind.CLIENT,
329370
attributes={
330-
GenAIAttributes.GEN_AI_SYSTEM: "groq",
331-
SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value,
371+
GenAIAttributes.GEN_AI_PROVIDER_NAME: _GROQ,
372+
GenAIAttributes.GEN_AI_OPERATION_NAME: _CHAT,
373+
GenAIAttributes.GEN_AI_REQUEST_MODEL: llm_model,
332374
},
333375
)
334376

@@ -338,21 +380,24 @@ async def _awrap(
338380

339381
try:
340382
response = await wrapped(*args, **kwargs)
341-
except Exception as e: # pylint: disable=broad-except
383+
except Exception as e:
342384
end_time = time.time()
343385
attributes = error_metrics_attributes(e)
344386

345387
if duration_histogram:
346388
duration = end_time - start_time
347389
duration_histogram.record(duration, attributes=attributes)
348390

391+
if span.is_recording():
392+
span.set_status(Status(StatusCode.ERROR))
393+
span.end()
349394
raise e
350395

351396
end_time = time.time()
352397

353398
if is_streaming_response(response):
354399
try:
355-
return await _create_async_stream_processor(response, span, event_logger)
400+
return _create_async_stream_processor(response, span, event_logger)
356401
except Exception as ex:
357402
logger.warning(
358403
"Failed to process streaming response for groq span, error: %s",
@@ -362,16 +407,23 @@ async def _awrap(
362407
span.end()
363408
raise
364409
elif response:
365-
metric_attributes = shared_metrics_attributes(response)
410+
try:
411+
metric_attributes = shared_metrics_attributes(response)
366412

367-
if duration_histogram:
368-
duration = time.time() - start_time
369-
duration_histogram.record(
370-
duration,
371-
attributes=metric_attributes,
372-
)
413+
if duration_histogram:
414+
duration = time.time() - start_time
415+
duration_histogram.record(
416+
duration,
417+
attributes=metric_attributes,
418+
)
373419

374-
_handle_response(span, response, token_histogram, event_logger)
420+
_handle_response(span, response, token_histogram, event_logger)
421+
422+
except Exception as ex:
423+
logger.warning(
424+
"Failed to set response attributes for groq span, error: %s",
425+
str(ex),
426+
)
375427

376428
if span.is_recording():
377429
span.set_status(Status(StatusCode.OK))
@@ -424,9 +476,7 @@ def _instrument(self, **kwargs):
424476
event_logger = None
425477
if not Config.use_legacy_attributes:
426478
logger_provider = kwargs.get("logger_provider")
427-
event_logger = get_logger(
428-
__name__, __version__, logger_provider=logger_provider
429-
)
479+
event_logger = get_logger(__name__, __version__, logger_provider=logger_provider)
430480

431481
for wrapped_method in WRAPPED_METHODS:
432482
wrap_package = wrapped_method.get("package")

0 commit comments

Comments
 (0)