Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion instrumentation-genai/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

| Instrumentation | Supported Packages | Metrics support | Semconv status |
| --------------- | ------------------ | --------------- | -------------- |
| [opentelemetry-instrumentation-anthropic](./opentelemetry-instrumentation-anthropic) | anthropic >= 0.3.0 | No | development
| [opentelemetry-instrumentation-anthropic](./opentelemetry-instrumentation-anthropic) | anthropic >= 0.16.0 | No | development
| [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.0.0 | No | development
| [opentelemetry-instrumentation-langchain](./opentelemetry-instrumentation-langchain) | langchain >= 0.3.21 | No | development
| [opentelemetry-instrumentation-openai-agents-v2](./opentelemetry-instrumentation-openai-agents-v2) | openai-agents >= 0.3.3 | No | development
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Initial implementation of Anthropic instrumentation
([#ISSUE_NUMBER](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3978))
([#3978](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3978))
- Implement sync `Messages.create` instrumentation with GenAI semantic convention attributes
([#4034](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4034))
- Captures request attributes: `gen_ai.request.model`, `gen_ai.request.max_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.top_k`, `gen_ai.request.stop_sequences`
- Captures response attributes: `gen_ai.response.id`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`
- Error handling with `error.type` attribute
- Minimum supported anthropic version is 0.16.0 (SDK uses modern `anthropic.resources.messages` module structure introduced in this version)

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
anthropic~=0.3.0
anthropic>=0.16.0
opentelemetry-sdk~=1.36.0
opentelemetry-instrumentation-anthropic #TODO: update to 2.1b0 when released
opentelemetry-exporter-otlp-proto-grpc~=1.36.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
anthropic~=0.3.0
anthropic>=0.16.0
opentelemetry-sdk~=1.28.2
opentelemetry-distro~=0.49b2
opentelemetry-instrumentation-anthropic #TODO: update to 2.1b0 when released
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [

[project.optional-dependencies]
instruments = [
"anthropic >= 0.3.0",
"anthropic >= 0.16.0",
]

[project.entry-points.opentelemetry_instrumentor]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,15 @@

from typing import Any, Collection

from wrapt import (
wrap_function_wrapper, # pyright: ignore[reportUnknownVariableType]
)

from opentelemetry.instrumentation.anthropic.package import _instruments
from opentelemetry.instrumentation.anthropic.patch import messages_create
from opentelemetry.instrumentation.anthropic.utils import is_content_enabled
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.utils import unwrap
from opentelemetry.semconv.schemas import Schemas


Expand Down Expand Up @@ -119,11 +126,21 @@ def _instrument(self, **kwargs: Any) -> None:
self._logger = logger
self._meter = meter

# Patching will be added in Ticket 3
# Patch Messages.create
wrap_function_wrapper(
module="anthropic.resources.messages",
name="Messages.create",
wrapper=messages_create(tracer, is_content_enabled()),
)

def _uninstrument(self, **kwargs: Any) -> None:
"""Disable Anthropic instrumentation.

This removes all patches applied during instrumentation.
"""
# Unpatching will be added in Ticket 3
import anthropic # pylint: disable=import-outside-toplevel # noqa: PLC0415

unwrap(
anthropic.resources.messages.Messages, # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]
"create",
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.

_instruments = ("anthropic >= 0.3.0",)
_instruments = ("anthropic >= 0.16.0",)
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,87 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Patching functions for Anthropic instrumentation."""

from typing import Any, Callable

from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAIAttributes,
)
from opentelemetry.trace import Span, SpanKind, Tracer

from .utils import (
get_llm_request_attributes,
handle_span_exception,
set_span_attribute,
)


def messages_create(
tracer: Tracer,
_capture_content: bool,
) -> Callable[..., Any]:
"""Wrap the `create` method of the `Messages` class to trace it."""

def traced_method(
wrapped: Callable[..., Any],
instance: Any,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> Any:
span_attributes = {**get_llm_request_attributes(kwargs, instance)}

span_name = f"{span_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME]} {span_attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]}"
with tracer.start_as_current_span(
name=span_name,
kind=SpanKind.CLIENT,
attributes=span_attributes,
end_on_exit=False,
) as span:
try:
result = wrapped(*args, **kwargs)

if span.is_recording():
_set_response_attributes(span, result)

span.end()
return result

except Exception as error:
handle_span_exception(span, error)
raise

return traced_method


def _set_response_attributes(span: Span, result: Any) -> None:
"""Set span attributes from the Anthropic response."""
if getattr(result, "model", None):
set_span_attribute(
span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, result.model
)

if getattr(result, "stop_reason", None):
# Anthropic uses stop_reason (single string), semantic convention expects array
set_span_attribute(
span,
GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS,
[result.stop_reason],
)

if getattr(result, "id", None):
set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_ID, result.id)

# Get the usage
if getattr(result, "usage", None):
set_span_attribute(
span,
GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS,
result.usage.input_tokens,
)
set_span_attribute(
span,
GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS,
result.usage.output_tokens,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Utility functions for Anthropic instrumentation."""

from __future__ import annotations

from os import environ
from typing import Any, Optional
from urllib.parse import urlparse

from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAIAttributes,
)
from opentelemetry.semconv._incubating.attributes import (
server_attributes as ServerAttributes,
)
from opentelemetry.semconv.attributes import (
error_attributes as ErrorAttributes,
)
from opentelemetry.trace import Span
from opentelemetry.trace.status import Status, StatusCode

OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
)


def is_content_enabled() -> bool:
"""Check if content capture is enabled via environment variable."""
capture_content = environ.get(
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, "false"
)
return capture_content.lower() == "true"


def set_server_address_and_port(
client_instance: Any, attributes: dict[str, Any]
) -> None:
"""Extract server address and port from the Anthropic client instance."""
base_client = getattr(client_instance, "_client", None)
base_url = getattr(base_client, "base_url", None)
if not base_url:
return

port: Optional[int] = None
if hasattr(base_url, "host"):
# httpx.URL object
attributes[ServerAttributes.SERVER_ADDRESS] = base_url.host
port = getattr(base_url, "port", None)
elif isinstance(base_url, str):
url = urlparse(base_url)
attributes[ServerAttributes.SERVER_ADDRESS] = url.hostname
port = url.port

if port and port != 443 and port > 0:
attributes[ServerAttributes.SERVER_PORT] = port


def set_span_attribute(span: Span, name: str, value: Any) -> None:
"""Set a span attribute if the value is not None."""
if value is None:
return
span.set_attribute(name, value)


def get_llm_request_attributes(
kwargs: dict[str, Any], client_instance: Any
) -> dict[str, Any]:
"""Extract LLM request attributes from kwargs."""
attributes = {
GenAIAttributes.GEN_AI_OPERATION_NAME: GenAIAttributes.GenAiOperationNameValues.CHAT.value,
GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.ANTHROPIC.value, # pyright: ignore[reportDeprecated]
GenAIAttributes.GEN_AI_REQUEST_MODEL: kwargs.get("model"),
GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS: kwargs.get("max_tokens"),
GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE: kwargs.get("temperature"),
GenAIAttributes.GEN_AI_REQUEST_TOP_P: kwargs.get("top_p"),
GenAIAttributes.GEN_AI_REQUEST_TOP_K: kwargs.get("top_k"),
GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES: kwargs.get(
"stop_sequences"
),
}

set_server_address_and_port(client_instance, attributes)

# Filter out None values
return {k: v for k, v in attributes.items() if v is not None}


def handle_span_exception(span: Span, error: Exception) -> None:
"""Handle an exception by setting span status and error attributes."""
span.set_status(Status(StatusCode.ERROR, str(error)))
if span.is_recording():
span.set_attribute(
ErrorAttributes.ERROR_TYPE, type(error).__qualname__
)
span.end()
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
interactions:
- request:
body: |-
{
"max_tokens": 100,
"messages": [
{
"role": "user",
"content": "Hello"
}
],
"model": "invalid-model-name"
}
headers:
accept:
- application/json
accept-encoding:
- gzip, deflate
anthropic-version:
- '2023-06-01'
connection:
- keep-alive
content-length:
- '110'
content-type:
- application/json
host:
- api.anthropic.com
user-agent:
- Anthropic/Python
x-api-key:
- test_anthropic_api_key
method: POST
uri: https://api.anthropic.com/v1/messages
response:
body:
string: |-
{
"type": "error",
"error": {
"type": "not_found_error",
"message": "model: invalid-model-name"
}
}
headers:
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Mon, 15 Dec 2024 10:00:04 GMT
Server:
- cloudflare
content-length:
- '105'
status:
code: 404
message: Not Found
version: 1

Loading