diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/__init__.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/__init__.py index cb7fd30c0d..d3275df1ba 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/__init__.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/__init__.py @@ -81,6 +81,32 @@ "method": "stream", "span_name": "anthropic.chat", }, + # Beta API methods (regular Anthropic SDK) + { + "package": "anthropic.resources.beta.messages.messages", + "object": "Messages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.messages.messages", + "object": "Messages", + "method": "stream", + "span_name": "anthropic.chat", + }, + # Beta API methods (Bedrock SDK) + { + "package": "anthropic.lib.bedrock._beta_messages", + "object": "Messages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.lib.bedrock._beta_messages", + "object": "Messages", + "method": "stream", + "span_name": "anthropic.chat", + }, ] WRAPPED_AMETHODS = [ @@ -96,6 +122,32 @@ "method": "create", "span_name": "anthropic.chat", }, + # Beta API async methods (regular Anthropic SDK) + { + "package": "anthropic.resources.beta.messages.messages", + "object": "AsyncMessages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.messages.messages", + "object": "AsyncMessages", + "method": "stream", + "span_name": "anthropic.chat", + }, + # Beta API async methods (Bedrock SDK) + { + "package": "anthropic.lib.bedrock._beta_messages", + "object": "AsyncMessages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.lib.bedrock._beta_messages", + "object": "AsyncMessages", + "method": "stream", + "span_name": "anthropic.chat", + }, ] @@ -130,8 +182,8 @@ async def _aset_token_usage( token_histogram: Histogram = None, choice_counter: Counter = None, ): - if not isinstance(response, dict): - response = response.__dict__ + from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data + response = await _aextract_response_data(response) if usage := response.get("usage"): prompt_tokens = usage.input_tokens @@ -223,8 +275,8 @@ def _set_token_usage( token_histogram: Histogram = None, choice_counter: Counter = None, ): - if not isinstance(response, dict): - response = response.__dict__ + from opentelemetry.instrumentation.anthropic.utils import _extract_response_data + response = _extract_response_data(response) if usage := response.get("usage"): prompt_tokens = usage.input_tokens @@ -384,6 +436,17 @@ async def _ahandle_input(span: Span, event_logger: Optional[EventLogger], kwargs await aset_input_attributes(span, kwargs) +@dont_throw +async def _ahandle_response(span: Span, event_logger: Optional[EventLogger], response): + if should_emit_events(): + emit_response_events(event_logger, response) + else: + if not span.is_recording(): + return + from opentelemetry.instrumentation.anthropic.span_utils import aset_response_attributes + await aset_response_attributes(span, response) + + @dont_throw def _handle_response(span: Span, event_logger: Optional[EventLogger], response): if should_emit_events(): @@ -606,7 +669,8 @@ async def _awrap( kwargs, ) elif response: - metric_attributes = shared_metrics_attributes(response) + from opentelemetry.instrumentation.anthropic.utils import ashared_metrics_attributes + metric_attributes = await ashared_metrics_attributes(response) if duration_histogram: duration = time.time() - start_time @@ -615,7 +679,7 @@ async def _awrap( attributes=metric_attributes, ) - _handle_response(span, event_logger, response) + await _ahandle_response(span, event_logger, response) if span.is_recording(): await _aset_token_usage( @@ -710,7 +774,9 @@ def _instrument(self, **kwargs): wrapped_method, ), ) - except ModuleNotFoundError: + logger.debug(f"Successfully wrapped {wrap_package}.{wrap_object}.{wrap_method}") + except Exception as e: + logger.debug(f"Failed to wrap {wrap_package}.{wrap_object}.{wrap_method}: {e}") pass # that's ok, we don't want to fail if some methods do not exist for wrapped_method in WRAPPED_AMETHODS: @@ -731,7 +797,7 @@ def _instrument(self, **kwargs): wrapped_method, ), ) - except ModuleNotFoundError: + except Exception: pass # that's ok, we don't want to fail if some methods do not exist def _uninstrument(self, **kwargs): diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py index 9ac7568b1e..8900b8d4b4 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py @@ -8,6 +8,7 @@ dont_throw, model_as_dict, should_send_prompts, + _extract_response_data, ) from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( GEN_AI_RESPONSE_ID, @@ -165,11 +166,81 @@ async def aset_input_attributes(span, kwargs): ) +async def _aset_span_completions(span, response): + if not should_send_prompts(): + return + from opentelemetry.instrumentation.anthropic import set_span_attribute + from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data + + response = await _aextract_response_data(response) + index = 0 + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + set_span_attribute(span, f"{prefix}.finish_reason", response.get("stop_reason")) + if response.get("role"): + set_span_attribute(span, f"{prefix}.role", response.get("role")) + + if response.get("completion"): + set_span_attribute(span, f"{prefix}.content", response.get("completion")) + elif response.get("content"): + tool_call_index = 0 + text = "" + for content in response.get("content"): + content_block_type = content.type + # usually, Antrhopic responds with just one text block, + # but the API allows for multiple text blocks, so concatenate them + if content_block_type == "text" and hasattr(content, "text"): + text += content.text + elif content_block_type == "thinking": + content = dict(content) + # override the role to thinking + set_span_attribute( + span, + f"{prefix}.role", + "thinking", + ) + set_span_attribute( + span, + f"{prefix}.content", + content.get("thinking"), + ) + # increment the index for subsequent content blocks + index += 1 + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + # set the role to the original role on the next completions + set_span_attribute( + span, + f"{prefix}.role", + response.get("role"), + ) + elif content_block_type == "tool_use": + content = dict(content) + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.id", + content.get("id"), + ) + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.name", + content.get("name"), + ) + tool_arguments = content.get("input") + if tool_arguments is not None: + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.arguments", + json.dumps(tool_arguments), + ) + tool_call_index += 1 + set_span_attribute(span, f"{prefix}.content", text) + + def _set_span_completions(span, response): if not should_send_prompts(): return from opentelemetry.instrumentation.anthropic import set_span_attribute + response = _extract_response_data(response) index = 0 prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" set_span_attribute(span, f"{prefix}.finish_reason", response.get("stop_reason")) @@ -232,12 +303,36 @@ def _set_span_completions(span, response): set_span_attribute(span, f"{prefix}.content", text) +@dont_throw +async def aset_response_attributes(span, response): + from opentelemetry.instrumentation.anthropic import set_span_attribute + from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data + + response = await _aextract_response_data(response) + set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) + set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id")) + + if response.get("usage"): + prompt_tokens = response.get("usage").input_tokens + completion_tokens = response.get("usage").output_tokens + set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + prompt_tokens + completion_tokens, + ) + + await _aset_span_completions(span, response) + + @dont_throw def set_response_attributes(span, response): from opentelemetry.instrumentation.anthropic import set_span_attribute - if not isinstance(response, dict): - response = response.__dict__ + response = _extract_response_data(response) set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id")) diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/utils.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/utils.py index c269e9b70d..e25a21ca91 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/utils.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/utils.py @@ -61,10 +61,95 @@ def _handle_exception(e, func, logger): return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper +async def _aextract_response_data(response): + """Async version of _extract_response_data that can await coroutines.""" + import inspect + + # If we get a coroutine, await it + if inspect.iscoroutine(response): + try: + response = await response + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to await coroutine response: {e}") + return {} + + if isinstance(response, dict): + return response + + # Handle with_raw_response wrapped responses + if hasattr(response, 'parse') and callable(response.parse): + try: + # For with_raw_response, parse() gives us the actual response object + parsed_response = response.parse() + if not isinstance(parsed_response, dict): + parsed_response = parsed_response.__dict__ + return parsed_response + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to parse response: {e}, response type: {type(response)}") + + # Fallback to __dict__ for regular response objects + if hasattr(response, '__dict__'): + response_dict = response.__dict__ + return response_dict + + return {} + + +def _extract_response_data(response): + """Extract the actual response data from both regular and with_raw_response wrapped responses.""" + import inspect + + # If we get a coroutine, we cannot process it in sync context + if inspect.iscoroutine(response): + import logging + logger = logging.getLogger(__name__) + logger.warning(f"_extract_response_data received coroutine {response} - response processing skipped") + return {} + + if isinstance(response, dict): + return response + + # Handle with_raw_response wrapped responses + if hasattr(response, 'parse') and callable(response.parse): + try: + # For with_raw_response, parse() gives us the actual response object + parsed_response = response.parse() + if not isinstance(parsed_response, dict): + parsed_response = parsed_response.__dict__ + return parsed_response + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to parse response: {e}, response type: {type(response)}") + + # Fallback to __dict__ for regular response objects + if hasattr(response, '__dict__'): + response_dict = response.__dict__ + return response_dict + + return {} + + +@dont_throw +async def ashared_metrics_attributes(response): + response = await _aextract_response_data(response) + + common_attributes = Config.get_common_metrics_attributes() + + return { + **common_attributes, + GEN_AI_SYSTEM: GEN_AI_SYSTEM_ANTHROPIC, + SpanAttributes.LLM_RESPONSE_MODEL: response.get("model"), + } + + @dont_throw def shared_metrics_attributes(response): - if not isinstance(response, dict): - response = response.__dict__ + response = _extract_response_data(response) common_attributes = Config.get_common_metrics_attributes() diff --git a/packages/opentelemetry-instrumentation-anthropic/poetry.lock b/packages/opentelemetry-instrumentation-anthropic/poetry.lock index c8c833ecc5..7bbd0cea8c 100644 --- a/packages/opentelemetry-instrumentation-anthropic/poetry.lock +++ b/packages/opentelemetry-instrumentation-anthropic/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -26,6 +26,8 @@ files = [ [package.dependencies] anyio = ">=3.5.0,<5" +boto3 = {version = ">=1.28.57", optional = true, markers = "extra == \"bedrock\""} +botocore = {version = ">=1.31.57", optional = true, markers = "extra == \"bedrock\""} distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" jiter = ">=0.4.0,<1" @@ -57,7 +59,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -76,6 +78,49 @@ files = [ pycodestyle = ">=2.11.0" tomli = {version = "*", markers = "python_version < \"3.11\""} +[[package]] +name = "boto3" +version = "1.40.7" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "boto3-1.40.7-py3-none-any.whl", hash = "sha256:8727cac601a679d2885dc78b8119a0548bbbe04e49b72f7d94021a629154c080"}, + {file = "boto3-1.40.7.tar.gz", hash = "sha256:61b15f70761f1eadd721c6ba41a92658f003eaaef09500ca7642f5ae68ec8945"}, +] + +[package.dependencies] +botocore = ">=1.40.7,<1.41.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.13.0,<0.14.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.40.7" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "botocore-1.40.7-py3-none-any.whl", hash = "sha256:a06956f3d7222e80ef6ae193608f358c3b7898e1a2b88553479d8f9737fbb03e"}, + {file = "botocore-1.40.7.tar.gz", hash = "sha256:33793696680cf3a0c4b5ace4f9070c67c4d4fcb19c999fd85cfee55de3dcf913"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = [ + {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, + {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, +] + +[package.extras] +crt = ["awscrt (==0.23.8)"] + [[package]] name = "certifi" version = "2024.8.30" @@ -138,7 +183,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev", "test"] -markers = "python_version < \"3.11\"" +markers = "python_version <= \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -218,7 +263,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -257,7 +302,7 @@ zipp = ">=0.5" [package.extras] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] [[package]] name = "iniconfig" @@ -354,6 +399,18 @@ files = [ {file = "jiter-0.6.1.tar.gz", hash = "sha256:e19cd21221fc139fb032e4112986656cb2739e9fe6d84c13956ab30ccc7d4449"}, ] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -720,7 +777,7 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" @@ -918,6 +975,21 @@ termcolor = ">=2.1.0" [package.extras] dev = ["black", "flake8", "pre-commit"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["test"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pyyaml" version = "6.0.2" @@ -981,6 +1053,36 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "s3transfer" +version = "0.13.1" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, + {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["test"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1015,7 +1117,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev", "test"] -markers = "python_version < \"3.11\"" +markers = "python_version <= \"3.10\"" files = [ {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, @@ -1047,8 +1149,8 @@ files = [ ] [package.extras] -brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1065,7 +1167,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1284,11 +1386,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [extras] @@ -1297,4 +1399,4 @@ instruments = [] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "8e586773f073662bd2916fef17bc96a70cf41ab20cd14bdfefb4220f3c61e4bf" +content-hash = "e0918372b0e8fa8fe34e18d5e3df0596b5f29bbb8b175bd7516f6a99030066e2" diff --git a/packages/opentelemetry-instrumentation-anthropic/pyproject.toml b/packages/opentelemetry-instrumentation-anthropic/pyproject.toml index 056d7de6c1..e79d6cfef0 100644 --- a/packages/opentelemetry-instrumentation-anthropic/pyproject.toml +++ b/packages/opentelemetry-instrumentation-anthropic/pyproject.toml @@ -36,7 +36,7 @@ pytest = "^8.2.2" pytest-sugar = "1.0.0" [tool.poetry.group.test.dependencies] -anthropic = ">=0.36.0" +anthropic = {extras = ["bedrock"], version = ">=0.36.0"} pytest = "^8.2.2" pytest-sugar = "1.0.0" vcrpy = "^6.0.1" diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_beta_with_raw_response.yaml b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_beta_with_raw_response.yaml new file mode 100644 index 0000000000..cda5222482 --- /dev/null +++ b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_beta_with_raw_response.yaml @@ -0,0 +1,80 @@ +interactions: +- request: + body: '{"max_tokens": 1024, "messages": [{"role": "user", "content": "Tell me + a joke about OpenTelemetry"}], "anthropic_version": "bedrock-2023-05-31"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - AWS4-HMAC-SHA256 Credential=AKIAQEMAC2MSQDTITCKK/20250812/us-east-1/bedrock/aws4_request, + SignedHeaders=accept;accept-encoding;content-length;content-type;host;x-amz-date;x-stainless-arch;x-stainless-lang;x-stainless-os;x-stainless-package-version;x-stainless-raw-response;x-stainless-read-timeout;x-stainless-retry-count;x-stainless-runtime;x-stainless-runtime-version;x-stainless-timeout, + Signature=c2c5f7bbfea072d8914f9500ba1d01f219242c57037156147fb496a489d6c514 + connection: + - keep-alive + content-length: + - '144' + content-type: + - application/json + host: + - bedrock-runtime.us-east-1.amazonaws.com + user-agent: + - AsyncAnthropicBedrock/Python 0.49.0 + x-amz-date: + - 20250812T151107Z + x-stainless-arch: + - arm64 + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 0.49.0 + x-stainless-raw-response: + - 'true' + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.11.7 + x-stainless-timeout: + - '600' + method: POST + uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke + response: + body: + string: '{"id":"msg_bdrk_01My5iqbGec6Tx5Hj3f9ixA5","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[{"type":"text","text":"Okay, + here''s an OpenTelemetry-themed joke for you:\n\nWhy did the developer add + OpenTelemetry to their codebase? To trace their bugs back to the source!\n\nYou + see, OpenTelemetry is an open-source observability framework that helps developers + collect, process, and export telemetry data (such as metrics, logs, and traces) + from their applications. The joke plays on the idea that tracing and observability + are key features of OpenTelemetry, which can help developers find and fix + bugs more easily.\n\nI hope you found that at least mildly amusing! Let me + know if you''d like to hear another OpenTelemetry or software engineering-themed + joke."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":17,"output_tokens":158}}' + headers: + Connection: + - keep-alive + Content-Length: + - '884' + Content-Type: + - application/json + Date: + - Tue, 12 Aug 2025 15:11:10 GMT + X-Amzn-Bedrock-Input-Token-Count: + - '17' + X-Amzn-Bedrock-Invocation-Latency: + - '2378' + X-Amzn-Bedrock-Output-Token-Count: + - '158' + x-amzn-RequestId: + - 32c10f95-b609-44be-95d4-b82692d2a824 + status: + code: 200 + message: OK +version: 1 diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_regular_create.yaml b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_regular_create.yaml new file mode 100644 index 0000000000..2e92086671 --- /dev/null +++ b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_regular_create.yaml @@ -0,0 +1,72 @@ +interactions: +- request: + body: '{"max_tokens": 1024, "messages": [{"role": "user", "content": "Tell me + a joke about OpenTelemetry"}], "anthropic_version": "bedrock-2023-05-31"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - AWS4-HMAC-SHA256 Credential=AKIAQEMAC2MSQDTITCKK/20250812/us-east-1/bedrock/aws4_request, + SignedHeaders=accept;accept-encoding;content-length;content-type;host;x-amz-date;x-stainless-arch;x-stainless-lang;x-stainless-os;x-stainless-package-version;x-stainless-read-timeout;x-stainless-retry-count;x-stainless-runtime;x-stainless-runtime-version;x-stainless-timeout, + Signature=7eb3fdf5c7fe741fb5066e245bf529e9b385d7667eedfe0a955bdabec62ae1aa + connection: + - keep-alive + content-length: + - '144' + content-type: + - application/json + host: + - bedrock-runtime.us-east-1.amazonaws.com + user-agent: + - AsyncAnthropicBedrock/Python 0.49.0 + x-amz-date: + - 20250812T130741Z + x-stainless-arch: + - arm64 + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 0.49.0 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.11.7 + x-stainless-timeout: + - '600' + method: POST + uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke + response: + body: + string: '{"id":"msg_bdrk_01BpNWhDaaMSLb423BECgv49","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[{"type":"text","text":"Here''s + an OpenTelemetry-themed joke for you:\n\nWhy did the developer have trouble + instrumenting their application with OpenTelemetry?\n\nBecause they were constantly + getting tripped up by all the Spans!"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":17,"output_tokens":50}}' + headers: + Connection: + - keep-alive + Content-Length: + - '446' + Content-Type: + - application/json + Date: + - Tue, 12 Aug 2025 13:07:42 GMT + X-Amzn-Bedrock-Input-Token-Count: + - '17' + X-Amzn-Bedrock-Invocation-Latency: + - '735' + X-Amzn-Bedrock-Output-Token-Count: + - '50' + x-amzn-RequestId: + - 20a78996-8d60-4102-935c-e7d2e2217dc8 + status: + code: 200 + message: OK +version: 1 diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_with_raw_response.yaml b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_with_raw_response.yaml new file mode 100644 index 0000000000..20a8c6af34 --- /dev/null +++ b/packages/opentelemetry-instrumentation-anthropic/tests/cassettes/test_bedrock_with_raw_response/test_async_anthropic_bedrock_with_raw_response.yaml @@ -0,0 +1,83 @@ +interactions: +- request: + body: '{"max_tokens": 1024, "messages": [{"role": "user", "content": "Tell me + a joke about OpenTelemetry"}], "anthropic_version": "bedrock-2023-05-31"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - AWS4-HMAC-SHA256 Credential=AKIAQEMAC2MSQDTITCKK/20250812/us-east-1/bedrock/aws4_request, + SignedHeaders=accept;accept-encoding;content-length;content-type;host;x-amz-date;x-stainless-arch;x-stainless-lang;x-stainless-os;x-stainless-package-version;x-stainless-raw-response;x-stainless-read-timeout;x-stainless-retry-count;x-stainless-runtime;x-stainless-runtime-version;x-stainless-timeout, + Signature=163e22b87236d5029439f088af26ed28cfa293f95a22e4cdba8789e58e8709c2 + connection: + - keep-alive + content-length: + - '144' + content-type: + - application/json + host: + - bedrock-runtime.us-east-1.amazonaws.com + user-agent: + - AsyncAnthropicBedrock/Python 0.49.0 + x-amz-date: + - 20250812T130738Z + x-stainless-arch: + - arm64 + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 0.49.0 + x-stainless-raw-response: + - 'true' + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.11.7 + x-stainless-timeout: + - '600' + method: POST + uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke + response: + body: + string: '{"id":"msg_bdrk_01N3b1bEN3sbiHjQAKknzJir","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[{"type":"text","text":"Here''s + an OpenTelemetry-themed joke:\n\nWhy did the developer spend hours configuring + the OpenTelemetry instrumentation?\nBecause they wanted to trace the root + cause of their bugs!\n\nIn the world of distributed systems and microservices, + where complexity reigns, OpenTelemetry is a powerful tool to help developers + understand the behavior of their applications. By providing a standardized + approach to collecting, processing, and exporting telemetry data, OpenTelemetry + can simplify the task of troubleshooting and optimizing complex systems. \n\nThe + joke plays on the idea that with all the configuration and setup required + to get OpenTelemetry working, a developer might find themselves spending a + lot of time \"tracing\" the root causes of their issues. But of course, the + payoff is worth it - a comprehensive view of your application''s performance + and behavior."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":17,"output_tokens":190}}' + headers: + Connection: + - keep-alive + Content-Length: + - '1110' + Content-Type: + - application/json + Date: + - Tue, 12 Aug 2025 13:07:41 GMT + X-Amzn-Bedrock-Input-Token-Count: + - '17' + X-Amzn-Bedrock-Invocation-Latency: + - '2144' + X-Amzn-Bedrock-Output-Token-Count: + - '190' + x-amzn-RequestId: + - 1b7b14a3-3209-40aa-81c1-b5f916f4d5a9 + status: + code: 200 + message: OK +version: 1 diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/test_bedrock_with_raw_response.py b/packages/opentelemetry-instrumentation-anthropic/tests/test_bedrock_with_raw_response.py new file mode 100644 index 0000000000..dc950a7f9a --- /dev/null +++ b/packages/opentelemetry-instrumentation-anthropic/tests/test_bedrock_with_raw_response.py @@ -0,0 +1,161 @@ +import os +import pytest +from opentelemetry.semconv_ai import SpanAttributes + +try: + from anthropic import AsyncAnthropicBedrock +except ImportError: + AsyncAnthropicBedrock = None + + +@pytest.fixture +def async_anthropic_bedrock_client(instrument_legacy): + if AsyncAnthropicBedrock is None: + pytest.skip("AsyncAnthropicBedrock not available") + + # Try to get credentials from environment first + aws_access_key = os.environ.get("AWS_ACCESS_KEY_ID", "test-key") + aws_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "test-secret") + aws_region = os.environ.get("AWS_REGION", "us-east-1") + + return AsyncAnthropicBedrock( + aws_region=aws_region, + aws_access_key=aws_access_key, + aws_secret_key=aws_secret_key + ) + + +@pytest.mark.asyncio +@pytest.mark.vcr +async def test_async_anthropic_bedrock_with_raw_response( + instrument_legacy, async_anthropic_bedrock_client, span_exporter, log_exporter, reader +): + """Test that AsyncAnthropicBedrock with_raw_response.create generates spans""" + response = await async_anthropic_bedrock_client.messages.with_raw_response.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Tell me a joke about OpenTelemetry", + } + ], + model="anthropic.claude-3-haiku-20240307-v1:0", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + assert all(span.name == "anthropic.chat" for span in spans) + + anthropic_span = spans[0] + assert ( + anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"] + == "Tell me a joke about OpenTelemetry" + ) + assert (anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"]) == "user" + # For raw response, content is accessed differently + response_content = response.parse().content[0].text if hasattr(response, 'parse') else response.content[0].text + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.content") + == response_content + ) + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.role") + == "assistant" + ) + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] > 0 + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] > 0 + assert ( + anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] + + anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] + == anthropic_span.attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] + ) + + +@pytest.mark.asyncio +@pytest.mark.vcr +async def test_async_anthropic_bedrock_regular_create( + instrument_legacy, async_anthropic_bedrock_client, span_exporter, log_exporter, reader +): + """Test that regular AsyncAnthropicBedrock create works (for comparison)""" + response = await async_anthropic_bedrock_client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Tell me a joke about OpenTelemetry", + } + ], + model="anthropic.claude-3-haiku-20240307-v1:0", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + assert all(span.name == "anthropic.chat" for span in spans) + + anthropic_span = spans[0] + assert ( + anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"] + == "Tell me a joke about OpenTelemetry" + ) + assert (anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"]) == "user" + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.content") + == response.content[0].text + ) + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.role") + == "assistant" + ) + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] > 0 + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] > 0 + assert ( + anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] + + anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] + == anthropic_span.attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] + ) + + +@pytest.mark.asyncio +@pytest.mark.vcr +async def test_async_anthropic_bedrock_beta_with_raw_response( + instrument_legacy, async_anthropic_bedrock_client, span_exporter, log_exporter, reader +): + """Test that AsyncAnthropicBedrock beta.messages.with_raw_response.create generates spans""" + response = await async_anthropic_bedrock_client.beta.messages.with_raw_response.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Tell me a joke about OpenTelemetry", + } + ], + model="anthropic.claude-3-haiku-20240307-v1:0", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + assert all(span.name == "anthropic.chat" for span in spans) + + anthropic_span = spans[0] + assert ( + anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"] + == "Tell me a joke about OpenTelemetry" + ) + assert (anthropic_span.attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"]) == "user" + # For raw response, content is accessed differently + response_content = response.parse().content[0].text if hasattr(response, 'parse') else response.content[0].text + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.content") + == response_content + ) + assert ( + anthropic_span.attributes.get(f"{SpanAttributes.LLM_COMPLETIONS}.0.role") + == "assistant" + ) + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] > 0 + assert anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] > 0 + assert ( + anthropic_span.attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] + + anthropic_span.attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] + == anthropic_span.attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] + )