diff --git a/03-integrations/3p-observability/dash0/.env.example b/03-integrations/3p-observability/dash0/.env.example new file mode 100644 index 00000000..54583086 --- /dev/null +++ b/03-integrations/3p-observability/dash0/.env.example @@ -0,0 +1,16 @@ +# Dash0 credentials — get from https://app.dash0.com → Settings → Auth Tokens +DASH0_AUTH_TOKEN=your-dash0-auth-token + +# OTLP ingress base URL for your region — get from Settings → Endpoints +# US West 2: https://ingress.us-west-2.aws.dash0.com +# EU West 1: https://ingress.eu-west-1.aws.dash0.com +DASH0_OTLP_ENDPOINT=https://ingress.us-west-2.aws.dash0.com + +# Dataset to route telemetry to (default works for most setups) +DASH0_DATASET=default + +# Service name visible in Dash0 tracing, metrics, and logs +OTEL_SERVICE_NAME=agentcore-travel-agent + +# AWS region +AWS_DEFAULT_REGION=us-east-1 diff --git a/03-integrations/3p-observability/dash0/README.md b/03-integrations/3p-observability/dash0/README.md new file mode 100644 index 00000000..9c17db04 --- /dev/null +++ b/03-integrations/3p-observability/dash0/README.md @@ -0,0 +1,68 @@ +# AgentCore + Dash0 observability + +Deploy a Strands travel agent to AgentCore Runtime with traces, metrics, and logs sent to [Dash0](https://www.dash0.com/) via OTLP HTTP. + +## Architecture + +``` +AgentCore runtime → utils/travel_agent.py + └── OTel SDK + ├── OTLPSpanExporter → {DASH0_OTLP_ENDPOINT}/v1/traces + ├── OTLPMetricExporter → {DASH0_OTLP_ENDPOINT}/v1/metrics + └── OTLPLogExporter → {DASH0_OTLP_ENDPOINT}/v1/logs + headers: Authorization: Bearer , Dash0-Dataset: + └── Dash0 → Tracing, Metrics, Logs +``` + +`DISABLE_ADOT_OBSERVABILITY=true` bypasses the default CloudWatch ADOT pipeline so Dash0 receives all telemetry. + +## Prerequisites + +- Python 3.10+, [uv](https://docs.astral.sh/uv/) +- AWS credentials configured +- [Dash0 account](https://www.dash0.com/) + +## Quick Start + +```bash +pip install bedrock-agentcore boto3 python-dotenv +cp .env.example .env +# Edit .env: set DASH0_AUTH_TOKEN and DASH0_OTLP_ENDPOINT for your region +python deploy.py +python invoke.py +# View telemetry: https://app.dash0.com → Tracing / Metrics / Logs +python cleanup.py +``` + +## Dash0 Regions + +| Region | DASH0_OTLP_ENDPOINT | +|:-------|:--------------------| +| US West 2 (default) | `https://ingress.us-west-2.aws.dash0.com` | +| EU West 1 | `https://ingress.eu-west-1.aws.dash0.com` | + +> Find your endpoint at **app.dash0.com → Settings → Endpoints**. + +## Environment Variables + +| Variable | Description | Default | +|:---------|:------------|:--------| +| `DASH0_AUTH_TOKEN` | Auth token from **Settings → Auth Tokens** | _(required)_ | +| `DASH0_OTLP_ENDPOINT` | OTLP ingress base URL for your region | `https://ingress.us-west-2.aws.dash0.com` | +| `DASH0_DATASET` | Dataset to route telemetry to | `default` | +| `OTEL_SERVICE_NAME` | Service name shown in Dash0 | `agentcore-travel-agent` | + +## Files + +| File | Description | +|:-----|:------------| +| `utils/travel_agent.py` | Agent with Dash0 OTel setup (traces, metrics, logs) | +| `deploy.py` | Deploys to AgentCore Runtime with Dash0 env vars | +| `invoke.py` | Invokes the deployed agent with sample travel prompts | +| `cleanup.py` | Deletes all created AWS resources | + +## Additional Resources + +- [Dash0 Documentation](https://dash0.com/docs) +- [Dash0 Endpoints glossary](https://dash0.com/docs/dash0/miscellaneous/glossary/endpoints) +- [AgentCore observability](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-configure.html) diff --git a/03-integrations/3p-observability/dash0/cleanup.py b/03-integrations/3p-observability/dash0/cleanup.py new file mode 100644 index 00000000..b5734314 --- /dev/null +++ b/03-integrations/3p-observability/dash0/cleanup.py @@ -0,0 +1,53 @@ +"""Delete all resources created by deploy.py. Reads runtime_config.json.""" + +import json +import time +import boto3 + +with open("runtime_config.json") as f: + config = json.load(f) + +region = config["region"] +runtime_id = config["runtime_id"] + +control = boto3.client("bedrock-agentcore-control", region_name=region) +iam = boto3.client("iam", region_name=region) +s3 = boto3.client("s3", region_name=region) + +# Delete endpoint +print("Deleting endpoint 'default'...") +try: + control.delete_agent_runtime_endpoint(agentRuntimeId=runtime_id, name="default") + time.sleep(5) +except Exception as e: + print(f" {e}") + +# Delete runtime +print(f"Deleting runtime {runtime_id}...") +try: + control.delete_agent_runtime(agentRuntimeId=runtime_id) +except Exception as e: + print(f" {e}") + +# Delete IAM role +role_name = config.get("role_name") +if role_name: + print(f"Deleting IAM role {role_name}...") + try: + for policy in iam.list_role_policies(RoleName=role_name).get("PolicyNames", []): + iam.delete_role_policy(RoleName=role_name, PolicyName=policy) + iam.delete_role(RoleName=role_name) + except Exception as e: + print(f" {e}") + +# Delete S3 objects +s3_bucket = config.get("s3_bucket") +s3_prefix = config.get("s3_prefix") +if s3_bucket and s3_prefix: + print(f"Deleting s3://{s3_bucket}/{s3_prefix}...") + try: + s3.delete_object(Bucket=s3_bucket, Key=s3_prefix) + except Exception as e: + print(f" {e}") + +print("Cleanup complete.") diff --git a/03-integrations/3p-observability/dash0/deploy.py b/03-integrations/3p-observability/dash0/deploy.py new file mode 100644 index 00000000..3ca41380 --- /dev/null +++ b/03-integrations/3p-observability/dash0/deploy.py @@ -0,0 +1,234 @@ +""" +Deploy the Travel Agent with Dash0 observability to AgentCore Runtime. + +Usage: + cp .env.example .env # fill in your Dash0 auth token + python deploy.py +""" + +import json +import os +import shutil +import subprocess +import sys +import time + +import boto3 +from boto3.session import Session +from dotenv import load_dotenv + +load_dotenv() + +AGENT_NAME = f"dash0_obs_{int(time.time()) % 100000}" +PROTOCOL = "HTTP" +PYTHON_RUNTIME = "PYTHON_3_13" +ENTRY_POINT = "travel_agent.py" +AGENT_FILES = ["utils/travel_agent.py"] + +PLATFORM_ENV_VARS = { + "DASH0_AUTH_TOKEN": os.getenv("DASH0_AUTH_TOKEN", ""), + "DASH0_OTLP_ENDPOINT": os.getenv("DASH0_OTLP_ENDPOINT", "https://ingress.us-west-2.aws.dash0.com"), + "DASH0_DATASET": os.getenv("DASH0_DATASET", "default"), + "OTEL_SERVICE_NAME": os.getenv("OTEL_SERVICE_NAME", "agentcore-travel-agent"), + "DISABLE_ADOT_OBSERVABILITY": "true", + "BEDROCK_MODEL_ID": "global.anthropic.claude-haiku-4-5-20251001-v1:0", +} + +session = Session() +REGION = session.region_name +ACCOUNT_ID = session.client("sts").get_caller_identity()["Account"] +S3_BUCKET = f"agentcore-code-{ACCOUNT_ID}-{REGION}" +S3_PREFIX = f"{AGENT_NAME}/code.zip" + +print(f"Region: {REGION}\nAccount: {ACCOUNT_ID}\nAgent: {AGENT_NAME}") + + +def create_execution_role() -> str: + iam = boto3.client("iam", region_name=REGION) + role_name = f"agentcore-{AGENT_NAME}-role" + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:SourceAccount": ACCOUNT_ID}}, + } + ], + } + inline_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:DescribeLogStreams", "logs:CreateLogGroup"], + "Resource": [f"arn:aws:logs:{REGION}:{ACCOUNT_ID}:log-group:/aws/bedrock-agentcore/runtimes/*"], + }, + { + "Effect": "Allow", + "Action": ["logs:DescribeLogGroups"], + "Resource": [f"arn:aws:logs:{REGION}:{ACCOUNT_ID}:log-group:*"], + }, + { + "Effect": "Allow", + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": [ + f"arn:aws:logs:{REGION}:{ACCOUNT_ID}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*" + ], + }, + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ], + "Resource": [ + "arn:aws:bedrock:*::foundation-model/*", + f"arn:aws:bedrock:{REGION}:{ACCOUNT_ID}:*", + ], + }, + ], + } + try: + resp = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust_policy), + Description=f"Execution role for {AGENT_NAME}", + ) + role_arn = resp["Role"]["Arn"] + print(f"\nCreated IAM role: {role_arn}") + except iam.exceptions.EntityAlreadyExistsException: + role_arn = f"arn:aws:iam::{ACCOUNT_ID}:role/{role_name}" + print(f"\nIAM role exists: {role_arn}") + iam.put_role_policy( + RoleName=role_name, + PolicyName=f"{AGENT_NAME}-policy", + PolicyDocument=json.dumps(inline_policy), + ) + time.sleep(10) + return role_arn + + +def build_and_upload_package(): + s3 = boto3.client("s3", region_name=REGION) + pkg_dir, zip_file = "deployment_package", "deployment_package.zip" + try: + if REGION == "us-east-1": + s3.create_bucket(Bucket=S3_BUCKET) + else: + s3.create_bucket( + Bucket=S3_BUCKET, + CreateBucketConfiguration={"LocationConstraint": REGION}, + ) + except (s3.exceptions.BucketAlreadyOwnedByYou, s3.exceptions.BucketAlreadyExists): + pass + if os.path.isdir(pkg_dir): + shutil.rmtree(pkg_dir) + if os.path.exists(zip_file): + os.remove(zip_file) + subprocess.run( + [ + "uv", + "pip", + "install", + "--python-platform", + "aarch64-manylinux2014", + "--python-version", + "3.13", + "--target", + pkg_dir, + "--only-binary", + ":all:", + "-r", + "requirements.txt", + ], + check=True, + ) + subprocess.run( + ["zip", "-r", f"../{zip_file}", "."], + cwd=pkg_dir, + check=True, + capture_output=True, + ) + for src_file in AGENT_FILES: + subprocess.run(["zip", zip_file, "-j", src_file], check=True, capture_output=True) + s3.upload_file(zip_file, S3_BUCKET, S3_PREFIX) + shutil.rmtree(pkg_dir) + os.remove(zip_file) + print(f" Package uploaded to s3://{S3_BUCKET}/{S3_PREFIX}") + + +def create_runtime(role_arn: str) -> dict: + control = boto3.client("bedrock-agentcore-control", region_name=REGION) + response = control.create_agent_runtime( + agentRuntimeName=AGENT_NAME, + agentRuntimeArtifact={ + "codeConfiguration": { + "code": {"s3": {"bucket": S3_BUCKET, "prefix": S3_PREFIX}}, + "runtime": PYTHON_RUNTIME, + "entryPoint": [ENTRY_POINT], + } + }, + roleArn=role_arn, + networkConfiguration={"networkMode": "PUBLIC"}, + protocolConfiguration={"serverProtocol": PROTOCOL}, + environmentVariables=PLATFORM_ENV_VARS, + description="Travel agent with Dash0 observability", + ) + runtime_id, runtime_arn = response["agentRuntimeId"], response["agentRuntimeArn"] + print(f" Runtime created: {runtime_id}") + while True: + status_resp = control.get_agent_runtime(agentRuntimeId=runtime_id) + status = status_resp["status"] + print(f" Status: {status}") + if status == "READY": + break + if status in ("CREATE_FAILED", "UPDATE_FAILED"): + sys.exit(1) + time.sleep(15) + return {"runtime_id": runtime_id, "runtime_arn": runtime_arn} + + +def create_endpoint(runtime_id: str): + control = boto3.client("bedrock-agentcore-control", region_name=REGION) + control.create_agent_runtime_endpoint(agentRuntimeId=runtime_id, name="default") + while True: + for ep in control.list_agent_runtime_endpoints(agentRuntimeId=runtime_id).get("runtimeEndpoints", []): + if ep["name"] == "default": + print(f" Status: {ep['status']}") + if ep["status"] == "READY": + return ep + if ep["status"] in ("CREATE_FAILED", "UPDATE_FAILED"): + sys.exit(1) + time.sleep(15) + + +def main(): + if not PLATFORM_ENV_VARS.get("DASH0_AUTH_TOKEN"): + print("ERROR: DASH0_AUTH_TOKEN not set. Copy .env.example → .env and fill in your credentials.") + sys.exit(1) + print("=" * 60) + print("Deploying Travel Agent with Dash0 Observability") + print("=" * 60) + role_arn = create_execution_role() + build_and_upload_package() + runtime = create_runtime(role_arn) + create_endpoint(runtime["runtime_id"]) + config = { + "agent_name": AGENT_NAME, + "runtime_id": runtime["runtime_id"], + "runtime_arn": runtime["runtime_arn"], + "region": REGION, + "role_name": f"agentcore-{AGENT_NAME}-role", + "s3_bucket": S3_BUCKET, + "s3_prefix": S3_PREFIX, + } + with open("runtime_config.json", "w") as f: + json.dump(config, f, indent=2) + print(f"\nDeployment complete! Runtime ARN: {runtime['runtime_arn']}") + print("Next: python invoke.py | Open app.dash0.com → Tracing") + + +if __name__ == "__main__": + main() diff --git a/03-integrations/3p-observability/dash0/invoke.py b/03-integrations/3p-observability/dash0/invoke.py new file mode 100644 index 00000000..4db9934b --- /dev/null +++ b/03-integrations/3p-observability/dash0/invoke.py @@ -0,0 +1,29 @@ +"""Invoke the deployed travel agent. Reads runtime_config.json written by deploy.py.""" + +import json +import boto3 + +with open("runtime_config.json") as f: + config = json.load(f) + +client = boto3.client("bedrock-agentcore", region_name=config["region"]) + +prompts = [ + "I'm planning a weekend trip to Kyoto in spring. What are the must-visit places?", + "What are the best beaches in Thailand for a budget traveler?", + "Suggest a 5-day itinerary for first-time visitors to Paris.", +] + +for prompt in prompts: + print(f"\nPrompt: {prompt}") + print("-" * 60) + response = client.invoke_agent_runtime( + agentRuntimeArn=config["runtime_arn"], + qualifier="DEFAULT", + payload=json.dumps({"prompt": prompt}).encode(), + ) + body = response["response"].read().decode() + try: + print(json.loads(body)) + except json.JSONDecodeError: + print(body) diff --git a/03-integrations/3p-observability/dash0/requirements.txt b/03-integrations/3p-observability/dash0/requirements.txt new file mode 100644 index 00000000..54d1bad3 --- /dev/null +++ b/03-integrations/3p-observability/dash0/requirements.txt @@ -0,0 +1,8 @@ +bedrock-agentcore>=1.5.0 +boto3 +strands-agents[otel] +strands-agents-tools +opentelemetry-sdk +opentelemetry-exporter-otlp-proto-http +ddgs +python-dotenv diff --git a/03-integrations/3p-observability/dash0/utils/travel_agent.py b/03-integrations/3p-observability/dash0/utils/travel_agent.py new file mode 100644 index 00000000..029be664 --- /dev/null +++ b/03-integrations/3p-observability/dash0/utils/travel_agent.py @@ -0,0 +1,135 @@ +""" +Travel agent for AgentCore Runtime with Dash0 observability. + +Configures OTel TracerProvider, MeterProvider, and LoggerProvider exporting +traces, metrics, and logs to Dash0's OTLP HTTP endpoint via Bearer token auth. +DISABLE_ADOT_OBSERVABILITY=true bypasses CloudWatch so Dash0 receives the telemetry. + +Required env vars (set via deploy.py → create_agent_runtime environmentVariables): + DASH0_AUTH_TOKEN — Auth token from Settings → Auth Tokens + DASH0_OTLP_ENDPOINT — OTLP ingress base URL (default: https://ingress.us-west-2.aws.dash0.com) + DASH0_DATASET — Dataset name (default: default) + OTEL_SERVICE_NAME — Service name visible in Dash0 + DISABLE_ADOT_OBSERVABILITY — must be "true" +""" + +import logging +import os + +logging.basicConfig(level=logging.ERROR, format="[%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) +logger.setLevel(os.getenv("AGENT_RUNTIME_LOG_LEVEL", "INFO").upper()) + +# ── Dash0 OTel Setup ─────────────────────────────────────────────────────────── +# Must be configured BEFORE any other OTel imports. + +auth_token = os.environ.get("DASH0_AUTH_TOKEN", "") +otlp_base = os.environ.get("DASH0_OTLP_ENDPOINT", "https://ingress.us-west-2.aws.dash0.com").rstrip("/") +dataset = os.environ.get("DASH0_DATASET", "default") +service_name = os.environ.get("OTEL_SERVICE_NAME", "agentcore-travel-agent") + +if auth_token: + from opentelemetry import metrics, trace + from opentelemetry._logs import set_logger_provider + from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + from opentelemetry.sdk._logs import LoggingHandler + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + + headers = {"Authorization": f"Bearer {auth_token}", "Dash0-Dataset": dataset} + resource = Resource.create({"service.name": service_name}) + + # Traces + trace_provider = TracerProvider(resource=resource) + trace_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint=f"{otlp_base}/v1/traces", headers=headers)) + ) + trace.set_tracer_provider(trace_provider) + + # Metrics + metrics.set_meter_provider( + MeterProvider( + resource=resource, + metric_readers=[ + PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=f"{otlp_base}/v1/metrics", headers=headers)) + ], + ) + ) + + # Logs + log_provider = LoggerProvider(resource=resource) + log_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=f"{otlp_base}/v1/logs", headers=headers)) + ) + set_logger_provider(log_provider) + logging.getLogger().addHandler(LoggingHandler(level=logging.NOTSET, logger_provider=log_provider)) + + logger.info( + "Dash0 OTel configured (service: %s, dataset: %s, endpoint: %s)", + service_name, + dataset, + otlp_base, + ) +else: + logger.warning("DASH0_AUTH_TOKEN not set — telemetry will not be sent to Dash0") + +# ── Agent ────────────────────────────────────────────────────────────────────── + +from bedrock_agentcore.runtime import BedrockAgentCoreApp # noqa: E402 +from ddgs import DDGS # noqa: E402 +from strands import Agent, tool # noqa: E402 +from strands.models import BedrockModel # noqa: E402 + +app = BedrockAgentCoreApp() + + +@tool +def web_search(query: str) -> str: + """Search the web for current travel information.""" + try: + ddgs = DDGS() + results = ddgs.text(query, max_results=5) + formatted = [] + for i, r in enumerate(results, 1): + formatted.append( + f"{i}. {r.get('title', 'No title')}\n" + f" {r.get('body', 'No summary')}\n" + f" Source: {r.get('href', 'No URL')}\n" + ) + return "\n".join(formatted) if formatted else "No results found." + except Exception as e: + return f"Search error: {str(e)}" + + +def create_agent(): + model_id = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-haiku-4-5-20251001-v1:0") + region = os.getenv("AWS_DEFAULT_REGION", "us-east-1") + model = BedrockModel(model_id=model_id, region_name=region, temperature=0.0, max_tokens=1024) + return Agent( + model=model, + system_prompt=( + "You are an experienced travel agent. Use web_search for destination research " + "and provide concise, well-sourced recommendations." + ), + tools=[web_search], + ) + + +@app.entrypoint +def invoke(payload, context=None): + user_input = payload.get("prompt", "") + logger.info("[%s] %s", getattr(context, "session_id", "local"), user_input) + agent = create_agent() + response = agent(user_input) + return response.message["content"][0]["text"] + + +if __name__ == "__main__": + app.run() diff --git a/03-integrations/README.md b/03-integrations/README.md index 6c09e48d..2bab0326 100644 --- a/03-integrations/README.md +++ b/03-integrations/README.md @@ -40,6 +40,7 @@ This folder contains framework and protocol integrations that demonstrate how to * **[Arize](./observability/arize/)**: LLM observability and evaluation with Arize Phoenix * **[Braintrust](./observability/braintrust/)**: AI evaluation and observability platform integration * **[Datadog](./observability/datadog/)**: Infrastructure and LLM monitoring with Datadog +* **[Dash0](./3p-observability/dash0/)**: OpenTelemetry-native observability platform with traces, metrics, and logs via OTLP HTTP * **[Dynatrace](./observability/dynatrace/)**: Application performance monitoring integration with travel agent example * **[Honeycomb](./observability/honeycomb/)**: Distributed tracing and observability with Honeycomb * **[Instana](./observability/instana/)**: IBM Instana application performance monitoring diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index d44da7e2..e0723ce6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -118,6 +118,7 @@ - Cristiano Scandura (scandura) - palbiren - Gui Ruggiero (guiruggiero) +- Julia Furst Morgado (juliafmorgado) - Visakh Madathil (vmmadathil) - JobRamos (jobdram) - Will Matos (wilmatos)