Skip to content

Commit 194b0a7

Browse files
feat(bedrock): Instrumentation adjustment for Otel GenAI semconv support (#3845)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb95db9 commit 194b0a7

27 files changed

+4549
-1336
lines changed

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

Lines changed: 115 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from opentelemetry.instrumentation.bedrock.span_utils import (
3030
converse_usage_record,
3131
set_converse_input_prompt_span_attributes,
32+
_set_converse_finish_reasons,
33+
_set_finish_reasons_unconditionally,
3234
set_converse_model_span_attributes,
3335
set_converse_response_span_attributes,
3436
set_converse_streaming_response_span_attributes,
@@ -48,6 +50,13 @@
4850
unwrap,
4951
)
5052
from opentelemetry.metrics import Counter, Histogram, Meter, get_meter
53+
from opentelemetry.semconv._incubating.attributes import (
54+
gen_ai_attributes as GenAIAttributes,
55+
)
56+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
57+
GenAiOperationNameValues,
58+
GenAiSystemValues,
59+
)
5160
from opentelemetry.semconv_ai import (
5261
SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
5362
Meters,
@@ -104,8 +113,9 @@ def __init__(
104113
{"package": "botocore.session", "object": "Session", "method": "create_client"},
105114
]
106115

107-
_BEDROCK_INVOKE_SPAN_NAME = "bedrock.completion"
108-
_BEDROCK_CONVERSE_SPAN_NAME = "bedrock.converse"
116+
def _span_name(operation_name, model):
117+
"""Build span name per OTel semconv: '{operation_name} {model}'."""
118+
return f"{operation_name} {model}" if model else operation_name
109119

110120

111121
def is_metrics_enabled() -> bool:
@@ -200,8 +210,15 @@ def with_instrumentation(*args, **kwargs):
200210
if context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY):
201211
return fn(*args, **kwargs)
202212

213+
(provider, _model_vendor, _model) = _get_vendor_model(kwargs.get("modelId"))
214+
operation_name = _derive_operation_name(kwargs)
215+
span_attributes = {
216+
GenAIAttributes.GEN_AI_PROVIDER_NAME: provider,
217+
GenAIAttributes.GEN_AI_OPERATION_NAME: operation_name,
218+
GenAIAttributes.GEN_AI_REQUEST_MODEL: _model,
219+
}
203220
with tracer.start_as_current_span(
204-
_BEDROCK_INVOKE_SPAN_NAME, kind=SpanKind.CLIENT
221+
_span_name(operation_name, _model), kind=SpanKind.CLIENT, attributes=span_attributes
205222
) as span:
206223
response = fn(*args, **kwargs)
207224
_handle_call(span, kwargs, response, metric_params, event_logger)
@@ -218,7 +235,18 @@ def with_instrumentation(*args, **kwargs):
218235
if context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY):
219236
return fn(*args, **kwargs)
220237

221-
span = tracer.start_span(_BEDROCK_INVOKE_SPAN_NAME, kind=SpanKind.CLIENT)
238+
(provider, _model_vendor, _model) = _get_vendor_model(kwargs.get("modelId"))
239+
operation_name = _derive_operation_name(kwargs)
240+
span_attributes = {
241+
GenAIAttributes.GEN_AI_PROVIDER_NAME: provider,
242+
GenAIAttributes.GEN_AI_OPERATION_NAME: operation_name,
243+
GenAIAttributes.GEN_AI_REQUEST_MODEL: _model,
244+
}
245+
span = tracer.start_span(
246+
_span_name(operation_name, _model),
247+
kind=SpanKind.CLIENT,
248+
attributes=span_attributes,
249+
)
222250

223251
response = fn(*args, **kwargs)
224252
_handle_stream_call(span, kwargs, response, metric_params, event_logger)
@@ -237,8 +265,16 @@ def with_instrumentation(*args, **kwargs):
237265
if context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY):
238266
return fn(*args, **kwargs)
239267

268+
(provider, _model_vendor, _model) = _get_vendor_model(kwargs.get("modelId"))
269+
span_attributes = {
270+
GenAIAttributes.GEN_AI_PROVIDER_NAME: provider,
271+
GenAIAttributes.GEN_AI_OPERATION_NAME: GenAiOperationNameValues.CHAT.value,
272+
GenAIAttributes.GEN_AI_REQUEST_MODEL: _model,
273+
}
240274
with tracer.start_as_current_span(
241-
_BEDROCK_CONVERSE_SPAN_NAME, kind=SpanKind.CLIENT
275+
_span_name(GenAiOperationNameValues.CHAT.value, _model),
276+
kind=SpanKind.CLIENT,
277+
attributes=span_attributes,
242278
) as span:
243279
response = fn(*args, **kwargs)
244280
_handle_converse(span, kwargs, response, metric_params, event_logger)
@@ -254,7 +290,17 @@ def with_instrumentation(*args, **kwargs):
254290
if context_api.get_value(SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY):
255291
return fn(*args, **kwargs)
256292

257-
span = tracer.start_span(_BEDROCK_CONVERSE_SPAN_NAME, kind=SpanKind.CLIENT)
293+
(provider, _model_vendor, _model) = _get_vendor_model(kwargs.get("modelId"))
294+
span_attributes = {
295+
GenAIAttributes.GEN_AI_PROVIDER_NAME: provider,
296+
GenAIAttributes.GEN_AI_OPERATION_NAME: GenAiOperationNameValues.CHAT.value,
297+
GenAIAttributes.GEN_AI_REQUEST_MODEL: _model,
298+
}
299+
span = tracer.start_span(
300+
_span_name(GenAiOperationNameValues.CHAT.value, _model),
301+
kind=SpanKind.CLIENT,
302+
attributes=span_attributes,
303+
)
258304
response = fn(*args, **kwargs)
259305
if span.is_recording():
260306
_handle_converse_stream(span, kwargs, response, metric_params, event_logger)
@@ -299,6 +345,7 @@ def stream_done(response_body):
299345
if should_emit_events() and event_logger:
300346
emit_message_events(event_logger, kwargs)
301347
emit_streaming_response_event(response_body, event_logger)
348+
_set_finish_reasons_unconditionally(model_vendor, span, response_body)
302349
else:
303350
set_model_message_span_attributes(model_vendor, span, request_body)
304351
set_model_choice_span_attributes(model_vendor, span, response_body)
@@ -345,6 +392,7 @@ def _handle_call(span: Span, kwargs, response, metric_params, event_logger):
345392
if should_emit_events() and event_logger:
346393
emit_message_events(event_logger, kwargs)
347394
emit_choice_events(event_logger, response)
395+
_set_finish_reasons_unconditionally(model_vendor, span, response_body)
348396
else:
349397
set_model_message_span_attributes(model_vendor, span, request_body)
350398
set_model_choice_span_attributes(model_vendor, span, response_body)
@@ -362,6 +410,7 @@ def _handle_converse(span, kwargs, response, metric_params, event_logger):
362410
if should_emit_events() and event_logger:
363411
emit_input_events_converse(kwargs, event_logger)
364412
emit_response_event_converse(response, event_logger)
413+
_set_converse_finish_reasons(span, response.get("stopReason"))
365414
else:
366415
set_converse_input_prompt_span_attributes(kwargs, span)
367416
set_converse_response_span_attributes(response, span)
@@ -385,11 +434,28 @@ def _handle_converse_stream(span, kwargs, response, metric_params, event_logger)
385434
def handler(func):
386435
def wrap(*args, **kwargs):
387436
response_msg = kwargs.pop("response_msg")
437+
tool_blocks = kwargs.pop("tool_blocks")
438+
reasoning_blocks = kwargs.pop("reasoning_blocks")
388439
span = kwargs.pop("span")
389440
event = func(*args, **kwargs)
390441
nonlocal role
391-
if "contentBlockDelta" in event and "text" in event["contentBlockDelta"].get("delta", {}):
392-
response_msg.append(event["contentBlockDelta"]["delta"]["text"])
442+
if "contentBlockDelta" in event:
443+
delta = event["contentBlockDelta"].get("delta", {})
444+
if "text" in delta:
445+
response_msg.append(delta["text"])
446+
if "toolUse" in delta:
447+
# Merge delta input into the last tool block (created by contentBlockStart)
448+
if tool_blocks:
449+
tool_blocks[-1].setdefault("input", "")
450+
tool_blocks[-1]["input"] += delta["toolUse"].get("input", "")
451+
else:
452+
tool_blocks.append(delta["toolUse"])
453+
if "reasoningContent" in delta:
454+
reasoning_blocks.append(delta["reasoningContent"].get("text", ""))
455+
elif "contentBlockStart" in event:
456+
start = event["contentBlockStart"].get("start", {})
457+
if "toolUse" in start:
458+
tool_blocks.append(start["toolUse"])
393459
elif "messageStart" in event:
394460
role = event["messageStart"]["role"]
395461
elif "metadata" in event:
@@ -398,29 +464,36 @@ def wrap(*args, **kwargs):
398464
converse_usage_record(span, event["metadata"], metric_params)
399465
span.end()
400466
elif "messageStop" in event:
467+
stop_reason = event.get("messageStop", {}).get("stopReason")
401468
if should_emit_events() and event_logger:
402469
emit_streaming_converse_response_event(
403470
event_logger,
404471
response_msg,
405472
role,
406-
event.get("messageStop", {}).get("stopReason", "unknown"),
473+
stop_reason,
407474
)
475+
_set_converse_finish_reasons(span, stop_reason)
408476
else:
409477
set_converse_streaming_response_span_attributes(
410-
response_msg, role, span
478+
response_msg,
479+
role,
480+
span,
481+
finish_reason=stop_reason,
482+
tool_blocks=tool_blocks,
483+
reasoning_blocks=reasoning_blocks,
411484
)
412485

413486
return event
414487

415-
return partial(wrap, response_msg=[], span=span)
488+
return partial(wrap, response_msg=[], tool_blocks=[], reasoning_blocks=[], span=span)
416489

417490
stream._parse_event = handler(stream._parse_event)
418491

419492

420493
def _get_vendor_model(modelId):
421494
# Docs:
422495
# https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system
423-
provider = "AWS"
496+
provider = GenAiSystemValues.AWS_BEDROCK.value
424497
model_vendor = "imported_model"
425498
model = modelId
426499

@@ -449,19 +522,32 @@ def _cross_region_check(value):
449522
return model_vendor, model
450523

451524

525+
def _derive_operation_name(kwargs):
526+
"""Derive operation name for invoke_model spans prior to creation."""
527+
body_str = kwargs.get("body")
528+
if body_str:
529+
try:
530+
body = json.loads(body_str)
531+
if isinstance(body, dict) and "messages" in body:
532+
return GenAiOperationNameValues.CHAT.value
533+
except (json.JSONDecodeError, TypeError):
534+
pass
535+
return GenAiOperationNameValues.TEXT_COMPLETION.value
536+
537+
452538
class GuardrailMeters:
453-
LLM_BEDROCK_GUARDRAIL_ACTIVATION = "gen_ai.bedrock.guardrail.activation"
454-
LLM_BEDROCK_GUARDRAIL_LATENCY = "gen_ai.bedrock.guardrail.latency"
455-
LLM_BEDROCK_GUARDRAIL_COVERAGE = "gen_ai.bedrock.guardrail.coverage"
456-
LLM_BEDROCK_GUARDRAIL_SENSITIVE = "gen_ai.bedrock.guardrail.sensitive_info"
457-
LLM_BEDROCK_GUARDRAIL_TOPICS = "gen_ai.bedrock.guardrail.topics"
458-
LLM_BEDROCK_GUARDRAIL_CONTENT = "gen_ai.bedrock.guardrail.content"
459-
LLM_BEDROCK_GUARDRAIL_WORDS = "gen_ai.bedrock.guardrail.words"
539+
GEN_AI_BEDROCK_GUARDRAIL_ACTIVATION = "gen_ai.bedrock.guardrail.activation"
540+
GEN_AI_BEDROCK_GUARDRAIL_LATENCY = "gen_ai.bedrock.guardrail.latency"
541+
GEN_AI_BEDROCK_GUARDRAIL_COVERAGE = "gen_ai.bedrock.guardrail.coverage"
542+
GEN_AI_BEDROCK_GUARDRAIL_SENSITIVE = "gen_ai.bedrock.guardrail.sensitive_info"
543+
GEN_AI_BEDROCK_GUARDRAIL_TOPICS = "gen_ai.bedrock.guardrail.topics"
544+
GEN_AI_BEDROCK_GUARDRAIL_CONTENT = "gen_ai.bedrock.guardrail.content"
545+
GEN_AI_BEDROCK_GUARDRAIL_WORDS = "gen_ai.bedrock.guardrail.words"
460546

461547

462548
class PromptCaching:
463549
# will be moved under the AI SemConv. Not namespaced since also OpenAI supports this.
464-
LLM_BEDROCK_PROMPT_CACHING = "gen_ai.prompt.caching"
550+
GEN_AI_PROMPT_CACHING = "gen_ai.prompt.caching"
465551

466552

467553
def _create_metrics(meter: Meter):
@@ -484,58 +570,57 @@ def _create_metrics(meter: Meter):
484570
)
485571

486572
exception_counter = meter.create_counter(
487-
# TODO: will fix this in future as a consolidation for semantic convention
488-
name="llm.bedrock.completions.exceptions",
573+
name="gen_ai.bedrock.completions.exceptions",
489574
unit="time",
490575
description="Number of exceptions occurred during chat completions",
491576
)
492577

493578
# Guardrail metrics
494579
guardrail_activation = meter.create_counter(
495-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_ACTIVATION,
580+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_ACTIVATION,
496581
unit="",
497582
description="Number of guardrail activation",
498583
)
499584

500585
guardrail_latency_histogram = meter.create_histogram(
501-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_LATENCY,
586+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_LATENCY,
502587
unit="ms",
503588
description="GenAI guardrail latency",
504589
)
505590

506591
guardrail_coverage = meter.create_counter(
507-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_COVERAGE,
592+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_COVERAGE,
508593
unit="char",
509594
description="GenAI guardrail coverage",
510595
)
511596

512597
guardrail_sensitive_info = meter.create_counter(
513-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_SENSITIVE,
598+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_SENSITIVE,
514599
unit="",
515600
description="GenAI guardrail sensitive information protection",
516601
)
517602

518603
guardrail_topic = meter.create_counter(
519-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_TOPICS,
604+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_TOPICS,
520605
unit="",
521606
description="GenAI guardrail topics protection",
522607
)
523608

524609
guardrail_content = meter.create_counter(
525-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_CONTENT,
610+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_CONTENT,
526611
unit="",
527612
description="GenAI guardrail content filter protection",
528613
)
529614

530615
guardrail_words = meter.create_counter(
531-
name=GuardrailMeters.LLM_BEDROCK_GUARDRAIL_WORDS,
616+
name=GuardrailMeters.GEN_AI_BEDROCK_GUARDRAIL_WORDS,
532617
unit="",
533618
description="GenAI guardrail words filter protection",
534619
)
535620

536621
# Prompt Caching
537622
prompt_caching = meter.create_counter(
538-
name=PromptCaching.LLM_BEDROCK_PROMPT_CACHING,
623+
name=PromptCaching.GEN_AI_PROMPT_CACHING,
539624
unit="",
540625
description="Number of cached tokens",
541626
)

0 commit comments

Comments
 (0)