AWS Observability
AWSOfficialPublished by AWS
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides comprehensive domain expertise for AWS observability, covering both classic CloudWatch and CloudWatch Omni. It assists users in investigating service health, querying logs and traces, configuring alerts, and managing dashboards. The skill is designed to help operators distinguish between the two services, navigate their respective APIs, and perform advanced tasks like agent quality evaluation and context graph analysis. It does not handle initial service setup or onboarding, which are managed by a separate dedicated skill.
What it does
- Investigates root causes using Omni context graphs and intelligence insights.
- Queries logs, traces, and metrics using SQL, PromQL, and Log Insights.
- Configures and tunes alerts, dashboards, and alarms for both CloudWatch and Omni.
- Evaluates AI agent quality through trace scoring and online evaluation.
- Provides methodology for service-health investigations and dependency analysis.
- Supports programmatic access via AWS CLI, SDKs, and CloudFormation.
When to use it
- When you need to investigate a service performance issue or root cause.
- When you need to write or debug queries for logs, traces, or metrics.
- When you need to create or update alerts and dashboards in CloudWatch or Omni.
- When you need to evaluate the quality of an AI agent using trace telemetry.
- When you need to understand service dependencies and blast radius.
Advantages
- Provides clear routing between CloudWatch and Omni based on service signals.
- Offers structured methodologies for complex observability tasks.
- Includes specific checklists to ensure all required configuration facts are surfaced.
- Maintains a strict scope to ensure accurate and relevant technical guidance.
- Supports advanced observability features like dynamic instrumentation and agent evaluation.
Usage examples
- “Why is my service experiencing high latency?”
- The agent will guide you through a service-health investigation, starting with relevant telemetry signals and checking for configuration issues like timeouts.
- “Create an Omni alert for high error rates.”
- The agent will provide the necessary authoring guidance, ask for your specific threshold and evaluation period, and confirm the configuration before proceeding.
- “How do I query my traces in Omni?”
- The agent will explain the SQL syntax for traces.default and provide the necessary fields to identify slow or failed spans.
- “Evaluate the quality of my AI agent's recent sessions.”
- The agent will help you select an appropriate evaluator and guide you through the process of scoring traces to assess agent performance.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/aws/aws-observability/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip aws-observability.zip -d .agents/skills
Documentation by AWS
AWS Observability
Overview
Domain expertise for AWS observability across metrics, logs, and traces, for two products that share the CloudWatch name but are separate services with separate control planes, data models, and APIs:
| CloudWatch | CloudWatch Omni | |
|---|---|---|
| What it is | Log groups, metric namespaces, alarms, Log Insights, X-Ray, Application Signals | Application Observability / Agent Observability. A Space per account per Region is the access boundary over the account's CloudWatch Dataset (OpenTelemetry logs, traces, and metrics); the Dataset is a CloudWatch resource the Space reads, not something the Space contains |
| Control plane | aws cloudwatch, aws logs, aws xray, aws application-signals | aws cloudwatchomni (endpoint prefix cloudwatch-omni, signing name cloudwatch) |
| Query | Log Insights query language; GetMetricData | SQL over logs.default / traces.default; PromQL over metrics; named views |
| Notify | Alarms (metric, composite, anomaly) | Alerts (SQL/PromQL rule, contributors, OK/WARNING/CRITICAL/NODATA) |
| Topology | Application Signals service map | Context graph (GetContextGraph) |
| Access | IAM only | Domain → Space → access grants and access profiles (set up in setting-up-cloudwatch-observability) |
| Only here | Dynamic Instrumentation, Synthetics canaries, CloudTrail auditing, EMF | Agent-quality evaluation, views, context graph |
| First-time setup | Application Signals onboarding — ADOT auto-instrumentation, the amazon-cloudwatch-observability add-on, ServiceEvents, CI/CD metadata | Domain → Space → grants → telemetry in → plain-ADOT instrumentation |
| References | references/cloudwatch/ | references/cloudwatch-omni/ |
This skill covers using both products, never setting either one up. Both first-time
setup paths above live in setting-up-cloudwatch-observability (same two-folder split);
route there rather than describing an onboarding procedure from memory.
Enabling Omni does not replace CloudWatch; log groups, metrics, and alarms keep working, and most customers use both.
Works best with the AWS MCP server — enables running CLI commands, querying CloudWatch, and validating configurations directly. All guidance also works with standard AWS CLI access.
Note: Reference files contain specific runtime versions, quota values, and feature matrices that may change. When precision matters (e.g. deploying to production, choosing a runtime, or checking a quota), confirm values against current AWS documentation rather than relying solely on the values in these files.
Scope guard — is this an observability request?
This skill owns the scope decision for every request routed to it, and this section is its sole home.
If a request is NOT about AWS observability with CloudWatch or CloudWatch Omni (weather, trivia, general chit-chat, non-observability coding), decline it in one sentence: state plainly that it is out of scope, never fabricate an answer, and never claim a false capability limitation (no "no internet access", no "no weather data") — the reason is simply that it is out of scope. Then redirect by naming what this skill does cover: querying CloudWatch and CloudWatch Omni logs, traces, and metrics, building dashboards, configuring alerts, investigating a service, or agent evaluation.
Do not attempt the off-topic task, and do not call a tool or run a query in pursuit of it. Keep it brief: no lecture, no long refusal.
Step 0 — CloudWatch or CloudWatch Omni?
Decide this before routing. The natural wording ("set up an alert for high latency", "build a dashboard", "query my logs") does not say which product the customer means.
The customer names the product — "Omni", "Application Observability", "Agent Observability", a Space, Domain, Dataset, access grant,
spaceId,cloudwatch-omni, Omni SQL, PromQL, views, context graph, evaluators, agent evaluation / scoring traces / online or continuous evaluation /gen_ai.evaluation, and OTel span vocabulary (traces.default,logs.default, spans,durationNano,status.code, resource attributes,service.name) → Omni. "Log Insights", "log group", "metric namespace", "CloudWatch alarm", "metric alarm", "composite alarm", "anomaly alarm", "X-Ray", "Application Signals", "canary", "CloudTrail", "Dynamic Instrumentation" → CloudWatch. A "log group" named inside an agent-evaluation request is the online-evaluation data source to verify, not a CloudWatch signal. A bare "alarm" (or "alert") with no other product signal is ambiguous — fall through to rule 3 and probe: a Space → Omni alert (alerts.md); no Space → CloudWatch alarm (cloudwatch/alarms.md). Exception: a "PromQL alarm" is a CloudWatch alarm on OTel metrics (cloudwatch/alarms.md) — "alarm" wins over "PromQL".Knowledge or how-to question ("what is an Omni alert", "does Omni have an API", "how do alerts differ from alarms") → answer from the reference files directly. Do not probe the account, and do not divert to the other product. Whether a capability exists is a fact about the product, not the account. concepts.md carries the full feature-equivalence matrix. 2a. Authoring / how-to alert request — "create / set up / write an Omni alert that fires when X", "how should I alert on Y" — with no Space or Region supplied and no go-ahead to actually create it, is a HOW-TO request. Deliver the authoring guidance (the alert row of "Must-state checklists" below, then alerts.md) FIRST, from the reference files. Do NOT stall on a Region/Space clarifying question and do NOT fall back to a CloudWatch alarm for a request that explicitly says "Omni alert". Probe the account only once the user supplies a Space/Region or asks you to create it. 2b. A telemetry object is already in the prompt — the user pastes, or the UI passes as context, a span, trace, log record, or query result and asks what it means, what errors it has, or how long it took. This is not a live-data request: do not probe the account, run a query, or ask the user to fetch it again. Read it in place per the "Reading a span or trace you already have" section of query/sql-logs-traces.md and state every item in its must-state list. 2c. A query request whose live result is empty or unreachable ("show me the slowest spans", "which traces failed") is still answered with the methodology — the exact query and the field rules that make it correct (see the must-state callouts in query/sql-logs-traces.md). Zero rows, a wrong-Region Space, or an unreachable endpoint is reported as a finding alongside the query, never as the whole answer.
Request that must act on live data, and the wording is ambiguous (not a Step 0.5 content question, which is answered from the catalog) → probe the target Region first:
aws cloudwatchomni list-domains aws cloudwatchomni list-spaces --region <target-region> --query "items[?region=='<target-region>']" # account-global; filter to the Regionlist-spacesis account-global (the--regionflag only selects the endpoint), so filter its result to the target Region as shown rather than reading a non-empty list as proof; a Space is one per account per Region. Seereferences/cloudwatch-omni/concepts.mdfor the full Region-probe rationale.A Domain and a Space in that Region (a non-empty filtered list) → Omni.
No Space in that Region (an empty filtered list) → CloudWatch: alarms → cloudwatch/alarms.md, dashboards → cloudwatch/dashboards.md, queries → cloudwatch/log-insights.md, metrics → cloudwatch/metrics.md. If the customer explicitly asked for Omni and has no Space, that is a first-time setup — see step 4.
The probe itself errors ("not yet supported", unknown service, endpoint does not resolve) → the CLI/SDK model in use lacks
cloudwatchomni. That is not evidence Omni is absent and must not be reported as "Omni is unavailable". The customer's installed AWS CLI/SDK most likely predates the service. If the request carried any Omni signal, give the customer the upgrade command to run (AWS CLI v2 reinstall orbrew upgrade awscli;pip install -U boto3 botocore) and have them re-runaws cloudwatchomni list-domains— exact steps in programmatic-access.md. Never run a package-manager upgrade or installer on the host yourself (brew,pip install -U,.pkg/MSI); it mutates the customer's machine beyond the request and can break unrelated tooling — hand over the command and continue with the guidance. Never substitute a CloudWatch or X-Ray command for an Omni request. If the request carried no Omni signal, do not block on the upgrade. When you are already going back to the customer for missing inputs (for example an alert's threshold and period), ask which product they want in that same question: say the Space probe could not run on this CLI, describe both options, and build neither until they answer. When the request is fully specified or says not to ask, proceed on the CloudWatch path (the pre-Omni default) and mention the upgrade only in passing.Still inconclusive → ask the customer.
First-time setup, on EITHER product → STOP and route to the
setting-up-cloudwatch-observabilityskill. This skill covers a service or Space that already reports and holds no onboarding procedure for either product, so there is nothing here to fall back on: not Omni setup (creating a Domain or Space, granting access, ingestion, forwarding, Slack, instrumenting an app or AI agent for a Space) and not CloudWatch setup (onboarding a service to Application Signals — ADOT auto-instrumentation, theamazon-cloudwatch-observabilityadd-on, monitored service, ServiceEvents, CI/CD metadata, the per-platform enablement guides). Any instrumentation / ADOT / collector request is a setup request either way; the setup skill owns the product decision too, so hand the whole request over rather than probinglist-spacesyourself. Application Signals once it is reporting — service map, alarms on its metrics, Dynamic Instrumentation debugging — belongs here.
Under-specified alert requests: When an alert or alarm request names what to watch (a symptom or a service) but not the inputs it needs — the threshold value and the evaluation period — ask for those rather than inventing them. Notifications are optional (per alerts.md), so ask for a notification destination only if the user wants to be notified. This holds on both paths (an Omni alert or a CloudWatch alarm).
Under-specified dashboard requests: When a dashboard request names what to show but not which metrics, panels, or layout, ground those against the data and confirm the panel set rather than inventing panels; a dashboard has no threshold, period, or notification. For ANY dashboard authoring/save request, also open dashboards.md and surface its "Facts you MUST surface when building or saving an Omni dashboard" checklist (see "Must-state checklists" below). Which signals or panels a named resource type needs is a Step 0.5 catalog question, not a dashboards-file question.
Step 0.5 — Service-health investigation (routing)
A large share of real questions are phrased as an operator symptom, not as a tool: "is my
<service> throttled / slow / erroring / unhealthy," "which of my <service>s are
<symptom>," "what's the health of my <service>," "what does <service> depend on and
which is broken," "what signals / what should be on a dashboard or view for <service>."
These are observability-data questions. Start from the telemetry, and answer with the
methodology (the correct signals, aggregation, scoping, and caveats) even when you also
pull live numbers and even when no matching resource exists in the account.
Telemetry shows the symptom. The cause is often a configured value, so when the question is
why something fails, also read the configuration of the resources involved, for example an
API Gateway integration timeout, an SQS visibility timeout, or a Lambda timeout or event
source mapping. A read-only or "logs only" role can usually still call these Get and
Describe APIs. Make the call and report AccessDenied if it happens.
Route by the symptom, then open the reference and surface every applicable item in its "facts you MUST surface" checklist — the checklist is the output contract, and it lives in the reference file, not here:
A metric symptom about one AWS service (throttling, latency, error rate, restarts, saturation, "what signals should it get", which signals or panels a named resource type needs on a dashboard or view) → query/promql-metrics.md. Open its "Service-health question — facts you MUST surface" section and surface every applicable item. Dashboard or view wording does not send this to either dashboards file (neither carries the signal facts) nor to rule 3's probe — it is a content question.
"What does
<service>depend on / what's broken downstream" / blast radius / who is affected / which direction do I walk the graph / what doCALLS,ACCESSES,RUNS_ONmean → context-graph.md. Open its "Dependency / blast-radius question — facts you MUST surface" section and surface every applicable item. A slowness or error symptom phrased in terms of the graph, dependencies, or edge types routes here, not to the metric bullet above.A health VERDICT for a service ("is
<service>healthy", "health summary of<service>") or a dependency map WITH ROLES ("what does<service>depend on and what role does each play", "which services call<service>") → service-health.md. Open its "Service health & dependency question — facts you MUST surface" section and surface every applicable item — it owns the combined verdict and the role-labelled dependency map, and defers the per-resource signal catalog and blast radius elsewhere. A bare single-service metric symptom, or a "what signals / panels does<resource type>need" or fleet-health-view ask, routes to the promql-metrics bullet above; a pure blast-radius / direction / edge-semantics question routes to the context-graph bullet.System status, a root-cause / triage symptom, or a past incident — "is anything wrong right now?", "what's wrong with
<service>", "why is<service>slow or erroring", "what changed since<time>", "users reported errors between X and Y — which service is the single most likely root cause", or any mention of insights, observations, or anomalies (a cause ask, not a single-signal health verdict or a dependency list) → intelligence.md: start from Omni Intelligence insights and observations, then use the context graph and queries to confirm. Its own "Scope and neighbours" section states which asks it hands back to the bullets above.Individual slow or failing spans / traces ("slowest spans for
<service>", "which traces failed") are trace SQL, not a metric aggregate → query/sql-logs-traces.md. Open its "Span Duration" and "Finding failed spans" sections and state every item in their must-state callouts — a request about a service's latency or error rate (an aggregate signal) is the PromQL bullet above instead.A failure being investigated from logs ("is this only failing for X?", "how many errors?", "what is the root cause?") → load the
aws-investigationskill and read itsreferences/completeness-and-root-cause.md. Read every page and every scope before counting, and check the root cause against every fact before reporting it.
Must-state checklists — the output contract
Four Omni tasks carry a checklist the answer text must carry, not just the plan. Open the reference and surface every item relevant to the request; the reference holds the per-item detail and is the source of truth, so do not restate it here.
| Task | Open | Section to surface |
|---|---|---|
| Authoring or advising on an Omni alert | alerts.md | "Facts you MUST surface when authoring an alert" |
| Scoring traces, choosing an evaluator, reading stored scores, online/continuous evaluation, evaluation datasets, turning scores into agent changes | agent-evaluation.md | every "tell the user ALL of this" callout |
| Exploring traces — one trace by id, comparing runs, finding traces by their spans, slowest traces, trace fields, agent sessions, token cost, what changed between two windows | query/trace-exploration.md | every "state … in the answer" callout |
| Authoring, saving, reading back, or debugging an Omni dashboard | dashboards.md | "Facts you MUST surface when building or saving an Omni dashboard" |
Routing — CloudWatch (references/cloudwatch/)
| User need | Action |
|---|---|
Enabling/onboarding a service to Application Signals (auto-instrumentation, the amazon-cloudwatch-observability add-on, monitored service), propagating ServiceEvents git/deployment metadata through CI/CD, or the per-platform × per-language enablement steps | STOP — this is first-time CloudWatch setup and no reference here covers it. Route to the setting-up-cloudwatch-observability skill |
| Writing Log Insights queries (pipe-delimited syntax: fields, filter, stats, sort, parse, display) | Read log-insights.md. If StartQuery rejects the window as before the log group's creation time, read the same window with FilterLogEvents. See Known issues 11 there |
| Configuring alarms (metric, composite, anomaly) | Read alarms.md. For an Omni alert, see the Omni table |
| Publishing custom metrics or using EMF | Read metrics.md |
| X-Ray / ADOT tracing behaviour — X-Ray-SDK-vs-ADOT choice, trace and segment structure, annotations vs metadata, sampling rules, collector pipeline config, X-Ray→OTel migration traps | Read tracing.md. Onboarding an un-instrumented service is the setup skill's job (see the first row) |
| Building CloudWatch dashboards (widget mechanics; which signals a given AWS service needs is Step 0.5) | Read dashboards.md |
| Debugging observability issues | Read troubleshooting.md — starts with the 5 most common fixes |
| Investigating a failure from logs: whether it affects one entity or the whole fleet, counts/totals/"no evidence of X", or deciding and reporting the root cause | Load the aws-investigation skill — completeness and scope first (references/completeness-and-root-cause.md), then root-cause analysis and the closing checklist |
| Debugging canary failures | Read synthetics.md — see Common failures table |
| CloudTrail operational auditing | Read cloudtrail.md |
| Setting up Lambda monitoring with CDK | Use alarm-template.ts as a starting point |
| Creating synthetic canaries | Read synthetics.md |
| Configuring ADOT collector | Use otel-config.yaml as a starting point |
| Debugging a running service with breakpoints/snapshots — Dynamic Instrumentation (modifies live services and captures live data) | Read dynamic-instrumentation.md in full before acting. Confirm with the user before any create/delete, and narrate before significant actions: observation → hypothesis → proposed action → expected result. Source inspection alone identifies hypotheses, not confirmed root causes; keep suspected causes tentative until runtime evidence confirms them. |
Routing — CloudWatch Omni (references/cloudwatch-omni/)
Rows that act on live Space data assume Step 0 found a Space. Knowledge questions are answered from the file directly.
| User need | Action |
|---|---|
| Concepts. What Omni is, what a Domain / Space / Dataset / grant / profile / view / alert / context graph is, whether a feature is Omni or CloudWatch, where setup starts | Read concepts.md |
Query logs or traces — SQL (SELECT … FROM logs.default / traces.default / default), field access, schema discovery, slowest / failed spans (durationNano, status.code), TABLESAMPLE | Read query/sql-logs-traces.md. For slowest or failed spans, state every item in its "Span Duration" / "Finding failed spans" must-state callouts, even when the live result is empty |
| Explore traces — open one trace by id (where the time went, why it failed, span tree, inputs/outputs, tokens), compare two runs, why a trace scored badly, find traces by their spans (tool calls per trace, a repeated tool, failed tool calls), the slowest traces today, what fields a trace carries, list or walk an agent's sessions (a looping session), token cost, guardrail or deploy questions, what changed (agent, model or downstream) | Read query/trace-exploration.md for the trace recipes and agent-span attributes, with query/sql-logs-traces.md for the SQL itself |
| A span, trace, or log record supplied in the prompt — "I have this span open, what errors are in it", a pasted telemetry object | Read the "Reading a span or trace you already have" section of query/sql-logs-traces.md. Answer from the object's own fields; do not probe, query, or ask the user to fetch it |
| Query metrics — PromQL, which metric answers which symptom per AWS service, why a metric is missing, gauge vs counter | Read query/promql-metrics.md. Metrics are PromQL, never SQL; run them directly through the CloudWatch Prometheus-compatible API (monitoring endpoint) or the CloudWatch MCP server's PromQL tools, not through StartTelemetryQuery |
Views — create, manage, or query named reusable SQL (FROM view.<name>) | Read query/views.md |
Dashboards in Omni — compose, ground panel queries, author panels[], lay out the grid, the API save semantics (an unknown root- or panel-level key, a missing type/layout, or a bad variant is REJECTED at save with a 400 ValidationException; a bad enum VALUE, x+w>60, or an unknown key inside config saves 200 and fails or is ignored at render; validate before save), fix an empty or blank panel, the *OmniDashboard APIs | Read dashboards.md |
Alerts in Omni — any mention of an Omni alert, CreateAlert / GetAlert / ListAlerts / UpdateAlert / DeleteAlert, a profileId, an alert ARN, or how alerts differ from alarms; create, tune, tag, list, delete; notifications | Read alerts.md. The alert API is real and first-class — do NOT redirect to CloudWatch alarms. For CloudWatch alarms when Omni is not enabled, read cloudwatch/alarms.md |
Omni Intelligence — is anything wrong right now, status of a service, why is a service slow or erroring, what caused an incident between two times, root cause, past incidents, insights, observations, anomalies, ListInsights, ListObservations | Read intelligence.md; it links to the context graph for cause-vs-impact questions. ListInsights, ListObservations, and GetContextGraph are the public CloudWatch Omni operations it teaches (aws cloudwatchomni list-insights / list-observations / get-context-graph), with the Intelligence console view as a human-readable cross-check. It consults the intelligence layer, localizes on the context graph, confirms with telemetry, and names one root-cause service. For a health verdict or dependency roles with no single-cause ask, use service-health.md; for pure dependency / blast-radius topology and graph traversal, use context-graph.md |
Context graph — why is service X slow or failing, what depends on it, upstream/downstream, which direction to walk, edge types CALLS / ACCESSES / RUNS_ON, blast radius, walking from an insight or anomaly to a root cause, GetContextGraph | Read context-graph.md and state every applicable item in its "facts you MUST surface" section |
Service health & dependency analysis — is <service> healthy, health summary, what does <service> depend on and what ROLE does each play (caller / callee / datastore / runs-on), which services call it | Read service-health.md and state every applicable item in its "facts you MUST surface" section. It grounds the verdict in the context graph + RED/USE metrics + alarm state; for the resource-specific signal catalog and query mechanics it defers to query/promql-metrics.md (§5 per-service catalog), query/sql-logs-traces.md, and alerts.md, for traversal and blast radius to context-graph.md. A "what signals / panels does <resource type> need" or fleet-health-view ask goes to promql-metrics.md, not here |
Agent evaluation — score traces on demand, choose an evaluator, read back stored gen_ai.evaluation.* scores ("which evaluators are doing worst", "which online evaluators are unhealthy / underperforming"), build datasets from traces, set up online evaluation, author a custom evaluator, audit whether an agent's traces are flowing, monitor quality, root-cause a quality regression, recommend prompt or tool-description changes from scores, verify a change against a baseline | Read agent-evaluation.md and state every applicable item in its "tell the user ALL of this" callouts |
| Programmatic access — "is there an API or SDK for Omni", calling Omni from code, CI, IaC, or an AI coding agent | Read programmatic-access.md. Omni has a real public SigV4 API; never answer that it has none, never substitute the CloudWatch or X-Ray CLI/SDK, and answer without probing for a Space. Endpoint cloudwatch-omni.<region>.api.aws, never .amazonaws.com; client cloudwatchomni, no hyphen. Give both authorization classes: a grant-mediated caller needs a matching grant or is denied; a direct IAM caller with none is not denied by the Space — its own IAM policy decides |
| AWS DevOps Agent — Investigations reports it is not connected, connecting it to a Space, or running, following, or chatting with an investigation | Connecting is first-time setup: route to the setting-up-cloudwatch-observability skill. In the Omni web app an investigation starts from a thread, /investigation new <description>, or a firing alert's Investigate action. Driving the DevOps Agent itself (starting, following, or chatting with investigations from a coding agent) belongs to its dedicated aws-agents-for-devsecops plugin; point there and do not reconstruct that workflow or invent aidevops calls |
| Who has access to a Space, granting or revoking access, access profiles, creating a Space or Domain, ingestion, forwarding, Slack, Azure, instrumenting an app or AI agent — and, on the CloudWatch side, onboarding a service to Application Signals | Route to the setting-up-cloudwatch-observability skill. If it is not installed locally, load it with the AWS MCP retrieve_skill tool (skill_name: setting-up-cloudwatch-observability; pass file for a reference it cites) |
| Spans multiple areas | Read the most specific reference first, then consult others as needed |
Files
references/cloudwatch/
| File | Content |
|---|---|
| alarms.md | Metric, composite, anomaly detection alarms — configuration, constraints, recommended defaults |
| log-insights.md | Complete query syntax, commands, functions, known issues, reusable query library |
| metrics.md | Custom metrics, EMF spec, metric filters, high-resolution, retention |
| tracing.md | X-Ray → ADOT migration, sampling rules, annotations vs metadata, collector config |
| dashboards.md | Widget types, cross-account/region, dynamic labels, sharing |
| troubleshooting.md | Error → cause → fix for all observability services |
| cloudtrail.md | Operational auditing, event types, S3+Athena queries |
| synthetics.md | Canary runtime/blueprint constraints, VPC networking, common failures |
| dynamic-instrumentation.md | Dynamic Instrumentation debugging loop — breakpoints/probes on live code, snapshot capture + correlation analysis, create/delete gating, snapshot PII handling. Runs via scripts/cloudwatch/di_instrumentation.py + scripts/cloudwatch/di_snapshots.py; details in dynamic-instrumentation/ |
| alarm-template.ts | Best-practice CDK Lambda monitoring (alarms + dashboard) |
| otel-config.yaml | ADOT collector config for X-Ray traces + CloudWatch EMF metrics |
Not here, by design: Application Signals onboarding (the enablement procedure, the
ServiceEvents CI/CD chain, the 16 per-platform guides) is first-time setup and lives in
setting-up-cloudwatch-observability. Do not reconstruct it here from memory; route.
references/cloudwatch-omni/
| File | Content |
|---|---|
| concepts.md | What Omni is and is not; glossary (Domain, Space, Dataset, grant, profile, view, alert, dashboard, context graph, evaluator); Omni-vs-CloudWatch feature-equivalence matrix; how to tell which product the customer means; the setup sequence and where it lives |
| context-graph.md | The service/resource topology Omni builds from traces and metrics; GetContextGraph request/response and CLI; reading upstream vs downstream and blast radius; walking from an insight or anomaly hop-by-hop to a root cause, then pivoting to queries |
| intelligence.md | Omni Intelligence — the insight and observation operations and data model, ListInsights / ListObservations inputs and filters, GetContextGraph with insights, answering status, troubleshooting, root-cause and past-incident questions, the 404-vs-403-vs-local error rule (owned by context-graph.md), cost and performance guardrails, and the empty-result/stale-insight gotchas |
| service-health.md | Service health verdict and dependency analysis — dependency map with roles (caller/callee/datastore/runs-on), the multi-signal HEALTHY/DEGRADED/DOWN verdict (RED for services, USE for resources, alarm state), and how to build the checks from a node's includeMetadata selectors. Ties together context-graph, promql-metrics, sql-logs-traces, and alerts rather than duplicating them — defers traversal and blast radius to context-graph and the resource-specific signal catalog to promql-metrics §5 |
| programmatic-access.md | The public SigV4 API (cloudwatch-omni endpoint prefix, cloudwatch signing name), how access grants authorize a programmatic caller, CLI/SDK access (and why an unsupported-service error is a client-version issue), CloudFormation/CDK, AI coding agents, and the wrong answers to avoid |
| query/sql-logs-traces.md | SQL over logs and traces — table addressing, required time range, system fields, field access and quoting, schema discovery, supported operations, functions, common patterns (including durationNano span duration), constraints, TABLESAMPLE |
| query/promql-metrics.md | Metrics in Omni are PromQL — what is queryable (OTLP, span RED, OTel-enriched vended metrics) and what is not, label conventions, __name__ matcher, rate() on counters, per-AWS-service metric catalog with derived formulas and dimension traps, and running queries and discovering names through the Prometheus-compatible HTTP API and MCP tools |
| query/trace-exploration.md | Working with whole traces on traces.default — span fields and AI-agent span attributes (span role, tool name, input/output, token usage), fetching one trace in the right window, time breakdown and self-time, failure analysis with exception events, comparing runs, tying a bad evaluation score to spans, tool-call and repeated-tool patterns, trace-level latency and "today", field discovery for traces, reporting, agent sessions, walking a session, token cost, signals the spans may not record, before/after comparison by layer |
| query/views.md | Named SQL views: CreateView / UpdateView / DeleteView / ListViews, FROM view.<name>, naming and definition rules, composition patterns |
| dashboards.md | Omni dashboards — composition recipes, grounding panel queries, the panels[] body and panel types, visualizations, the 60-column grid, the API save semantics (unknown root/panel keys are rejected 400; unknown keys inside config save 200 and are ignored at render; validate before save), troubleshooting empty/blank panels, the Create/Get/List/Update/DeleteOmniDashboard APIs, archetype templates |
| alerts.md | Omni alerts — alert vs alarm, evaluation (FIELD_VALUE / COUNT_OF_RESULTS, contributors), states and no-data treatment, notification rules, step-by-step create / update / delete / tag / fetch, and the alert APIs |
| agent-evaluation.md | Agent-quality evaluation on OTel traces — instrumentation health audit, evaluator selection, on-demand scoring, online evaluation, custom evaluators, datasets from traces, reading back stored gen_ai.evaluation.* scores (retrieval plan + SQL mechanics), and turning scores into changes (monitoring, regressions, evaluator drills, golden datasets, change and re-run). Uses scripts/cloudwatch-omni/evaluate_traces.py and scripts/cloudwatch-omni/capture_dataset_from_traces.py |
Files
- ATTRIBUTION.mdAdded by AutoGineer899 B
- LICENSELicence file added by AutoGineer9.9 KiB
- NOTICELicence file added by AutoGineer67 B
- SKILL.md37.0 KiB
- assets/cloudwatch/alarm-template.tsScript3.9 KiB
- assets/cloudwatch/otel-config.yaml1.9 KiB
- references/cloudwatch-omni/agent-evaluation.md114.9 KiB
- references/cloudwatch-omni/alerts.md108.5 KiB
- references/cloudwatch-omni/concepts.md29.3 KiB
- references/cloudwatch-omni/context-graph.md32.6 KiB
- references/cloudwatch-omni/dashboards.md79.4 KiB
- references/cloudwatch-omni/intelligence.md16.9 KiB
- references/cloudwatch-omni/programmatic-access.md15.5 KiB
- references/cloudwatch-omni/query/promql-metrics.md66.8 KiB
- references/cloudwatch-omni/query/sql-logs-traces.md45.1 KiB
- references/cloudwatch-omni/query/trace-exploration.md26.1 KiB
- references/cloudwatch-omni/query/views.md12.8 KiB
- references/cloudwatch-omni/service-health.md17.5 KiB
- references/cloudwatch/alarms.md11.6 KiB
- references/cloudwatch/cloudtrail.md5.4 KiB
- references/cloudwatch/dashboards.md5.8 KiB
- references/cloudwatch/dynamic-instrumentation.md39.3 KiB
- references/cloudwatch/dynamic-instrumentation/breakpoint-creation.md16.3 KiB
- references/cloudwatch/dynamic-instrumentation/call-tree-and-directions.md5.7 KiB
- references/cloudwatch/dynamic-instrumentation/snapshot-parsing.md4.2 KiB
- references/cloudwatch/log-insights.md8.3 KiB
- references/cloudwatch/metrics.md7.5 KiB
- references/cloudwatch/synthetics.md6.5 KiB
- references/cloudwatch/tracing.md9.4 KiB
- references/cloudwatch/troubleshooting.md7.5 KiB
- scripts/cloudwatch-omni/capture_dataset_from_traces.pyScript25.2 KiB
- scripts/cloudwatch-omni/evaluate_traces.pyScript42.7 KiB
- scripts/cloudwatch-omni/test_evaluate_traces.pyScript12.6 KiB
- scripts/cloudwatch/di_app_signals_client.pyScript3.3 KiB
- scripts/cloudwatch/di_capture.pyScript8.5 KiB
- scripts/cloudwatch/di_constants.pyScript560 B
- scripts/cloudwatch/di_crud_rendering.pyScript13.1 KiB
- scripts/cloudwatch/di_crud_tools.pyScript28.8 KiB
- scripts/cloudwatch/di_error_translation.pyScript5.6 KiB
- scripts/cloudwatch/di_formatting.pyScript992 B
- scripts/cloudwatch/di_gateway.pyScript7.0 KiB
- scripts/cloudwatch/di_instrumentation.pyScript13.0 KiB
- scripts/cloudwatch/di_location.pyScript15.3 KiB
- scripts/cloudwatch/di_logs_client.pyScript4.2 KiB
- scripts/cloudwatch/di_region.pyScript2.0 KiB
- scripts/cloudwatch/di_result.pyScript2.0 KiB
- scripts/cloudwatch/di_session.pyScript2.0 KiB
- scripts/cloudwatch/di_snapshot_parsing.pyScript11.4 KiB
- scripts/cloudwatch/di_snapshot_queries.pyScript2.4 KiB
- scripts/cloudwatch/di_snapshot_rendering.pyScript12.5 KiB
- scripts/cloudwatch/di_snapshot_tools.pyScript12.0 KiB
- scripts/cloudwatch/di_snapshots.pyScript12.8 KiB
- scripts/cloudwatch/di_status_assessment.pyScript4.7 KiB
- scripts/cloudwatch/di_status_rendering.pyScript11.8 KiB
- scripts/cloudwatch/di_status_tools.pyScript13.0 KiB
- scripts/cloudwatch/di_validation.pyScript11.2 KiB
scripts/cloudwatch-omni/evaluate_traces.py
#!/usr/bin/env python3
"""Score CloudWatch Omni agent traces on demand with AgentCore evaluators.
Bundled skill helper (retrieved via the skill's supplementary-file mechanism and run
with the agent's shell) for Section 5, Step C "on-demand (sync) evaluation". Given one or
more trace ids and one or more evaluator ids, it resolves each trace's session, fetches
that session's spans from CloudWatch Omni (traces.default) via Omni SQL, reconstructs
them into the exact `sessionSpans` shape the scoring API needs (the fragile part — every
span's `kind` is coerced to a bare OTel string, since `bedrock-agentcore evaluate` rejects
numeric/prefixed/null kinds with `ValidationException: Failed to parse span data`), and
scores every (trace, evaluator) pair — fetching each unique trace ONCE, then reusing its
spans across every evaluator. It prints a compact JSON receipt (per-pair score / label /
errorCode, and the judge's explanation only when asked for it). Raw spans never leave
this process (kept out of the model's context).
Scoring goes through the AgentCore data plane (`bedrock-agentcore evaluate`) on session
spans fetched from CloudWatch Omni. Do NOT hand-extract input/output/toolCalls or call
any other Evaluate API (e.g. any cloudwatch-omni Evaluate operation) or assemble
`structuredInput` by hand: that manual reshaping is exactly the error-prone work this
helper exists to replace.
Doing the span-shaping in code is deliberate: hand-shaping spans turn-by-turn is
the step an agent thrashes on (reshape → ValidationException → retry → loop). This makes
the sync path deterministic. Batch is fetch-once/score-many so scoring N traces against M
evaluators is N fetches + N×M scores, never N×M fetches.
Each score is also written back as a `gen_ai.evaluation.result` telemetry record (the same
shape online evaluation emits, minus the online-config attribute) so it is queryable later
instead of living only in this conversation. That step is best-effort — it never fails the
run, and it is skipped silently when the caller lacks logs write permission. Pass
`--no-writeback` to turn it off.
SENSITIVE DATA: an evaluator's `explanation` is the judge's prose, and it quotes the agent's own
inputs and outputs. Two paths carry it, with different defaults:
* The RECEIPT (stdout, so it reaches the calling agent's context, transcript and client logs)
omits it unless `--include-explanations` is passed; by default each row reports only
`hasExplanation`, so the caller knows one exists and can ask for it. Scores, labels and
error codes are always returned.
* The WRITEBACK record persists it as `gen_ai.evaluation.explanation` — that is the point of
the record (a score without its "why" is not much use when read back later). So when this
run CREATES the results log group it provisions it deliberately: `--retention-days`
(DEFAULT_RESULTS_RETENTION_DAYS; 0 leaves the account default) bounds how long they live, and
`--kms-key-id` encrypts the group with a customer-managed key instead of the AWS-owned
default. Both apply ONLY to a group this run creates — a pre-existing group keeps whatever
retention and key it already has, which are the owner's to set. If retention cannot be set
the receipt says so in `notes` rather than leaving an unbounded group unremarked. Pass
`--no-writeback` to persist nothing at all.
Requires boto3 + caller AWS credentials with cloudwatch-omni (Omni SQL span queries) +
bedrock-agentcore (data plane evaluate); the writeback additionally uses logs:PutLogEvents
(and logs:CreateLogGroup / logs:CreateLogStream / logs:PutRetentionPolicy the first time). A `--kms-key-id` key
policy must let the `logs.<region>.amazonaws.com` service principal use the key, or group
creation fails (the run does NOT fall back to an unencrypted group).
Examples:
# one trace, one evaluator
python evaluate_traces.py --trace-id 6a82... --evaluator-id Builtin.Helpfulness
# a sample of traces × two evaluators (matrix; each trace fetched once)
python evaluate_traces.py --trace-ids 6a82...,7b91...,8c03... \
--evaluator-ids Builtin.Helpfulness,Builtin.ToolSelectionAccuracy --level TOOL_CALL
python evaluate_traces.py --trace-id 6a82... --include-explanations # return the "why" too
python evaluate_traces.py --trace-id 6a82... --no-writeback # score only, don't persist
# persist under a customer-managed key with 90-day retention
python evaluate_traces.py --trace-id 6a82... --kms-key-id arn:aws:kms:... --retention-days 90
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from typing import NoReturn
import boto3
from botocore.exceptions import BotoCoreError, ClientError
EVALUATE_SERVICE = "bedrock-agentcore" # data plane hosts `evaluate`
DEFAULT_EVALUATOR = "Builtin.Helpfulness"
LEVELS = ("TRACE", "TOOL_CALL", "SESSION")
# Batch bounds (match the on-demand evaluate-traces tool: 50 traces / 10 evaluators / 100 pairs).
# All THREE apply independently and are enforced by TRUNCATING with a note in the receipt — never a
# silent cap. `traces × evaluators` fans out into one evaluate call per pair, so a big matrix would
# throttle and blow the timeout.
MAX_TRACES = 50 # unique traces fetched per run
MAX_EVALUATORS = 10 # distinct evaluators per run
MAX_PAIRS = 100 # total (trace, evaluator) PAIRS per run
# Hard ceiling on ac.evaluate() calls per run. TRACE/SESSION issue 1 call per pair (so MAX_PAIRS
# bounds them), but TOOL_CALL chunks tool spans into ≤MAX_TARGET_IDS-id calls, so one pair can be
# many calls — this bounds the total fan-out (throttle/timeout guard) regardless of level. This is
# NOT one of the three authoritative caps above: it maps to the ~100-evaluations/minute account
# quota, so raising it risks throttling. With MAX_PAIRS now == 100 it equals this ceiling, so a
# full 100-pair TRACE/SESSION run spends exactly the budget; TOOL_CALL runs can hit it sooner.
MAX_EVALUATE_CALLS = 100
# Evaluators that require ground truth (an expected tool trajectory) and so CANNOT score a raw
# trace: this helper sends `sessionSpans` only, no `evaluationReferenceInputs`, so they would
# error or score nothing. The evaluator APIs expose NO machine-readable requirement flag
# (get-evaluator returns only id/name/description/evaluatorConfig/level/status), so this list is
# maintained by hand — the trajectory matchers are the built-ins that compare against an
# expected sequence. Do NOT "helpfully" delete it as a hardcoded catalog: there is no flag to
# read instead. Correctness / GoalSuccessRate are deliberately absent — they can optionally use
# ground truth but run without it, so on-demand scoring of a raw trace is valid for them.
_GROUND_TRUTH_REQUIRED = frozenset(
{
"Builtin.TrajectoryExactOrderMatch",
"Builtin.TrajectoryInOrderMatch",
"Builtin.TrajectoryAnyOrderMatch",
}
)
# On-demand `evaluate` returns the score inline but does NOT persist it, so a score would be
# lost the moment the conversation ends. Write each one back as a telemetry record — same
# `gen_ai.evaluation.result` shape online evaluation emits — so scores can be queried later
# (by a later session or another user) alongside the online ones.
EVAL_RESULTS_LOG_GROUP_PREFIX = "/aws/cloudwatch/evaluations/results/"
EVAL_RESULTS_LOG_STREAM = "on-demand"
EVAL_RESULT_RECORD_NAME = "gen_ai.evaluation.result"
# The persisted record carries the judge's explanation (which quotes agent input/output), so a
# group this run creates gets a BOUNDED retention rather than inheriting the account default —
# which can be "never expire". Overridable per run; 0 opts out for accounts that manage retention
# centrally. Only ever applied to a group this run creates.
DEFAULT_RESULTS_RETENTION_DAYS = 30
# The only retentionInDays values CloudWatch Logs accepts. Validated up front so a bad value fails
# the run instead of failing put_retention_policy AFTER the group was created — which would leave
# exactly the unbounded group the default exists to avoid.
_RETENTION_DAYS_ALLOWED = frozenset(
(
1,
3,
5,
7,
14,
30,
60,
90,
120,
150,
180,
365,
400,
545,
731,
1096,
1827,
2192,
2557,
2922,
3288,
3653,
)
)
_MAX_LOG_GROUP_LEN = 512
# Log-group names allow [.-_/#A-Za-z0-9]; anything else in a service name is replaced.
_LOG_GROUP_SAFE_RE = re.compile(r"[^A-Za-z0-9_.\-/#]")
# Writeback is a courtesy, not the job: if the caller simply lacks logs write permission,
# skip silently rather than nagging about a permission they may not want to grant.
_ACCESS_DENIED_CODES = frozenset(
("AccessDenied", "AccessDeniedException", "UnauthorizedException", "AuthorizationError")
)
# Distinguishes "not written, but nothing worth telling the user about" from a real success,
# so the receipt never claims a score was persisted when it was not.
_WRITEBACK_SKIPPED = object()
# Used for BOTH the log-group segment and the record's service.name when the scored spans
# carry no service name, so the stored record always agrees with where it was filed.
_UNKNOWN_SERVICE = "default"
# fetch LIMIT+1 so an exactly-full result is detectable as truncated; paginate to collect
# all rows (maxResults cap is 1000 per page).
SPAN_ROW_LIMIT = 1000
QUERY_POLL_ATTEMPTS = 150 # × 2s = up to 5 min for an Omni SQL query to complete
_TRACE_ID_RE = re.compile(r"^[0-9a-fA-F]{1,64}$")
# Session ids are alphanumeric + a few separators; validate (reject) rather than mangle,
# so a malformed id never silently rewrites to a DIFFERENT real session's id.
_SESSION_ID_RE = re.compile(r"^[0-9A-Za-z._:\-]{1,128}$")
# A service name is interpolated into the span query, so it is validated, never escaped.
_SERVICE_NAME_RE = re.compile(r"^[0-9A-Za-z._:/\-]{1,255}$")
# OTLP SpanKind enum → bare OTel name. `evaluate` requires `kind` to be a STRING; the
# stored telemetry is polymorphic (some emitters use "SERVER", others the numeric enum,
# some spans omit kind / send null), so coerce every shape to the bare name.
_KIND_BY_NUMBER = {
0: "UNSPECIFIED",
1: "INTERNAL",
2: "SERVER",
3: "CLIENT",
4: "PRODUCER",
5: "CONSUMER",
}
_VALID_KINDS = frozenset(_KIND_BY_NUMBER.values())
_DEFAULT_KIND = "UNSPECIFIED"
# Span attribute columns to SELECT from traces.default and their SQL aliases.
# Maps (OTLP attribute key, SQL column alias); only non-null values are included in
# the reconstructed span's attributes dict.
#
# Coverage note: Omni SQL does not expose a wildcard `attributes.*` projection, so only
# known keys can be fetched. This list covers all attributes the built-in AgentCore
# evaluators are known to consume. Custom evaluators that rely on attributes not listed
# here will receive incomplete span context — extend _ATTR_COLS if that is the case.
_ATTR_COLS = [
# Span kind (agent framework conventions)
("openinference.span.kind", "oi_span_kind"),
("aws.genai.span_kind", "aws_genai_span_kind"),
("gen_ai.operation.name", "gen_ai_op_name"),
# Input / output (string-valued keys; structured-array keys like gen_ai.input.messages
# are included as-is — evaluators that need them receive whatever value Omni stores)
("input.value", "input_value"),
("output.value", "output_value"),
("gen_ai.input_value", "gen_ai_input_value"),
("gen_ai.output_value", "gen_ai_output_value"),
("gen_ai.input.messages", "gen_ai_input_messages"),
("gen_ai.output.messages", "gen_ai_output_messages"),
("llm.input_messages", "llm_input_messages"),
("llm.output_messages", "llm_output_messages"),
# Tool call
("tool.name", "tool_name"),
("gen_ai.tool.name", "gen_ai_tool_name"),
("gen_ai.tool.call.id", "gen_ai_tool_call_id"),
# Session identity
("session.id", "session_id_attr"),
# LLM metadata
("gen_ai.request.model", "gen_ai_req_model"),
("gen_ai.response.model", "gen_ai_resp_model"),
("gen_ai.system", "gen_ai_system"),
# Token counts
("gen_ai.usage.input_tokens", "usage_input_tokens"),
("gen_ai.usage.output_tokens", "usage_output_tokens"),
("llm.token_count.prompt", "prompt_tokens"),
("llm.token_count.completion", "completion_tokens"),
]
# Pre-built SELECT fragment — one alias per attribute key.
_ATTR_SELECT = ", ".join(
"attributes['%s'] AS %s" % (key, alias) for key, alias in _ATTR_COLS
)
def _die(payload) -> NoReturn:
"""Print an error receipt to stdout (so the agent sees it) and exit non-zero."""
print(json.dumps(payload))
sys.exit(1)
def _split_ids(*values, lower: bool = False):
"""Flatten comma-separated id args into a de-duplicated, order-preserving list.
Pass lower=True for trace ids: Omni SQL traceId values are lowercase hex, so normalise
on input. Do NOT pass lower=True for evaluator ids — the evaluate API and
_GROUND_TRUTH_REQUIRED use the original casing.
"""
ids = []
for v in values:
if v:
ids += [(x.strip().lower() if lower else x.strip()) for x in v.split(",") if x.strip()]
return list(dict.fromkeys(ids))
def _normalize_span_kind(value):
"""Coerce a raw span `kind` (numeric enum / SPAN_KIND_* / null / missing) to a bare
OTel SpanKind string. `bool` is NOT treated as a numeric enum."""
if isinstance(value, bool):
return _DEFAULT_KIND
if isinstance(value, int):
return _KIND_BY_NUMBER.get(value, _DEFAULT_KIND)
if isinstance(value, float) and value.is_integer():
return _KIND_BY_NUMBER.get(int(value), _DEFAULT_KIND)
if isinstance(value, str):
text = value.strip()
if not text:
return _DEFAULT_KIND
if text.isdecimal(): # NOT isdigit(): "²"/"³" are digits but int() rejects them
return _KIND_BY_NUMBER.get(int(text), _DEFAULT_KIND)
upper = text.upper()
for prefix in ("SPAN_KIND_", "SPANKIND."):
if upper.startswith(prefix):
upper = upper[len(prefix):]
break
return upper if upper in _VALID_KINDS else _DEFAULT_KIND
return _DEFAULT_KIND
def _row_to_span(row):
"""Reconstruct an OTLP-compatible span dict from an Omni SQL result row.
Only non-empty values are included in the attributes dict so the evaluator receives
a clean document rather than a dict full of null/empty-string keys.
"""
attrs = {key: row[alias] for key, alias in _ATTR_COLS if row.get(alias) not in (None, "")}
span: dict = {
"traceId": row.get("traceId"),
"spanId": row.get("spanId"),
"name": row.get("name") or "",
"kind": _normalize_span_kind(row.get("kind")),
"attributes": attrs,
"resource": {"attributes": {}},
}
if row.get("parentSpanId"):
span["parentSpanId"] = row["parentSpanId"]
if row.get("startTimeUnixNano"):
span["startTimeUnixNano"] = row["startTimeUnixNano"]
if row.get("endTimeUnixNano"):
span["endTimeUnixNano"] = row["endTimeUnixNano"]
if row.get("svc_name"):
span["resource"]["attributes"]["service.name"] = row["svc_name"]
if row.get("scope_name"):
span["scope"] = {"name": row["scope_name"]}
if row.get("status_code"):
span["status"] = {"code": row["status_code"]}
return span
# ---- span fetch via CloudWatch Omni SQL (traces.default) ----
def _omni_query(omni, sql, soft=False):
"""Run an Omni SQL query and paginate to collect all rows.
Returns (rows, error). soft=True returns (None, message) on failure so callers can
degrade gracefully (e.g. session resolution has a traceId fallback).
"""
session_id = None
rows: list = []
try:
try:
session_id = omni.start_telemetry_query_session(
sessionName="eval-traces-%d" % int(time.time())
)["sessionId"]
except (BotoCoreError, ClientError) as e:
msg = "Omni start_telemetry_query_session failed: %s" % e
if soft:
return None, msg
_die({"error": msg})
try:
qid = omni.start_telemetry_query(
sessionId=session_id, queryString=sql
)["queryId"]
except (BotoCoreError, ClientError) as e:
msg = "Omni start_telemetry_query failed: %s" % e
if soft:
return None, msg
_die({"error": msg})
# Phase 1: poll for completion (status only)
status = None
for _ in range(QUERY_POLL_ATTEMPTS):
time.sleep(2)
try:
poll = omni.get_telemetry_query_results(queryId=qid, maxResults=1)
except (BotoCoreError, ClientError) as e:
msg = "Omni get_telemetry_query_results failed: %s" % e
if soft:
return None, msg
_die({"error": msg})
status = poll.get("status")
if status in ("Complete", "Failed", "Cancelled"):
break
if status != "Complete":
msg = (
"Omni SQL query did not complete (status=%s); "
"narrow --window-days to speed it up" % status
)
if soft:
return None, msg
_die({"error": msg})
# Phase 2: paginate to collect all rows (maxResults cap is 1000 per page)
next_token = None
while len(rows) <= SPAN_ROW_LIMIT:
try:
kwargs: dict = {"queryId": qid, "maxResults": 1000}
if next_token:
kwargs["nextToken"] = next_token
page = omni.get_telemetry_query_results(**kwargs)
except (BotoCoreError, ClientError) as e:
msg = "Omni get_telemetry_query_results (fetch) failed: %s" % e
if soft:
return None, msg
_die({"error": msg})
page_rows = page.get("rows", [])
rows.extend(page_rows)
next_token = page.get("nextToken")
if not next_token or not page_rows: # no token or empty page → done
break
finally:
if session_id:
try:
omni.stop_telemetry_query_session(sessionId=session_id)
except Exception:
pass
return rows, None
def _resolve_session_id(omni, trace_id, window_days):
"""Find the trace's session.id via Omni SQL, or None. Non-fatal on failure."""
sql = (
"SELECT attributes['session.id'] AS session_id "
"FROM \"traces.default\" "
"WHERE traceId = '%s' "
"AND attributes['session.id'] IS NOT NULL "
"AND \"@timestamp\" BETWEEN NOW() - INTERVAL '%d days' AND NOW() "
"LIMIT 1"
) % (trace_id, window_days)
rows, _err = _omni_query(omni, sql, soft=True)
for row in rows or []:
sid = row.get("session_id")
if sid:
return sid
return None
def _fetch_spans(omni, filter_clause, window_days, soft=False):
"""Fetch spans from traces.default for the given WHERE clause.
Returns (spans, truncated, error). spans is a list of OTLP-compatible span dicts
reconstructed from Omni SQL columns. truncated is True when the SQL LIMIT was hit.
soft=True returns (None, False, message) on failure so one trace's error doesn't
sink the whole batch.
"""
sql = (
"SELECT traceId, spanId, parentSpanId, name, kind, "
"startTimeUnixNano, endTimeUnixNano, "
"resource['attributes']['service.name'] AS svc_name, "
"scope['name'] AS scope_name, "
"status['code'] AS status_code, "
+ _ATTR_SELECT
+ " FROM \"traces.default\" "
"WHERE %s "
"AND \"@timestamp\" BETWEEN NOW() - INTERVAL '%d days' AND NOW() "
"ORDER BY startTimeUnixNano ASC "
# fetch one past the cap so an exactly-full result is detectable as truncated
"LIMIT %d"
) % (filter_clause, window_days, SPAN_ROW_LIMIT + 1)
rows, err = _omni_query(omni, sql, soft=soft)
if rows is None:
return None, False, err
truncated = len(rows) > SPAN_ROW_LIMIT
if truncated:
rows = rows[:SPAN_ROW_LIMIT] # drop the probe row before building spans
spans = [_row_to_span(r) for r in rows if r.get("traceId")]
return spans, truncated, None
def _tool_span_ids(spans):
"""Span ids of TOOL spans (for TOOL_CALL level), matching the bespoke tool's detection."""
ids = []
for s in spans:
attrs = s.get("attributes") or {}
is_tool = (
attrs.get("openinference.span.kind") == "TOOL"
or attrs.get("gen_ai.operation.name") == "execute_tool"
)
sid = s.get("spanId")
if is_tool and isinstance(sid, str) and len(sid) == 16:
ids.append(sid)
return ids
def _summarize(response, evaluator_id, include_explanations=False):
"""Map a raw evaluate response to compact rows; the raw envelope is NOT returned.
The explanation is the judge's prose and quotes the agent's own inputs and outputs, so it is
kept OUT of the caller-facing receipt unless `--include-explanations` asks for it: the receipt
goes to stdout, which lands in the calling agent's context, its transcript and client logs.
`hasExplanation` still tells the caller one exists and can be requested, so the score is never
silently un-explainable.
The text is retained on the PRIVATE `_explanation` key either way, because the writeback record
persists it regardless (see _eval_result_records — a stored score without its "why" is little
use when read back later, and that path is bounded by retention/CMK instead of by omission).
`_public_rows` strips every private key before the receipt is printed.
"""
rows = []
for res in response.get("evaluationResults") or []:
explanation = str(res.get("explanation") or "")
clipped = explanation[:600] + ("…" if len(explanation) > 600 else "")
row = {
"evaluatorId": res.get("evaluatorId", evaluator_id),
"value": res.get("value"),
"label": res.get("label"),
"_explanation": clipped,
}
if include_explanations:
row["explanation"] = clipped
else:
row["hasExplanation"] = bool(explanation)
if res.get("errorCode"):
row["errorCode"] = res.get("errorCode")
if res.get("errorMessage"):
row["errorMessage"] = str(res.get("errorMessage"))[:600]
rows.append(row)
return rows
def _public_rows(rows):
"""Drop private (`_`-prefixed) keys — what the receipt may print, vs. what writeback may read.
Kept as the LAST step before printing so nothing that only exists for the writeback path can
reach stdout by accident.
"""
return [{k: v for k, v in row.items() if not k.startswith("_")} for row in rows]
def _log_group_segment(service):
"""Sanitize a service name into a single log-group path segment."""
seg = _LOG_GROUP_SAFE_RE.sub("_", (service or "").strip()) or _UNKNOWN_SERVICE
return seg[: _MAX_LOG_GROUP_LEN - len(EVAL_RESULTS_LOG_GROUP_PREFIX)]
def _service_name(spans, trace_id):
"""The scored trace's `resource.attributes['service.name']` (its own span wins)."""
fallback = None
for s in spans:
name = ((s.get("resource") or {}).get("attributes") or {}).get("service.name")
if not isinstance(name, str) or not name:
continue
if s.get("traceId") == trace_id:
return name
fallback = fallback or name
return fallback
def _eval_result_records(rows, trace_id, session_id, level, service, ts_ms, partial=False):
"""Build one `gen_ai.evaluation.result` record per SCORED row."""
records = []
for row in rows:
if row.get("errorCode") or row.get("value") is None:
continue
attributes = {
"gen_ai.evaluation.name": row.get("evaluatorId"),
"gen_ai.evaluation.score.value": str(row.get("value")),
"aws.bedrock_agentcore.evaluation_level": level,
}
if session_id:
attributes["session.id"] = session_id
if row.get("label") is not None:
attributes["gen_ai.evaluation.score.label"] = row.get("label")
if row.get("_explanation"):
attributes["gen_ai.evaluation.explanation"] = row.get("_explanation")
if partial:
attributes["gen_ai.evaluation.partial"] = "true"
records.append(
{
"name": EVAL_RESULT_RECORD_NAME,
"traceId": trace_id,
"attributes": attributes,
"resource": {"attributes": {"service.name": service}},
"@timestamp": ts_ms,
}
)
return records
def _err_code(e):
"""The `Error.Code` of a ClientError, or "" when the envelope does not carry one."""
return (e.response.get("Error") or {}).get("Code") or ""
def _provision_group(logs, group, retention_days, kms_key_id):
"""Create the results log group + stream, retention-bounded and optionally CMK-encrypted."""
warning = None
kwargs = {"logGroupName": group}
if kms_key_id:
kwargs["kmsKeyId"] = kms_key_id
try:
logs.create_log_group(**kwargs)
created = True
except BotoCoreError as e:
return "could not persist the score for later querying: %s" % e, None
except ClientError as e:
code = _err_code(e)
if code in _ACCESS_DENIED_CODES:
return _WRITEBACK_SKIPPED, None
if code == "ResourceAlreadyExistsException":
created = False
elif kms_key_id and code in ("InvalidParameterException", "ValidationException"):
return (
"could not create %s with kmsKeyId %s (does the key policy allow the CloudWatch "
"Logs service principal to use it?): %s" % (group, kms_key_id, e)
), None
else:
return "could not persist the score for later querying: %s" % e, None
if created and retention_days:
try:
logs.put_retention_policy(logGroupName=group, retentionInDays=retention_days)
except (ClientError, BotoCoreError) as e:
warning = (
"created %s but could not set %d-day retention (needs logs:PutRetentionPolicy), "
"so it keeps the account default retention — which may be 'never expire', and "
"these records carry the evaluator's explanation: %s" % (group, retention_days, e)
)
try:
logs.create_log_stream(logGroupName=group, logStreamName=EVAL_RESULTS_LOG_STREAM)
except BotoCoreError as e:
return "could not persist the score for later querying: %s" % e, warning
except ClientError as e:
code = _err_code(e)
if code in _ACCESS_DENIED_CODES:
return _WRITEBACK_SKIPPED, warning
if code != "ResourceAlreadyExistsException":
return "could not persist the score for later querying: %s" % e, warning
return None, warning
def _write_back(logs, service, records, ts_ms, retention_days=None, kms_key_id=None):
"""Persist scores to the evaluation-results log group. Best-effort: never raises."""
if not records:
return _WRITEBACK_SKIPPED, None
group = EVAL_RESULTS_LOG_GROUP_PREFIX + _log_group_segment(service)
events = [{"timestamp": ts_ms, "message": json.dumps(r, default=str)} for r in records]
warning = None
for attempt in (1, 2):
try:
logs.put_log_events(
logGroupName=group, logStreamName=EVAL_RESULTS_LOG_STREAM, logEvents=events
)
return None, warning
except ClientError as e:
code = _err_code(e)
if code in _ACCESS_DENIED_CODES:
return _WRITEBACK_SKIPPED, warning
if code == "ResourceNotFoundException" and attempt == 1:
outcome, warning = _provision_group(logs, group, retention_days, kms_key_id)
if outcome is not None:
return outcome, warning
continue
return "could not persist the score for later querying: %s" % e, warning
except BotoCoreError as e:
return "could not persist the score for later querying: %s" % e, warning
return None, warning
def _fetch_trace(omni, tid, window_days, session_id=None, span_cache=None, service_name=None):
"""Resolve one trace's session and fetch its spans via Omni SQL ONCE.
service_name keeps only that service's spans, so another agent sharing the trace is not
part of what is scored.
Returns {traceId, sessionId, spans, truncated} on success, or {traceId, error} on failure.
span_cache de-duplicates by resolved session id: two trace ids in the same session fetch
that session's spans only once.
"""
sid = session_id or _resolve_session_id(omni, tid, window_days)
if sid and not _SESSION_ID_RE.match(sid):
sid = None
cache_key = sid or ("trace:" + tid)
if span_cache is not None and cache_key in span_cache:
spans, truncated = span_cache[cache_key]
else:
filter_clause = (
("attributes['session.id'] = '%s'" % sid) if sid else ("traceId = '%s'" % tid)
)
if service_name:
filter_clause += " AND resource['attributes']['service.name'] = '%s'" % service_name
spans, truncated, err = _fetch_spans(omni, filter_clause, window_days, soft=True)
if spans is None:
return {"traceId": tid, "error": err or "span fetch query did not complete"}
if not spans:
where = " for service %s" % service_name if service_name else ""
return {
"traceId": tid,
"error": "no spans found%s in the last %d day(s)" % (where, window_days),
}
if span_cache is not None:
span_cache[cache_key] = (spans, truncated)
if sid and truncated and not any(s.get("traceId") == tid for s in spans):
return {
"traceId": tid,
"error": "session has more than %d spans and the target trace was not in the fetched "
"set; narrow --window-days, or the session is too large to score whole"
% SPAN_ROW_LIMIT,
}
return {"traceId": tid, "sessionId": sid, "spans": spans, "truncated": truncated}
def _score_pair(ac, tid, spans, evaluator_id, level, max_calls, include_explanations=False):
"""Score ONE (trace, evaluator) pair against the AgentCore data plane."""
evaluation_input = {"sessionSpans": spans}
calls, note = [], None
if level == "SESSION":
calls.append({"evaluatorId": evaluator_id, "evaluationInput": evaluation_input})
elif level == "TOOL_CALL":
span_ids = _tool_span_ids(spans)
if not span_ids:
return [], ["TOOL_CALL requested but the trace has no tool spans"], 0, None
for i in range(0, len(span_ids), MAX_TARGET_IDS):
calls.append(
{
"evaluatorId": evaluator_id,
"evaluationInput": evaluation_input,
"evaluationTarget": {"spanIds": span_ids[i: i + MAX_TARGET_IDS]},
}
)
if len(calls) > max_calls:
note = "tool-span scoring truncated to %d of %d chunks (evaluate-call budget)" % (
max(0, max_calls),
len(calls),
)
else: # TRACE
calls.append(
{
"evaluatorId": evaluator_id,
"evaluationInput": evaluation_input,
"evaluationTarget": {"traceIds": [tid]},
}
)
calls = calls[: max(0, max_calls)]
rows, errors = [], []
for kwargs in calls:
try:
resp = ac.evaluate(**kwargs)
except (BotoCoreError, ClientError) as e:
msg = str(e)
if "Unknown evaluator" in msg and "provider" in msg:
msg += (
" — this evaluator is listed by list-evaluators/get-evaluator but not "
"invocable by evaluate (a service-side discovery/evaluate mismatch, not "
"a payload problem); pick a different evaluator"
)
errors.append("evaluate failed: %s" % msg)
continue
resp.pop("ResponseMetadata", None)
rows.extend(_summarize(resp, evaluator_id, include_explanations))
return rows, errors, len(calls), note
MAX_TARGET_IDS = 10 # scoring service cap on ids per call (TOOL_CALL chunking)
def _run(args):
trace_ids = _split_ids(args.trace_ids, args.trace_id, lower=True)
if not trace_ids:
_die({"error": "no trace ids given (use --trace-id or --trace-ids)"})
for t in trace_ids:
if not _TRACE_ID_RE.match(t):
_die({"error": "invalid trace id (expected hex): %r" % t})
evaluator_ids = _split_ids(args.evaluator_ids, args.evaluator_id) or [DEFAULT_EVALUATOR]
if args.session_id:
if len(trace_ids) > 1:
_die(
{
"error": "--session-id applies to a single trace; omit it when scoring multiple traces"
}
)
if not _SESSION_ID_RE.match(args.session_id):
_die({"error": "invalid session id: %r" % args.session_id})
if args.service_name and not _SERVICE_NAME_RE.match(args.service_name):
_die({"error": "invalid service name: %r" % args.service_name})
if args.retention_days and args.retention_days not in _RETENTION_DAYS_ALLOWED:
_die(
{
"error": "invalid --retention-days %r; CloudWatch Logs accepts one of %s (or 0 to "
"leave the account default)"
% (args.retention_days, sorted(_RETENTION_DAYS_ALLOWED)),
}
)
notes = []
gt = [e for e in evaluator_ids if e in _GROUND_TRUTH_REQUIRED]
if gt:
evaluator_ids = [e for e in evaluator_ids if e not in _GROUND_TRUTH_REQUIRED]
msg = (
"evaluator(s) %s require ground truth (an expected tool trajectory), which a raw "
"trace does not carry — on-demand scoring here supplies no reference inputs. Use "
"ground-truth-free evaluators (e.g. Builtin.ToolSelectionAccuracy for tool quality), "
"or evaluate against a dataset whose examples carry the expected trajectory."
% ", ".join(gt)
)
if not evaluator_ids:
_die({"error": msg, "evaluatorIds": gt})
notes.append("skipped " + msg)
if len(evaluator_ids) > MAX_EVALUATORS:
dropped_evals = evaluator_ids[MAX_EVALUATORS:]
evaluator_ids = evaluator_ids[:MAX_EVALUATORS]
notes.append(
"capped to %d evaluators (≤%d-evaluator limit); NOT run: %s"
% (len(evaluator_ids), MAX_EVALUATORS, ", ".join(dropped_evals))
)
n_eval = len(evaluator_ids)
allowed_traces = min(MAX_TRACES, max(1, MAX_PAIRS // n_eval))
if len(trace_ids) > allowed_traces:
dropped = trace_ids[allowed_traces:]
trace_ids = trace_ids[:allowed_traces]
notes.append(
"capped to %d traces (≤%d-trace / ≤%d-pair limit at %d evaluators); NOT scored: %s"
% (len(trace_ids), MAX_TRACES, MAX_PAIRS, n_eval, ", ".join(dropped))
)
session = boto3.Session(region_name=args.region)
try:
omni = session.client("cloudwatchomni") # span queries via Omni SQL
except (
Exception
) as e: # noqa: BLE001 — e.g. UnknownServiceError on a boto3 too old for this service
_die(
{
"error": "could not create a cloudwatchomni client (is boto3 recent enough?): %s"
% e
}
)
logs = session.client("logs") # writeback only (put_log_events)
try:
ac = session.client(EVALUATE_SERVICE)
except (
Exception
) as e: # noqa: BLE001 — e.g. UnknownServiceError on a boto3 too old for this service
_die(
{
"error": "could not create a %s client (is boto3 recent enough?): %s"
% (EVALUATE_SERVICE, e)
}
)
fetched: dict = {}
fetch_errors: list = []
span_cache: dict = {}
for tid in trace_ids:
f = _fetch_trace(
omni,
tid,
args.window_days,
session_id=args.session_id if len(trace_ids) == 1 else None,
span_cache=span_cache,
service_name=args.service_name,
)
if f.get("error"):
fetch_errors.append({"traceId": tid, "error": f["error"]})
else:
fetched[tid] = f
results: list = []
score_errors: list = []
pairs_scored = 0
persisted_traces = 0
calls_budget = MAX_EVALUATE_CALLS
budget_hit = False
scored_sessions = set()
ts_ms = int(time.time() * 1000)
for tid in trace_ids:
if budget_hit:
break
f = fetched.get(tid)
if not f:
continue
trace_rows = []
for ev in evaluator_ids:
if args.level == "SESSION" and f["sessionId"]:
skey = (f["sessionId"], ev)
if skey in scored_sessions:
continue
scored_sessions.add(skey)
if calls_budget <= 0:
notes.append(
"evaluate-call budget (%d) reached; remaining (trace, evaluator) pairs "
"not scored" % MAX_EVALUATE_CALLS
)
budget_hit = True
break
rows, errs, made, pair_note = _score_pair(
ac, tid, f["spans"], ev, args.level, calls_budget, args.include_explanations
)
calls_budget -= made
for r in rows:
r["traceId"] = tid
if any(r.get("value") is not None and not r.get("errorCode") for r in rows):
pairs_scored += 1
trace_rows.extend(rows)
for err in errs:
score_errors.append({"traceId": tid, "evaluatorId": ev, "error": err})
if pair_note:
notes.append("trace %s / %s: %s" % (tid, ev, pair_note))
results.extend(trace_rows)
if trace_rows and not args.no_writeback:
service = _service_name(f["spans"], tid) or _UNKNOWN_SERVICE
records = _eval_result_records(
trace_rows, tid, f["sessionId"], args.level, service, ts_ms, partial=f["truncated"]
)
if records:
outcome, warning = _write_back(
logs, service, records, ts_ms, args.retention_days, args.kms_key_id
)
if warning and warning not in notes:
notes.append(warning)
if outcome is None:
persisted_traces += 1
elif outcome is not _WRITEBACK_SKIPPED:
notes.append("trace %s: %s" % (tid, outcome))
if f["truncated"]:
notes.append(
"trace %s: span fetch hit the %d-row cap; scored on a partial span set"
% (tid, SPAN_ROW_LIMIT)
)
if not results:
_die(
{
"error": "no (trace, evaluator) pair produced a score",
"level": args.level,
"evaluators": evaluator_ids,
"fetchErrors": fetch_errors,
"scoreErrors": score_errors,
}
)
scored_trace_ids = {
r["traceId"] for r in results if r.get("value") is not None and not r.get("errorCode")
}
receipt = {
"level": args.level,
"evaluators": evaluator_ids,
"tracesFetched": len(fetched),
"tracesScored": len(scored_trace_ids),
"pairsScored": pairs_scored,
"results": _public_rows(results),
}
if not args.no_writeback:
receipt["persistedTraces"] = persisted_traces
if persisted_traces:
receipt["resultsLogGroupPrefix"] = EVAL_RESULTS_LOG_GROUP_PREFIX
receipt["resultsRetentionDaysOnCreate"] = args.retention_days or "account default"
receipt["resultsKmsKeyOnCreate"] = args.kms_key_id or "aws-owned"
if notes:
receipt["notes"] = notes
if fetch_errors:
receipt["fetchErrors"] = fetch_errors
if score_errors:
receipt["scoreErrors"] = score_errors
print(json.dumps(receipt, default=str, indent=2))
def main():
ap = argparse.ArgumentParser(
description="Score CloudWatch Omni traces on demand with AgentCore evaluators."
)
ap.add_argument(
"--trace-id", help="a single trace id to score (or use --trace-ids for a batch)"
)
ap.add_argument("--trace-ids", help="comma-separated trace ids to score (each fetched once)")
ap.add_argument(
"--session-id",
help="the trace's session id (single-trace only; resolved automatically if omitted)",
)
ap.add_argument("--evaluator-id", help="a single evaluator id, e.g. Builtin.Helpfulness")
ap.add_argument(
"--evaluator-ids",
help="comma-separated evaluator ids; every one is run against every trace",
)
ap.add_argument("--level", choices=LEVELS, default="TRACE")
ap.add_argument(
"--service-name",
help="score only spans whose resource service.name matches, for a trace shared by "
"several agents",
)
ap.add_argument("--region", default="us-east-1")
ap.add_argument("--window-days", type=int, default=30, help="trace lookback window")
ap.add_argument(
"--include-explanations",
action="store_true",
help="return the evaluator's explanation text in the receipt. Off by default because it "
"quotes the agent's own inputs and outputs, and the receipt reaches the calling agent's "
"context and transcript; without it each row reports hasExplanation instead. The "
"persisted record carries the explanation either way",
)
ap.add_argument(
"--no-writeback",
action="store_true",
help="do not persist scores to the evaluation-results log group (persisted by default "
"so they can be queried later)",
)
ap.add_argument(
"--retention-days",
type=int,
default=DEFAULT_RESULTS_RETENTION_DAYS,
help="retention for the evaluation-results log group when this run CREATES it, bounding "
"how long the persisted explanations live (default %d, 0 leaves the account default; a "
"pre-existing group keeps its own)" % DEFAULT_RESULTS_RETENTION_DAYS,
)
ap.add_argument(
"--kms-key-id",
help="customer-managed KMS key (arn or id) to encrypt the evaluation-results log group "
"with when this run CREATES it; its key policy must allow the CloudWatch Logs service "
"principal (default: AWS-owned key)",
)
args = ap.parse_args()
try:
_run(args)
except Exception as e: # noqa: BLE001
_die({"error": "unexpected error: %s" % e})
if __name__ == "__main__":
main()
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an MCP server, Needs a command-line tool, Needs Python, Needs Node.js
Security
What our scanner noticed· Low risk
- Phrasing that asks for actions without confirmation
- Instructions that use the network
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- AWS
- Publisher
- AWS (Official)
- Source
- aws/agent-toolkit-for-aws · skills/core-skills/aws-observability · commit 0a218f8
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- NOTICE (added by AutoGineer from NOTICE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Also distributed in
- aws/agent-toolkit-for-aws
AutoGineer is not affiliated with or endorsed by AWS or AWS.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download aws-observability.zip