The Claude Platform can export OpenTelemetry (OTel) traces for Messages API requests, tool uses, and Claude Managed Agents sessions.
The spans follow the GenAI semantic conventions, which
AI Observability natively understands.
You can collect traces from two sources, and combine them:
To see billed cost and organization-wide token usage, you can also import usage and cost reports from the Claude Usage and Cost Analytics API.
OpenTelemetry export on the Claude Platform is in beta. Span and attribute names may change.
This guide is for:
By following this guide, you'll learn:
AI Observability.To get started, you need to have:
openpipeline:traces:ingest scope.read:analytics scope (Claude Enterprise).openpipeline:bizevents:ingest scope.It's helpful to have some basic knowledge of:
In the Claude Console, go to Settings > OpenTelemetry and select Add destination.
For Provider, select Custom.
For OTLP endpoint URL, enter https://<your-environment-id>.live.dynatrace.com/api/v2/otlp.
Don't append /v1/traces; the Claude API adds it.
Under Request headers, add the Authorization header with the value Bearer <your-platform-token>.
Choose a Content mode:
AI Observability.Select the Workspaces to export, then save the destination.

With Full content, the default Maximum content size in MB of 0.75 MB per span works with Dynatrace. Dynatrace accepts OTLP trace requests of up to 8 MB. For details, see OTLP trace ingest limits. If you raise the limit under Advanced settings, keep it well below 8 MB, because a single request can contain several spans.
In the destination's More actions menu, select Test.
The Claude API sends a single anthropic.test span to Dynatrace and reports whether delivery succeeded.
After real traffic flows, the Delivery column shows Healthy.

The Claude SDK emits spans through the tracer provider registered in your application. Configure an OTLP exporter that points to Dynatrace, then pass the provider to the client. The following example uses Python; the SDKs for TypeScript, Java, Go, C#, PHP, and Ruby support the same options.
pip install anthropic opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://<your-environment-id>.live.dynatrace.com/api/v2/otlp/v1/traces"export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer <your-platform-token>"export OTEL_SERVICE_NAME="my-claude-app"
from anthropic import Anthropicfrom opentelemetry import tracefrom opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporterfrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessorprovider = TracerProvider()provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))trace.set_tracer_provider(provider)client = Anthropic(open_telemetry={"tracer_provider": provider,"traces": {"content_mode": "content"}, # omit for metadata only})message = client.messages.create(model="claude-sonnet-5-5",max_tokens=256,messages=[{"role": "user", "content": "Write a haiku about observability."}],)print(message.content[0].text)provider.shutdown()
Use API tracing and SDK tracing together, both sending to Dynatrace.
The SDK propagates the W3C traceparent header, so the API's server spans join your application's trace.
Traces show individual requests, but they don't show what Anthropic bills you. You can use the Usage and Cost Analytics API to get aggregated token usage and cost for your whole organization, including traffic that you don't trace, such as Claude Code and Claude Console usage.
Using this API requires an Admin API key (Claude Console) or an Analytics API key (Claude Enterprise). If you don't have one of these API keys, you can still query token usage from your traces, but not billed cost.
For more information, see Get token usage without an API key.
To send this data to Dynatrace, run a small job that polls the API and sends the results as business events. The job reads two endpoints:
| Endpoint | Data |
|---|---|
| Hourly token counts: |
| Daily cost in USD: |
The script groups both by model, workspace_id, service_tier, and product (for example, messages_api or claude_code).
The API supports more dimensions, such as api_key_id and actor (the user or service account behind each request). For the full list, see the usage and cost API reference.
The API reports aggregates only. It doesn't include prompts, responses, or per-request identifiers.
anthropic-beta: analytics-usage-cost-2026-08-24 header.data_refreshed_at; the following script imports a day only after the data covers it completely.The following Python script imports the previous UTC day of hourly usage and daily cost and sends one business event per result row. Run it once a day, for example with cron or a scheduled CI job. If the data for that day isn't complete yet, the script exits without sending anything, so a later run can pick it up.
Re-running the job for the same day sends the same buckets again.
Run it once per day, or deduplicate in your queries by bucket.start and the grouping fields.
Query a timeframe that covers when the job ran, because event timestamps are the ingest time, not the bucket time.
Dynatrace rejects business events with a timestamp more than 24 hours in the past, so each event carries its bucket time in bucket.start and bucket.end and gets the ingest time as its timestamp.
export ANTHROPIC_ADMIN_KEY="<your-admin-or-analytics-api-key>"export DT_ENV_URL="https://<your-environment-id>.live.dynatrace.com"export DT_TOKEN="<your-platform-token>"pip install requests
Save the following script as import_usage_cost.py and run it with python import_usage_cost.py.
import osimport sysfrom datetime import datetime, timedelta, timezoneimport requestsANTHROPIC = "https://api.anthropic.com/v1/organizations/analytics"HEADERS = {"x-api-key": os.environ["ANTHROPIC_ADMIN_KEY"],"anthropic-version": "2023-06-01","anthropic-beta": "analytics-usage-cost-2026-08-24","User-Agent": "Dynatrace/1.0.0 (https://www.dynatrace.com)",}DT_INGEST = os.environ["DT_ENV_URL"] + "/api/v2/bizevents/ingest"DT_HEADERS = {"Authorization": "Bearer " + os.environ["DT_TOKEN"]}GROUP_BY = ["model", "workspace_id", "service_tier", "product"]def fetch(path, params):"""Return all rows for a query, restarting if a page cursor expires."""for _ in range(3): # restart if a page cursor expiresrows, query = [], dict(params, limit=1000)while True:resp = requests.get(f"{ANTHROPIC}/{path}", headers=HEADERS, params=query, timeout=60)if resp.status_code == 410: # cursor expired after a data refreshbreakresp.raise_for_status()body = resp.json()refreshed = body["data_refreshed_at"]if refreshed is None or datetime.fromisoformat(refreshed.replace("Z", "+00:00")) < end:sys.exit(f"{path}: data not complete yet (refreshed at {refreshed}), try later")rows += body["data"]if not body["has_more"]:return rowsquery["page"] = body["next_page"]sys.exit(f"{path}: page cursor kept expiring, try again later")day = os.environ.get("IMPORT_DAY") # YYYY-MM-DD (UTC); defaults to the previous UTC daytoday = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)start = datetime.strptime(day, "%Y-%m-%d").replace(tzinfo=timezone.utc) if day else today - timedelta(days=1)end = start + timedelta(days=1)window = {"starting_at": start.strftime("%Y-%m-%dT%H:%M:%SZ"),"ending_at": end.strftime("%Y-%m-%dT%H:%M:%SZ"),}events = []for row in fetch("usage", {**window, "time_bucket_width": "1h", "group_by[]": GROUP_BY}):g, u = row["group"], row["usage"]events.append({"event.type": "anthropic.usage","event.provider": "claude-platform","bucket.start": row["starting_at"],"bucket.end": row["ending_at"],"gen_ai.provider.name": "anthropic","gen_ai.request.model": g["model"],"workspace_id": g["workspace_id"],"service_tier": g["service_tier"],"product": g["product"],"requests": u["requests"],"uncached_input_tokens": u["uncached_input_tokens"],"cache_read_input_tokens": u["cache_read_input_tokens"],"cache_creation_5m_input_tokens": u["cache_creation"]["ephemeral_5m_input_tokens"],"cache_creation_1h_input_tokens": u["cache_creation"]["ephemeral_1h_input_tokens"],"output_tokens": u["output_tokens"],"total_tokens": u["total_tokens"],"web_search_requests": u["server_tool_use"]["web_search_requests"],})for row in fetch("cost", {**window, "time_bucket_width": "1d","group_by[]": GROUP_BY + ["cost_type", "token_type"]}):g, c = row["group"], row["cost"]events.append({"event.type": "anthropic.cost","event.provider": "claude-platform","bucket.start": row["starting_at"],"bucket.end": row["ending_at"],"gen_ai.provider.name": "anthropic","gen_ai.request.model": g["model"],"workspace_id": g["workspace_id"],"service_tier": g["service_tier"],"product": g["product"],"cost_type": g["cost_type"],"token_type": g["token_type"],"cost.usd": float(c["amount"]) / 100,"cost.list_usd": float(c["list_amount"]) / 100,})for i in range(0, len(events), 500):requests.post(DT_INGEST, headers=DT_HEADERS, json=events[i:i + 500], timeout=30).raise_for_status()print(f"Sent {len(events)} events")
An Admin API key has organization-wide admin access to your Claude Console organization.
Once you've imported the data, use DQL to query your usage and costs.
Daily cost by model and product:
fetch bizevents| filter event.type == "anthropic.cost"| summarize {cost_usd = sum(cost.usd),list_usd = sum(cost.list_usd)}, by: {day = toTimestamp(bucket.start), gen_ai.request.model, product}| sort day desc
Hourly tokens and requests by model:
fetch bizevents| filter event.type == "anthropic.usage"| summarize {requests = sum(requests),input = sum(uncached_input_tokens + cache_read_input_tokens + cache_creation_5m_input_tokens + cache_creation_1h_input_tokens),output = sum(output_tokens)}, by: {hour = bin(toTimestamp(bucket.start), 1h), gen_ai.request.model}| sort hour
To get billed cost, you need an Admin API key (Claude Console) or an Analytics API key (Claude Enterprise). There's no workspace-level alternative.
If you don't have one of these API keys, you can get token usage from your traces, but not billed cost.
Use DQL to query gen_ai.usage.input_tokens and gen_ai.usage.output_tokens:
fetch spans| filter gen_ai.provider.name == "anthropic" and isNotNull(gen_ai.usage.input_tokens)| summarize {input_tokens = sum(gen_ai.usage.input_tokens),output_tokens = sum(gen_ai.usage.output_tokens)}, by: {day = bin(start_time, 1d), gen_ai.request.model}| sort day desc
If you use API tracing and SDK tracing together, add and service.name == "claude-platform" to the filter to count each request once:
fetch spans| filter gen_ai.provider.name == "anthropic" and service.name == "claude-platform" and isNotNull(gen_ai.usage.input_tokens)| summarize {input_tokens = sum(gen_ai.usage.input_tokens),output_tokens = sum(gen_ai.usage.output_tokens)}, by: {day = bin(start_time, 1d), gen_ai.request.model}| sort day desc
AI Observability.anthropic or by your model to find Claude requests.anthropic.messages.create, anthropic.model_request, and tool spans and their gen_ai.* attributes.The Agents topology view shows how your applications, the claude-platform service, the anthropic model provider, and the Claude models connect.

API spans arrive with service.name set to claude-platform.
You can also query them in Distributed Tracing or with DQL:
fetch spans| filter service.name == "claude-platform"| fields start_time, span.name, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, duration

When a request has both an SDK span (CLIENT) and an API span (SERVER), both carry token usage.
To count tokens once, sum only the API spans. For details, see Counting tokens.