# Instrument a Python Application on Amazon ECS with ADOT Wire the ADOT Python auto-instrumentation SDK into an ECS task definition (Fargate or EC2 launch type). An init container copies the SDK into a shared volume at task startup, so the application image is not rebuilt and the application source is not touched. Read [instrumentation.md](instrumentation.md) first — it defines what is out of scope (OTLP endpoints, Application Signals). Deploying a collector is a separate, optional step — [collector-ecs.md](collector-ecs.md). ## Prerequisites - An existing ECS service running a Python application - The task definition available to edit — as CDK, Terraform, CloudFormation, or raw JSON ## Critical Requirements **Do NOT:** - Add a CloudWatch Agent sidecar container, or any collector sidecar — **unless you are also deploying a collector** ([collector-ecs.md](collector-ecs.md)), where the CloudWatch Agent sidecar is the primary collector - Set `OTEL_EXPORTER_OTLP_*` — leave the SDK's default endpoint in place — **unless you are also deploying a collector** ([collector-ecs.md](collector-ecs.md)), which sets `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_PROTOCOL` deliberately - Set `OTEL_AWS_APPLICATION_SIGNALS_*`, `OTEL_AWS_SERVICE_EVENTS_*`, or `OTEL_AWS_DYNAMIC_INSTRUMENTATION_*` - Add `CloudWatchAgentServerPolicy` or `AWSXRayDaemonWriteAccess` to the task role — **unless you are also deploying a collector** ([collector-ecs.md](collector-ecs.md)), which requires `CloudWatchAgentServerPolicy` on the **task** role - Run `cdk deploy`, `terraform apply`, or `aws ecs update-service` automatically - Modify the application's `.py` files **On IAM:** instrumentation on its own needs no permissions, so this change adds none. The task may later need permission to reach wherever telemetry is sent — that belongs with the destination, not this guide. If you deploy a collector instead ([collector-ecs.md](collector-ecs.md)), the collector is what gets the IAM — the workload still gets none. ## Step 1: Add a shared volume to the task definition ```typescript const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', { // ... existing configuration unchanged ... volumes: [ { name: 'opentelemetry-auto-instrumentation-python' }, ], }); ``` A bind mount (a volume with only a `name`) is all that is needed — no EFS, no host path. ## Step 2: Add the ADOT init container ```typescript const initContainer = taskDefinition.addContainer('init', { // Look up the latest tag — see instrumentation.md image: ecs.ContainerImage.fromRegistry('public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.19.0'), essential: false, memoryReservationMiB: 64, cpu: 32, command: ['cp', '-a', '/autoinstrumentation/.', '/otel-auto-instrumentation-python'], logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'init-', logGroup: serviceLogGroup, }), }); initContainer.addMountPoints({ sourceVolume: 'opentelemetry-auto-instrumentation-python', containerPath: '/otel-auto-instrumentation-python', readOnly: false, }); ``` `essential: false` matters — the init container exits after the copy, and an essential container exiting would stop the whole task. ## Step 3: Configure the application container ```typescript const mainContainer = taskDefinition.addContainer('-container', { // ... existing image, ports, health check unchanged ... environment: { // ... existing environment variables preserved ... PYTHONPATH: '/otel-auto-instrumentation-python/opentelemetry/instrumentation/auto_instrumentation:/otel-auto-instrumentation-python', OTEL_SERVICE_NAME: '', }, }); mainContainer.addMountPoints({ sourceVolume: 'opentelemetry-auto-instrumentation-python', containerPath: '/otel-auto-instrumentation-python', readOnly: false, }); ``` ### PYTHONPATH must be prepended, not replaced If the container **already sets** `PYTHONPATH`, keep its existing value in the middle so the instrumentation paths are prepended rather than overwriting it: ``` /otel-auto-instrumentation-python/opentelemetry/instrumentation/auto_instrumentation::/otel-auto-instrumentation-python ``` If the container does not set `PYTHONPATH` today, use the two-segment form shown above. Check the existing container environment before writing this value — silently dropping an existing `PYTHONPATH` will break the application's imports. Set `OTEL_SERVICE_NAME` from the existing ECS service or container name. Optionally add `OTEL_RESOURCE_ATTRIBUTES` for attributes such as `deployment.environment=production`. ## Step 4: Make the application wait for the init container ```typescript mainContainer.addContainerDependencies({ container: initContainer, condition: ecs.ContainerDependencyCondition.SUCCESS, }); ``` Without this, the application can start before the SDK finishes copying and `PYTHONPATH` will point at directories that do not exist yet. ## Raw task definition JSON If the task definition is managed as JSON (or through Terraform's `container_definitions`), the same four pieces are: **Terraform note:** in `aws_ecs_task_definition`, `container_definitions` is `jsonencode` of **only the containers array** — `volumes` is a sibling HCL block, not a key inside that JSON. Pasting the whole object below into `jsonencode(...)` silently drops the volume, after which every mount path is missing and the app starts uninstrumented: ```hcl resource "aws_ecs_task_definition" "app" { # ... volume { name = "opentelemetry-auto-instrumentation-" } container_definitions = jsonencode([ /* the containers array only */ ]) } ``` ```json { "volumes": [{ "name": "opentelemetry-auto-instrumentation-python" }], "containerDefinitions": [ { "name": "init", "image": "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.19.0", "essential": false, "command": ["cp", "-a", "/autoinstrumentation/.", "/otel-auto-instrumentation-python"], "mountPoints": [ { "sourceVolume": "opentelemetry-auto-instrumentation-python", "containerPath": "/otel-auto-instrumentation-python", "readOnly": false } ] }, { "name": "-container", "environment": [ { "name": "PYTHONPATH", "value": "/otel-auto-instrumentation-python/opentelemetry/instrumentation/auto_instrumentation:/otel-auto-instrumentation-python" }, { "name": "OTEL_SERVICE_NAME", "value": "" } ], "mountPoints": [ { "sourceVolume": "opentelemetry-auto-instrumentation-python", "containerPath": "/otel-auto-instrumentation-python", "readOnly": false } ], "dependsOn": [{ "containerName": "init", "condition": "SUCCESS" }] } ] } ``` ## Note on pre-fork servers Gunicorn with its default sync workers and no `--preload` needs no special handling — instrumentation loads and produces both server spans and nested client spans normally. The configurations that more often need attention are `--preload` and async worker classes (gevent, eventlet). If the workload uses one of those and no telemetry appears, the usual remedy is a Gunicorn `post_fork` hook that re-initializes the SDK — flag that to the user rather than changing the server configuration silently. ## Verify After the user deploys and tasks recycle: ```bash # The init container should have exited 0 aws ecs describe-tasks --cluster --tasks \ --query 'tasks[0].containers[?name==`init`].[name,lastStatus,exitCode]' ``` Then check the application container's CloudWatch Logs. **ADOT Python prints no line containing "opentelemetry"** — the real startup signal is `Configuration of aws_configurator not loaded, configurator already loaded`. Until a receiver exists at the default OTLP endpoint, exporter connection errors are expected — that is the next step, not a failure of instrumentation. ## Completion **Tell the user:** "I've wired ADOT Python auto-instrumentation into your ECS task definition. **Changes:** - Added a bind mount volume, `opentelemetry-auto-instrumentation-python` - Added a non-essential `init` container that copies the ADOT Python SDK into that volume - Added `PYTHONPATH` (prepended to any existing value) and `OTEL_SERVICE_NAME` to the application container, plus the volume mount and a `SUCCESS` dependency on `init` **Not changed:** your application image, your application source, the task role's IAM policies, and the service's sidecars — no CloudWatch Agent or collector was added. **Next steps:** 1. Review the diff — confirm the `PYTHONPATH` value preserves any path the container already relied on. 2. Deploy and let the tasks recycle. 3. **Telemetry has nowhere to go yet.** No OTLP endpoint was configured, so the SDK is using its default (`localhost:4318`). Two ways to fix that: deploy an OTel Collector alongside it and export to that ([collector-ecs.md](collector-ecs.md)), or point `OTEL_EXPORTER_OTLP_ENDPOINT` at an OTLP endpoint you already have. Let me know if you'd like adjustments before you deploy."