3535)
3636from 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
4341from opentelemetry .trace .status import Status , StatusCode
4442from wrapt import wrap_function_wrapper
4543
4644from groq ._streaming import AsyncStream , Stream
45+ from groq .types .completion_usage import CompletionUsage
4746
4847logger = logging .getLogger (__name__ )
4948
5049_instruments = ("groq >= 0.9.0" ,)
5150
51+ _GROQ = GenAIAttributes .GenAiProviderNameValues .GROQ .value
52+ _CHAT = GenAIAttributes .GenAiOperationNameValues .CHAT .value
5253
5354WRAPPED_METHODS = [
5455 {
5556 "package" : "groq.resources.chat.completions" ,
5657 "object" : "Completions" ,
5758 "method" : "create" ,
58- "span_name" : "groq.chat" ,
5959 },
6060]
6161WRAPPED_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
127126def _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
144174def _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
156191def _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):
182218async 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