Skip to content
Merged
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
68 changes: 68 additions & 0 deletions .github/workflows/anthropic-python-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Anthropic Python compatibility

# traceAI-anthropic on the oldest SDK it supports, the first SDK with native
# OpenTelemetry tracing, and the latest SDK. python-tests.yml covers the locked SDK.

on:
pull_request:
paths:
- 'python/frameworks/anthropic/**'
- 'python/fi_instrumentation/**'
- 'python/tests/harness/**'
- '.github/workflows/anthropic-python-tests.yml'
push:
branches: [dev]
paths:
- 'python/frameworks/anthropic/**'
- 'python/fi_instrumentation/**'
- 'python/tests/harness/**'
- '.github/workflows/anthropic-python-tests.yml'

permissions:
contents: read

jobs:
contract:
name: Anthropic Python (anthropic ${{ matrix.sdk }})
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Declared floor (pyproject.toml), before native tracing.
- sdk: '0.41.0'
python: '3.10'
extra: 'httpx<0.28'
# First SDK with native OpenTelemetry tracing.
- sdk: '1.14.0'
python: '3.10'
extra: ''
# Latest release.
- sdk: '2.3.0'
python: '3.12'
extra: ''
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python }}
- name: Install source integration and the selected SDK
env:
SDK: ${{ matrix.sdk }}
EXTRA: ${{ matrix.extra }}
run: |
python -m venv .venv
.venv/bin/pip install -e python -e python/frameworks/anthropic "anthropic==$SDK" opentelemetry-sdk pytest pytest-asyncio ${EXTRA:+"$EXTRA"}
.venv/bin/python -c 'import anthropic; print("anthropic", anthropic.__version__)'
- name: Run with outbound network disabled
run: |
sudo unshare --net -- bash -e -c '
ip link set lo up
cd "$2/python/frameworks/anthropic"
"$1" -m pytest tests -q -p no:cacheprovider -o addopts=""
cd "$2/python"
FI_BASE_URL=http://127.0.0.1:9 FI_GRPC_URL=http://127.0.0.1:9 "$1" -m pytest tests/test_framework_anthropic.py -q -p no:cacheprovider
' _ "$PWD/.venv/bin/python" "$PWD"
11 changes: 11 additions & 0 deletions python/frameworks/anthropic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ from traceai_anthropic import AnthropicInstrumentor
AnthropicInstrumentor().instrument(tracer_provider=trace_provider)
```

### Anthropic SDK native tracing
Anthropic SDK 1.14.0 and later can trace Messages calls themselves. To send those spans to Future AGI without this instrumentor, pass the provider to the client:

```python
client = anthropic.Anthropic(open_telemetry={"tracer_provider": trace_provider})
```

The SDK's spans include model, timing and token usage, and include prompts and responses only with `"traces": {"content_mode": "content"}`. `TraceConfig`, `FI_HIDE_*` settings and the `using_session` / `using_attributes` context (session, user, metadata, tags) don't apply to them.

When `AnthropicInstrumentor` is installed and the SDK's spans would reach the same tracer provider (because you pass it to the client, or because it is the global provider), the instrumentor records `messages.create` (sync and async) and sync `messages.count_tokens` calls itself and turns the SDK's span off for each of those calls, so each call is recorded once with the Future AGI context and privacy controls. Your client keeps its own `open_telemetry` option. To skip this per-call step, create the client with `open_telemetry=False`. `messages.stream()` and async `messages.count_tokens` are not wrapped by the instrumentor, so they keep the SDK's span.

### Create Anthropic Components
Set up your Anthropic client with built-in observability.

Expand Down
157 changes: 157 additions & 0 deletions python/frameworks/anthropic/tests/test_native_otlp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Each Messages call reaches the Future AGI collector path once, with what Observe reads.

Real path: Anthropic SDK native tracing (or AnthropicInstrumentor) -> fi_instrumentation
register() provider -> OTLP/HTTP exporter -> loopback harness Receiver. Synthetic
transport and credentials; nothing leaves 127.0.0.1.
"""

import inspect
import json
import sys
from pathlib import Path

import anthropic
import pytest

try:
import httpx2 as httpx
except ImportError:
import httpx

_TESTS_DIR = Path(__file__).resolve().parents[3] / "tests"
if not (_TESTS_DIR / "harness" / "__init__.py").exists():
pytest.skip("needs the repository's OTLP test harness", allow_module_level=True)
sys.path.insert(0, str(_TESTS_DIR))

from harness import Receiver # noqa: E402

NATIVE = "open_telemetry" in inspect.signature(anthropic.Anthropic).parameters
pytestmark = pytest.mark.skipif(not NATIVE, reason="SDK predates native OpenTelemetry")

PROMPT = "synthetic prompt"
ANSWER = "synthetic answer"
PARAMS = dict(model="local-test", max_tokens=16, messages=[{"role": "user", "content": PROMPT}])
MESSAGE = {
"id": "msg_local",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": ANSWER}],
"model": "local-test",
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {
"input_tokens": 3,
"output_tokens": 2,
"cache_creation_input_tokens": 4,
"cache_read_input_tokens": 5,
},
}


def respond(request):
return httpx.Response(200, json=MESSAGE)


def attributes(span):
values = {}
for item in span.get("attributes", []):
value = item["value"]
values[item["key"]] = next(iter(value.values())) if value else None
return values


@pytest.fixture
def collector(monkeypatch):
with Receiver() as receiver:
monkeypatch.setenv("FI_BASE_URL", receiver.origin)
monkeypatch.setenv("FI_API_KEY", "synthetic-api-key")
monkeypatch.setenv("FI_SECRET_KEY", "synthetic-secret-key")
yield receiver


def register_provider():
from fi_instrumentation import register
from fi_instrumentation.fi_types import ProjectType

return register(project_type=ProjectType.OBSERVE, project_name="anthropic_native_contract", verbose=False)


def send(provider, content_mode=None):
traces = {"content_mode": content_mode} if content_mode else {}
with anthropic.Anthropic(
api_key="synthetic",
base_url="http://localhost",
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
open_telemetry={"tracer_provider": provider, "traces": traces},
) as client:
assert client.messages.create(**PARAMS).content[0].text == ANSWER


def assert_collector_auth(collector):
requests = collector.requests()
assert requests and {r["path"] for r in requests} == {"/tracer/v1/traces"}
assert requests[0]["headers"]["x-api-key"] == "synthetic-api-key"
assert requests[0]["headers"]["x-secret-key"] == "synthetic-secret-key"


def test_native_span_reaches_the_collector_once_with_model_usage_and_content(collector):
provider = register_provider()
try:
send(provider, content_mode="content")
provider.force_flush()
finally:
provider.shutdown()

assert_collector_auth(collector)
spans = collector.spans()
assert [span["name"] for span in spans] == ["anthropic.messages.create"]
values = attributes(spans[0])
assert values["gen_ai.provider.name"] == "anthropic"
assert values["gen_ai.operation.name"] == "chat"
assert values["gen_ai.request.model"] == "local-test"
assert values["gen_ai.response.model"] == "local-test"
# Native input usage already includes cache reads and writes.
assert int(values["gen_ai.usage.input_tokens"]) == 12
assert int(values["gen_ai.usage.output_tokens"]) == 2
assert PROMPT in json.loads(values["gen_ai.input.messages"])[0]["parts"][0]["content"]
assert ANSWER in json.loads(values["gen_ai.output.messages"])[0]["parts"][0]["content"]


def test_instrumented_client_reaches_the_collector_once_with_future_agi_context(collector):
from fi_instrumentation import using_attributes
from traceai_anthropic import AnthropicInstrumentor

provider = register_provider()
instrumentor = AnthropicInstrumentor()
instrumentor.instrument(tracer_provider=provider)
try:
with using_attributes(session_id="sess-1", user_id="user-1"):
send(provider, content_mode="content")
provider.force_flush()
finally:
instrumentor.uninstrument()
provider.shutdown()

assert_collector_auth(collector)
spans = collector.spans()
assert [span["name"] for span in spans] == ["Messages"]
values = attributes(spans[0])
assert values["session.id"] == "sess-1"
assert values["user.id"] == "user-1"
assert values["gen_ai.span.kind"] == "LLM"
assert PROMPT in values["input.value"]
assert ANSWER in values["output.value"]


def test_native_default_exports_metadata_without_content(collector):
provider = register_provider()
try:
send(provider)
provider.force_flush()
finally:
provider.shutdown()
spans = collector.spans()
assert [span["name"] for span in spans] == ["anthropic.messages.create"]
payload = json.dumps(spans)
assert PROMPT not in payload and ANSWER not in payload
assert attributes(spans[0])["gen_ai.request.model"] == "local-test"
Loading
Loading