Find EC2 Instances Paying for Detailed Monitoring

Front of a server rack with rows of small green and blue status lights

Photo by Syed Ahmad on Unsplash

EC2 detailed monitoring cost is the CloudWatch custom metric price for each metric the instance sends every minute: $0.30 per metric-month for the first 10,000 metrics in us-east-1 (September 2026). At the 7 metrics AWS uses in its pricing example, that’s $2.10 per instance-month. Find the instances with DescribeInstances filtered on monitoring-state and turn it off with UnmonitorInstances.

Basic monitoring sends EC2 metrics to CloudWatch every 5 minutes at no charge. Detailed monitoring sends them every minute and bills them as custom metrics. It’s a checkbox in a launch template, and a default when you create a launch configuration with the AWS CLI, so it often ends up on fleets that never look at 1-minute data. This example is for engineers and FinOps reviewers who want the list, the EC2 detailed monitoring cost behind it, and the Auto Scaling settings that keep switching it back on.

The script reports by default. It changes only instances you name with --apply --instance-ids.

What does EC2 detailed monitoring cost?

CloudWatch prices detailed monitoring as custom metrics, with no API charge for sending them, prorated by the hour and charged only in hours when metrics are sent. A stopped instance sends nothing and costs nothing. As of September 2026, the AWS Price List for Amazon CloudWatch in US East (N. Virginia), published 22 September 2026, lists:

Custom metrics per month (us-east-1) Price per metric-month
First 10,000 $0.30
Next 240,000 $0.10
Next 750,000 $0.05
Over 1,000,000 $0.02

The number of metrics depends on the instance type. The CloudWatch pricing page’s example assumes 7, and mentions an instance with 12 costing up to $3.60 a month at the top tier. The script uses 7 by default; change it with --metrics-per-instance.

Worked example: 40 always-on instances with detailed monitoring, 7 metrics each, is 280 metrics × $0.30 = $84.00 a month, or $1,008 a year. If 30 of them sit in groups without scaling policies, $63.00 of that buys data nobody acts on. The tiers apply to all custom metrics in the account, so if you already publish more than 10,000, the marginal price is lower. To see the whole CloudWatch line first, getting the total cost of AWS CloudWatch for the current month breaks it down.

Who actually needs 1-minute EC2 metrics?

  • Auto Scaling groups with scaling policies. The EC2 Auto Scaling guide strongly recommends detailed monitoring when a group has a scaling policy, because 1-minute data lets it react faster. The script marks those instances “keep”.
  • Alarms that must fire within minutes. An alarm on a 60-second period needs 1-minute data. With basic monitoring, set its period to 300 seconds.
  • Short incident investigations. Turn it on for the investigation and off afterwards; it takes one API call each way.

Everything else, like bastion hosts, CI runners, fixed-size worker pools and dev boxes, is usually fine with 5-minute data. Custom application metrics are a separate charge; publishing custom CloudWatch metrics with SDK v3 covers how those are priced.

What does the script do?

  1. Lists instances with detailed monitoringpaginateDescribeInstances with the filters monitoring-state=enabled and instance-state-name=pending,running.
  2. Maps them to Auto Scaling groupsThrough the aws:autoscaling:groupName tag.
  3. Reads each group’s launch settingsThe launch template version it uses (DescribeLaunchTemplateVersions, Monitoring.Enabled), including mixed instances policies, or its launch configuration (InstanceMonitoring.Enabled).
  4. Reads scaling policiespaginateDescribePolicies, ignoring disabled ones.
  5. Gives a verdictKeep, fix the launch settings, or candidate. With --apply, calls UnmonitorInstances for the IDs you named.

Prerequisites

  • Node.js 18 or later with tsx, @aws-sdk/client-ec2 and @aws-sdk/client-auto-scaling.
  • A read-only profile; connecting AWS profiles, SSO and assumed roles covers setup. Use a separate role for --apply.
  • A list of alarms on EC2 metrics with 60-second periods, so you know which ones to adjust.

Which IAM permissions does it need?

ec2-detailed-monitoring-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadInstancesAndAutoScaling",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeLaunchTemplateVersions",
        "autoscaling:DescribeAutoScalingGroups",
        "autoscaling:DescribeLaunchConfigurations",
        "autoscaling:DescribePolicies"
      ],
      "Resource": "*"
    },
    {
      "Sid": "SwitchToBasicOnlyWithApply",
      "Effect": "Allow",
      "Action": "ec2:UnmonitorInstances",
      "Resource": "arn:aws:ec2:*:123456789012:instance/*"
    }
  ]
}

The Describe actions don’t support resource-level permissions, so they use *. Remove the second statement for report-only runs. The IAM policy generator for TypeScript code derives the same list from the script.

The script to find EC2 instances with detailed monitoring

find-ec2-detailed-monitoring.ts

// find-ec2-detailed-monitoring.ts
// Lists running EC2 instances with detailed (1-minute) monitoring, prices it as CloudWatch metrics,
// and shows which Auto Scaling groups turn it on and whether they have scaling policies that need it.
// Report only by default. With --apply --instance-ids it switches the named instances back to basic monitoring.
// Usage: npx tsx find-ec2-detailed-monitoring.ts [--regions us-east-1,eu-west-1] [--metrics-per-instance 7]
//        npx tsx find-ec2-detailed-monitoring.ts --regions us-east-1 --apply --instance-ids i-0abc,i-0def
import {
  EC2Client,
  paginateDescribeInstances,
  DescribeLaunchTemplateVersionsCommand,
  UnmonitorInstancesCommand,
  type Instance,
} from "@aws-sdk/client-ec2";
import {
  AutoScalingClient,
  paginateDescribeAutoScalingGroups,
  paginateDescribeLaunchConfigurations,
  paginateDescribePolicies,
  type AutoScalingGroup,
} from "@aws-sdk/client-auto-scaling";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const metricsPerInstance = Math.max(Number(flag("--metrics-per-instance") ?? 7), 1);
const apply = args.includes("--apply");
const applyIds = new Set((flag("--instance-ids") ?? "").split(",").map((v) => v.trim()).filter(Boolean));

// Detailed monitoring is billed as custom metrics: $0.30 per metric-month for the first 10,000 metrics
// (us-east-1, AWS Price List for CloudWatch published 22 September 2026), prorated by the hour.
const PRICE_PER_METRIC_MONTH = 0.3;

interface GroupInfo {
  name: string;
  source: string;
  enablesDetailed: boolean | undefined;
  policies: string[];
}

interface Row {
  Instance: string;
  Name: string;
  Type: string;
  AutoScalingGroup: string;
  ScalingPolicies: string;
  PerMonth: string;
  Verdict: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const tag = (i: Instance, key: string): string | undefined => i.Tags?.find((t) => t.Key === key)?.Value;

/** For each Auto Scaling group: where its launch settings come from, whether they enable detailed monitoring, and its scaling policies. */
async function readGroups(region: string, ec2: EC2Client): Promise<Map<string, GroupInfo>> {
  const asg = new AutoScalingClient({ region });
  const groups: AutoScalingGroup[] = [];
  for await (const page of paginateDescribeAutoScalingGroups({ client: asg }, {})) groups.push(...(page.AutoScalingGroups ?? []));
  const launchConfigs = new Map<string, boolean | undefined>();
  for await (const page of paginateDescribeLaunchConfigurations({ client: asg }, {})) {
    for (const lc of page.LaunchConfigurations ?? []) launchConfigs.set(lc.LaunchConfigurationName ?? "", lc.InstanceMonitoring?.Enabled);
  }
  const policies = new Map<string, string[]>();
  for await (const page of paginateDescribePolicies({ client: asg }, {})) {
    for (const p of page.ScalingPolicies ?? []) {
      if (p.Enabled === false) continue;
      const list = policies.get(p.AutoScalingGroupName ?? "") ?? [];
      list.push(p.PolicyType ?? "policy");
      policies.set(p.AutoScalingGroupName ?? "", list);
    }
  }
  const out = new Map<string, GroupInfo>();
  for (const g of groups) {
    const name = g.AutoScalingGroupName ?? "";
    const info: GroupInfo = { name, source: "-", enablesDetailed: undefined, policies: policies.get(name) ?? [] };
    const lt = g.LaunchTemplate ?? g.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification;
    if (lt?.LaunchTemplateId || lt?.LaunchTemplateName) {
      const version = lt.Version ?? "$Default";
      info.source = `template ${lt.LaunchTemplateName ?? lt.LaunchTemplateId} (${version})`;
      try {
        const res = await ec2.send(
          new DescribeLaunchTemplateVersionsCommand({ LaunchTemplateId: lt.LaunchTemplateId, LaunchTemplateName: lt.LaunchTemplateId ? undefined : lt.LaunchTemplateName, Versions: [version] }),
        );
        info.enablesDetailed = res.LaunchTemplateVersions?.[0]?.LaunchTemplateData?.Monitoring?.Enabled ?? false;
      } catch (err) {
        info.source += ` (unreadable: ${errorText(err)})`;
      }
    } else if (g.LaunchConfigurationName) {
      info.source = `launch configuration ${g.LaunchConfigurationName}`;
      info.enablesDetailed = launchConfigs.get(g.LaunchConfigurationName);
    }
    out.set(name, info);
  }
  return out;
}

function verdict(group: GroupInfo | undefined): string {
  if (!group) return "candidate: not in an Auto Scaling group";
  if (group.policies.length) return "keep: scaling policies react faster with 1-minute data";
  if (group.enablesDetailed) return `fix the source: ${group.source} turns it back on for new instances`;
  return "candidate: group has no scaling policies";
}

async function scanRegion(region: string): Promise<void> {
  const ec2 = new EC2Client({ region });
  const instances: Instance[] = [];
  const filters = [
    { Name: "monitoring-state", Values: ["enabled"] },
    { Name: "instance-state-name", Values: ["pending", "running"] },
  ];
  for await (const page of paginateDescribeInstances({ client: ec2 }, { Filters: filters })) {
    for (const r of page.Reservations ?? []) instances.push(...(r.Instances ?? []));
  }
  if (instances.length === 0) {
    console.log(`${region}: no running instances with detailed monitoring`);
    return;
  }
  const groups = await readGroups(region, ec2);
  const perInstance = metricsPerInstance * PRICE_PER_METRIC_MONTH;
  const rows: Row[] = instances.map((i) => {
    const groupName = tag(i, "aws:autoscaling:groupName");
    const group = groupName ? groups.get(groupName) : undefined;
    return {
      Instance: i.InstanceId ?? "",
      Name: tag(i, "Name") ?? "-",
      Type: i.InstanceType ?? "",
      AutoScalingGroup: groupName ?? "-",
      ScalingPolicies: group?.policies.join(", ") || "-",
      PerMonth: `$${perInstance.toFixed(2)}`,
      Verdict: verdict(group),
    };
  });
  rows.sort((a, b) => a.Verdict.localeCompare(b.Verdict));
  console.log(`\n${region}: ${rows.length} instances with detailed monitoring (${metricsPerInstance} metrics each assumed)`);
  console.table(rows);
  const optional = rows.filter((r) => !r.Verdict.startsWith("keep")).length;
  console.log(
    `Detailed monitoring: about $${(rows.length * perInstance).toFixed(2)} a month; $${(optional * perInstance).toFixed(2)} of it is on instances without scaling policies.`,
  );
  const sources = [...groups.values()].filter((g) => g.enablesDetailed);
  if (sources.length) {
    console.log("Auto Scaling groups whose launch settings enable detailed monitoring:");
    console.table(sources.map((g) => ({ Group: g.name, Source: g.source, ScalingPolicies: g.policies.join(", ") || "-" })));
  }
  if (apply) {
    const chosen = rows.filter((r) => applyIds.has(r.Instance)).map((r) => r.Instance);
    if (chosen.length === 0) {
      console.log("Nothing to apply: none of --instance-ids has detailed monitoring in this region.");
      return;
    }
    try {
      const res = await ec2.send(new UnmonitorInstancesCommand({ InstanceIds: chosen }));
      for (const m of res.InstanceMonitorings ?? []) console.log(`${m.InstanceId}: monitoring ${m.Monitoring?.State}`);
    } catch (err) {
      console.error(`UnmonitorInstances: ${errorText(err)}`);
    }
  }
}

async function main(): Promise<void> {
  if (apply && applyIds.size === 0) {
    console.error("--apply needs --instance-ids i-...,i-... (the script never changes instances you didn't name)");
    process.exit(2);
  }
  for (const region of regions) {
    try {
      await scanRegion(region);
    } catch (err) {
      console.error(`${region}: ${errorText(err)}`);
    }
  }
}

main().catch((err) => {
  console.error(errorText(err));
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-auto-scaling
npm install --save-dev tsx typescript @types/node

# Report only
AWS_PROFILE=readonly npx tsx find-ec2-detailed-monitoring.ts --regions us-east-1,eu-west-1

# Switch two standalone instances to basic monitoring
AWS_PROFILE=ec2-admin npx tsx find-ec2-detailed-monitoring.ts --regions us-east-1 --apply --instance-ids i-0bast,i-0jenk

Sample output

Output

us-east-1: 7 instances with detailed monitoring (7 metrics each assumed)
┌─────────┬───────────┬──────────────┬──────────────┬──────────────────┬─────────────────────────┬──────────┬─────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Instance  │ Name         │ Type         │ AutoScalingGroup │ ScalingPolicies         │ PerMonth │ Verdict                                                                             │
├─────────┼───────────┼──────────────┼──────────────┼──────────────────┼─────────────────────────┼──────────┼─────────────────────────────────────────────────────────────────────────────────────┤
│ 0       │ 'i-0bast' │ 'bastion'    │ 't3.micro'   │ '-'              │ '-'                     │ '$2.10'  │ 'candidate: not in an Auto Scaling group'                                           │
│ 1       │ 'i-0jenk' │ 'jenkins'    │ 'm5.xlarge'  │ '-'              │ '-'                     │ '$2.10'  │ 'candidate: not in an Auto Scaling group'                                           │
│ 2       │ 'i-0old1' │ 'legacy-api' │ 'm5.large'   │ 'legacy-asg'     │ '-'                     │ '$2.10'  │ 'fix the source: launch configuration legacy-lc turns it back on for new instances' │
│ 3       │ 'i-0wrk1' │ 'worker'     │ 'c6i.xlarge' │ 'worker-asg'     │ '-'                     │ '$2.10'  │ 'fix the source: template worker ($Default) turns it back on for new instances'     │
│ 4       │ 'i-0wrk2' │ 'worker'     │ 'c6i.xlarge' │ 'worker-asg'     │ '-'                     │ '$2.10'  │ 'fix the source: template worker ($Default) turns it back on for new instances'     │
│ 5       │ 'i-0web1' │ 'web'        │ 'm6i.large'  │ 'web-asg'        │ 'TargetTrackingScaling' │ '$2.10'  │ 'keep: scaling policies react faster with 1-minute data'                            │
│ 6       │ 'i-0web2' │ 'web'        │ 'm6i.large'  │ 'web-asg'        │ 'TargetTrackingScaling' │ '$2.10'  │ 'keep: scaling policies react faster with 1-minute data'                            │
└─────────┴───────────┴──────────────┴──────────────┴──────────────────┴─────────────────────────┴──────────┴─────────────────────────────────────────────────────────────────────────────────────┘
Detailed monitoring: about $14.70 a month; $10.50 of it is on instances without scaling policies.
Auto Scaling groups whose launch settings enable detailed monitoring:
┌─────────┬──────────────┬──────────────────────────────────┬─────────────────────────┐
│ (index) │ Group        │ Source                           │ ScalingPolicies         │
├─────────┼──────────────┼──────────────────────────────────┼─────────────────────────┤
│ 0       │ 'web-asg'    │ 'template web ($Latest)'         │ 'TargetTrackingScaling' │
│ 1       │ 'worker-asg' │ 'template worker ($Default)'     │ '-'                     │
│ 2       │ 'legacy-asg' │ 'launch configuration legacy-lc' │ '-'                     │
└─────────┴──────────────┴──────────────────────────────────┴─────────────────────────┘
i-0bast: monitoring disabling
i-0jenk: monitoring disabling

This run used mocked EC2 and Auto Scaling responses. The two web-asg instances stay: the group scales on a target tracking policy. worker-asg and legacy-asg have no scaling policies, but their launch settings enable detailed monitoring, so switching the running instances off would only last until the next replacement. The bastion and Jenkins hosts aren’t in any group and were the two IDs passed with --apply; their state reads disabling until the change completes.

How do you stop Auto Scaling groups turning it back on?

The EC2 Auto Scaling guide says existing instances keep their monitoring type when you change a group’s launch template or configuration. So fix the source first, then the instances:

  1. Create a new launch template versionSet Monitoring.Enabled to false and make it the version the group uses ($Default, $Latest or a number, whichever the group points at).
  2. Replace launch configurationsYou can’t edit a launch configuration. Move the group to a launch template; finding Auto Scaling groups still on launch configurations lists them.
  3. Roll the instancesUse an instance refresh, or call UnmonitorInstances on the running instances.
  4. Update alarmsFor step and simple scaling alarms, AWS says to match the period to the monitoring type (300 seconds for basic). An alarm left at 60 seconds may find no data in four out of every five periods.

This is the “measure, then remove what nobody uses” loop that the FinOps Foundation describes as workload optimization. The instances themselves are often the bigger saving: detecting underutilized EC2 instances by CPU and finding burstable instances with unlimited mode charges cover that.

Troubleshooting

  • Instances come back with detailed monitoring. The group’s launch template or launch configuration enables it. Check the “fix the source” rows and the second table.
  • “template … (unreadable …)”. The role lacks ec2:DescribeLaunchTemplateVersions, or the group points at a version that was deleted.
  • The CloudWatch bill didn’t drop as expected. The metric count per instance varies by instance type, and other sources publish custom metrics too. Logs are often the larger line; the script to find the top CloudWatch log groups by ingestion covers them.
  • Access denied on UnmonitorInstances. Compare the role with the policy above, then see troubleshooting AWS IAM access denied errors.

Ask ChatWithCloud instead

For a quick answer, ask ChatWithCloud “Which running EC2 instances have detailed monitoring enabled, and which Auto Scaling groups are they in?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud answers AWS questions locally explains the loop. It can be wrong and runs changes without a confirmation step, so use a read-only profile and switch monitoring yourself. If the CloudWatch line is what jumped, start with asking AI why your AWS bill increased. More reports live in the AWS practical examples library.

Frequently asked questions

How much does EC2 detailed monitoring cost?

Each metric costs the custom metric price, $0.30 per metric-month for the first 10,000 in us-east-1 as of September 2026. At 7 metrics per instance, about $2.10 a month, prorated by the hour.

Is EC2 basic monitoring free?

Yes. Basic monitoring sends metrics every 5 minutes and is included; detailed monitoring sends them every minute and is charged.

How do I disable detailed monitoring on an EC2 instance?

Call UnmonitorInstances (or aws ec2 unmonitor-instances) with the instance IDs. For Auto Scaling groups, also change the launch template or configuration.

Do I need detailed monitoring for Auto Scaling?

Not strictly, but AWS strongly recommends it for groups with scaling policies, because 1-minute data makes scaling react faster.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud