plutus-scan 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/bin/plutus-scan.js +4 -0
- package/package.json +41 -4
- package/src/analysis/anomalies.js +227 -0
- package/src/analysis/breakdowns.js +41 -0
- package/src/analysis/candidate-rules-focus.js +131 -0
- package/src/analysis/candidates.js +326 -0
- package/src/analysis/drivers.js +119 -0
- package/src/analysis/forecast.js +217 -0
- package/src/analysis/monthly.js +89 -0
- package/src/cli.js +625 -0
- package/src/model.js +144 -0
- package/src/report/client.js +643 -0
- package/src/report/dashboard.js +492 -0
- package/src/report/data.js +222 -0
- package/src/report/headline.js +137 -0
- package/src/report/html.js +442 -0
- package/src/report/markdown.js +97 -0
- package/src/report/plutus-link.js +25 -0
- package/src/report/releases.js +31 -0
- package/src/report/sparkline.js +46 -0
- package/src/report/template.html +451 -0
- package/src/report/terminal.js +255 -0
- package/src/sources/anthropic.js +102 -0
- package/src/sources/aws.js +381 -0
- package/src/sources/demo.js +274 -0
- package/src/sources/focus/csv.js +113 -0
- package/src/sources/focus/index.js +287 -0
- package/src/sources/focus/parquet.js +74 -0
- package/src/sources/focus/rows.js +127 -0
- package/src/sources/github-auth.js +79 -0
- package/src/sources/github.js +313 -0
- package/src/sources/openai.js +121 -0
- package/src/transport.js +159 -0
- package/src/tui/app.js +158 -0
- package/src/tui/screen.js +78 -0
- package/src/tui/text.js +67 -0
- package/src/tui/views/anomalies.js +110 -0
- package/src/tui/views/drivers.js +115 -0
- package/src/tui/views/list.js +68 -0
- package/src/tui/views/overview.js +82 -0
- package/src/ui/progress.js +231 -0
- package/src/ui/theme.js +100 -0
- package/src/ui/widgets.js +230 -0
- package/src/version.js +6 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate rules for Azure and Google Cloud, read from FOCUS exports. A FOCUS row has no AWS usage type:
|
|
3
|
+
* the row model's `usageType` is the row's `ChargeDescription` (the provider's own words for the SKU) and
|
|
4
|
+
* `service` its `ServiceName`, so these rules match those two, not a usage type. No region prefix is
|
|
5
|
+
* stripped; both providers put the region at the end of a description or not at all, and the patterns
|
|
6
|
+
* stop before it.
|
|
7
|
+
*
|
|
8
|
+
* Every pattern is anchored on a string seen in real data, recorded in
|
|
9
|
+
* test/fixtures/candidates/focus-strings.json with where it was seen:
|
|
10
|
+
* - Azure: ChargeDescription values of the official FOCUS 1.0 sample data (its 100,000-row file has 4,964
|
|
11
|
+
* Microsoft rows), which is real Azure Cost Management output.
|
|
12
|
+
* - Google Cloud: the sample data has two Google Cloud rows only, so the rest are SKU descriptions printed
|
|
13
|
+
* in Google's own documentation (the Cloud NAT pricing page, the Cloud Billing legacy SKU list and the
|
|
14
|
+
* Cloud Billing query examples). Google's FOCUS view fills ChargeDescription from the SKU description.
|
|
15
|
+
* A signal with no string found in either was left out rather than guessed. Rules follow the same
|
|
16
|
+
* conventions as candidates.js: disjoint, bill-only, `strength` strict (when unsure, `look`), and no
|
|
17
|
+
* `plutusFix` (Plutus fixes no Azure or Google Cloud waste yet).
|
|
18
|
+
*
|
|
19
|
+
* @typedef {import('./candidates.js').CandidateRule} CandidateRule
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @param {RegExp} re @returns {(service: string) => boolean} */
|
|
23
|
+
const inService = (re) => (service) => re.test(service);
|
|
24
|
+
const isMonitor = inService(/^azure monitor$/i);
|
|
25
|
+
const isCloudSql = inService(/cloud sql/i);
|
|
26
|
+
const isCompute = inService(/compute engine/i);
|
|
27
|
+
const isArtifactRegistry = inService(/^artifact registry$/i);
|
|
28
|
+
|
|
29
|
+
/** @type {CandidateRule[]} */
|
|
30
|
+
export const AZURE_CANDIDATE_RULES = [
|
|
31
|
+
{
|
|
32
|
+
id: 'azure-old-vm-series', provider: 'azure', strength: 'strong', title: 'Older VM series',
|
|
33
|
+
why: 'Previous-generation series; newer ones are available',
|
|
34
|
+
// Series that Microsoft's size overview no longer lists among the current D-family series.
|
|
35
|
+
test: (u) => /^Virtual Machines (?:Dv2\/DSv2|Dv3\/DSv3|Dsv4) Series - /i.test(u),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'azure-hdd-disks', provider: 'azure', strength: 'look', title: 'Standard HDD managed disks',
|
|
39
|
+
why: 'Disk size tiers and operations bill whether or not the disk is busy',
|
|
40
|
+
test: (u) => /^Standard HDD Managed Disks - /i.test(u),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'azure-premium-disks', provider: 'azure', strength: 'look', title: 'Premium SSD managed disks',
|
|
44
|
+
why: 'Billed by size tier, so an oversized disk costs the same as a busy one',
|
|
45
|
+
test: (u) => /^Premium SSD Managed Disks - /i.test(u),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'azure-page-blob-disks', provider: 'azure', strength: 'look', title: 'Unmanaged page blob disks',
|
|
49
|
+
why: 'Page blob disks are the older, unmanaged disk model',
|
|
50
|
+
test: (u, service) => /^(?:Standard|Premium) Page Blob - .*\bDisk\b/i.test(u) && /storage accounts/i.test(service),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'azure-public-ip', provider: 'azure', strength: 'look', title: 'Public IP addresses',
|
|
54
|
+
why: 'Each public IP address bills by the hour; check each is still in use',
|
|
55
|
+
test: (u) => /^IP Addresses - (?:Basic|Standard) IPv4 (?:Static|Dynamic) Public IP(?: - |$)/i.test(u),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'azure-log-ingestion', provider: 'azure', strength: 'look', title: 'Log Analytics ingestion',
|
|
59
|
+
why: 'Log volume, not queries, is what this line bills',
|
|
60
|
+
test: (u, service) => /^Log Analytics - Pay-as-you-go Data Ingestion(?: - |$)/i.test(u) && isMonitor(service),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'azure-log-retention', provider: 'azure', strength: 'look', title: 'Log Analytics retention',
|
|
64
|
+
why: 'Logs kept past the included period keep billing by the month',
|
|
65
|
+
test: (u, service) => /^Log Analytics - Pay-as-you-go Data Retention(?: - |$)/i.test(u) && isMonitor(service),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'azure-inter-region', provider: 'azure', strength: 'look', title: 'Inter-region data transfer',
|
|
69
|
+
why: 'Traffic leaving one Azure region for another',
|
|
70
|
+
test: (u) => /^Bandwidth Inter-Region - /i.test(u),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'azure-egress', provider: 'azure', strength: 'look', title: 'Internet data transfer out',
|
|
74
|
+
why: 'Check what is sending it',
|
|
75
|
+
test: (u) => /^Rtn Preference: MGN - Standard Data Transfer Out(?: - |$)/i.test(u),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'azure-vpn-gateway', provider: 'azure', strength: 'look', title: 'VPN gateways',
|
|
79
|
+
why: 'A gateway bills by the hour whether or not traffic flows',
|
|
80
|
+
test: (u) => /^VPN Gateway - /i.test(u),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'azure-backup', provider: 'azure', strength: 'look', title: 'Backup instances and stored data',
|
|
84
|
+
why: 'Protected instances and stored backup data bill monthly; retention drives it',
|
|
85
|
+
test: (u) => /^Backup - /i.test(u),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** @type {CandidateRule[]} */
|
|
90
|
+
export const GCP_CANDIDATE_RULES = [
|
|
91
|
+
{
|
|
92
|
+
id: 'gcp-sql-idle-ip', provider: 'gcp', strength: 'strong', title: 'Idle Cloud SQL IP addresses',
|
|
93
|
+
why: 'Billed while the address is reserved but not in use',
|
|
94
|
+
test: (u, service) => /^IP address idling\b/i.test(u) && isCloudSql(service),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'gcp-static-ip', provider: 'gcp', strength: 'look', title: 'Static external IP addresses',
|
|
98
|
+
why: 'A reserved address bills by the hour; check each is attached',
|
|
99
|
+
test: (u) => /^Static Ip Charge$/i.test(u),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'gcp-pd-standard', provider: 'gcp', strength: 'look', title: 'Standard persistent disks',
|
|
103
|
+
why: 'Provisioned capacity bills whether or not the disk is busy',
|
|
104
|
+
test: (u, service) => /^Storage PD Capacity$/i.test(u) && isCompute(service),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'gcp-pd-snapshots', provider: 'gcp', strength: 'look', title: 'Persistent disk snapshots',
|
|
108
|
+
why: 'Old snapshots tend to accumulate',
|
|
109
|
+
test: (u) => /^Storage PD Snapshot$/i.test(u),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'gcp-nat', provider: 'gcp', strength: 'look', title: 'Cloud NAT',
|
|
113
|
+
why: 'Often traffic that could use Private Google Access',
|
|
114
|
+
test: (u) => /^Networking (?:Cloud|Private) NAT (?:IP usage|data processing|gateway uptime)$/i.test(u),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'gcp-n1', provider: 'gcp', strength: 'look', title: 'N1 instances',
|
|
118
|
+
why: 'N1 is the oldest general-purpose family; newer families are available',
|
|
119
|
+
test: (u) => /^N1 Predefined Instance (?:Core|Ram) running in /i.test(u),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'gcp-lb-forwarding', provider: 'gcp', strength: 'look', title: 'Load balancer forwarding rules',
|
|
123
|
+
why: 'Each forwarding rule bills by the hour',
|
|
124
|
+
test: (u) => /^Cloud Load Balancer Forwarding Rule\b/i.test(u),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'gcp-artifact-registry', provider: 'gcp', strength: 'look', title: 'Artifact Registry storage',
|
|
128
|
+
why: 'Old and untagged images accumulate without a cleanup policy',
|
|
129
|
+
test: (u, service) => /^Artifact Registry Storage$/i.test(u) && isArtifactRegistry(service),
|
|
130
|
+
},
|
|
131
|
+
];
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing candidates to review: AWS usage types, and Azure and Google Cloud FOCUS charge descriptions, that often hide savings.
|
|
3
|
+
* Rule table from docs/design.md (Analysis, candidates to review).
|
|
4
|
+
*/
|
|
5
|
+
import { lastNDays } from '../model.js';
|
|
6
|
+
import { AZURE_CANDIDATE_RULES, GCP_CANDIDATE_RULES } from './candidate-rules-focus.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ usageType: string, cost: number }} CandidateUsage */
|
|
9
|
+
/** @typedef {'strong' | 'look'} Strength */
|
|
10
|
+
/** @typedef {{ id: string, provider: string, title: string, why: string, cost30d: number, strength: Strength,
|
|
11
|
+
* plutusFix: 'pull_request' | null, usageTypes: CandidateUsage[] }} Candidate */
|
|
12
|
+
|
|
13
|
+
export const CANDIDATE_MIN_COST = 10;
|
|
14
|
+
|
|
15
|
+
const OLD_FAMILIES = ['m1', 'm2', 'm3', 'c1', 'c3', 'cc2', 'cr1', 'g2', 'hi1', 'hs1', 'i2', 'r3', 't1', 'm4', 'c4', 'r4', 't2'];
|
|
16
|
+
const CLOUDWATCH = ['AmazonCloudWatch', 'Amazon CloudWatch'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Service gates, for the usage types that several services bill under the same name (`TimedStorage-ByteHrs`
|
|
20
|
+
* is S3 storage, CloudWatch Logs storage and ECR storage). Matched without case and on a fragment of the
|
|
21
|
+
* name, because Cost Explorer and FOCUS exports spell the same service a little differently.
|
|
22
|
+
*/
|
|
23
|
+
const inService = (re) => (/** @type {string} */ service) => re.test(service);
|
|
24
|
+
const isS3 = inService(/simple storage service/i);
|
|
25
|
+
const isCloudWatch = inService(/cloudwatch/i);
|
|
26
|
+
const isElb = inService(/elastic load balancing/i);
|
|
27
|
+
const isEcr = inService(/container registry/i);
|
|
28
|
+
/** RDS instance families older than the current ones, as in the `old-gen` rule for EC2. */
|
|
29
|
+
const OLD_RDS = /^(?:InstanceUsage|Multi-AZUsage):db\.(?:m1|m2|m3|m4|r3|r4|t1|t2)\./;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `plutusFix` says whether Plutus can open a pull request that fixes this kind of waste. It is the one
|
|
33
|
+
* place that fact lives; every output reads it from the candidate.
|
|
34
|
+
* `strength` says how much the billing line alone says: `strong` when the line itself is evidence of
|
|
35
|
+
* something usually worth changing (an idle resource, a superseded generation or storage class with a
|
|
36
|
+
* cheaper like-for-like successor, a surcharge); `look` when it is a legitimate cost centre that is only
|
|
37
|
+
* sometimes waste. When unsure a rule is `look`. The test is in docs/design.md, Candidate strength.
|
|
38
|
+
* @typedef {{ id: string, provider: string, title: string, why: string, strength: Strength, plutusFix?: 'pull_request',
|
|
39
|
+
* test: (usageType: string, service: string, rawUsageType: string) => boolean }} CandidateRule
|
|
40
|
+
* `usageType` has any region prefix stripped; `rawUsageType` is as billed, for the few names that begin
|
|
41
|
+
* with a token the strip would take for a region (`KMS-Keys`, `AWS-Out-Bytes`).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The rule table. Every pattern was checked against the AWS Price List bulk API (the us-east-1
|
|
46
|
+
* `index.csv` of each service offer, fetched 2026-11-12), which lists real `usageType` values, and each
|
|
47
|
+
* service gate against the `ServiceName` values of the official FOCUS 1.0 sample. The values each rule
|
|
48
|
+
* was checked against, and the offer each came from, are in test/fixtures/candidates/usage-types.json,
|
|
49
|
+
* which candidates.test.js reads. Rules are disjoint: a usage type matches at most one of them, so table
|
|
50
|
+
* order only settles how candidates of equal cost print. A rule reads only billing names: nothing here
|
|
51
|
+
* knows whether a resource is idle, only that its kind of charge is one worth a look.
|
|
52
|
+
*
|
|
53
|
+
* @type {CandidateRule[]}
|
|
54
|
+
*/
|
|
55
|
+
const AWS_RULES = [
|
|
56
|
+
// Rules from the first release.
|
|
57
|
+
{
|
|
58
|
+
id: 'idle-ip', strength: 'strong', title: 'Idle public IPs', why: 'Billed while attached to nothing',
|
|
59
|
+
test: (u) => u === 'ElasticIP:IdleAddress' || u === 'PublicIPv4:IdleAddress',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'extended-support', strength: 'strong', title: 'Extended support charges', why: 'RDS or EKS versions past standard support',
|
|
63
|
+
// EKS bills `AmazonEKS-Hours:extendedSupport`, RDS `ExtendedSupport:Yr1-Yr2:...`, OpenSearch `OpenSearchExtendedSupport`.
|
|
64
|
+
test: (u) => /extendedsupport/i.test(u),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'nat', strength: 'look', title: 'NAT gateway', why: 'Often traffic that could use VPC endpoints',
|
|
68
|
+
test: (u) => ['NatGateway-Hours', 'NatGateway-Bytes', 'RegionalNatGateway-Hours', 'RegionalNatGateway-Bytes'].includes(u),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'cross-az', strength: 'look', title: 'Cross-AZ data transfer', why: 'Traffic between availability zones',
|
|
72
|
+
test: (u) => u === 'DataTransfer-Regional-Bytes',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'egress', strength: 'look', title: 'Internet data transfer out', why: 'Check what is sending it',
|
|
76
|
+
test: (u) => u === 'DataTransfer-Out-Bytes',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'gp2', strength: 'strong', title: 'gp2 volumes', why: 'gp3 is usually about 20% cheaper', plutusFix: 'pull_request',
|
|
80
|
+
test: (u) => u === 'EBS:VolumeUsage.gp2',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'snapshots', strength: 'look', title: 'EBS snapshots', why: 'Old snapshots tend to accumulate',
|
|
84
|
+
test: (u) => u === 'EBS:SnapshotUsage',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'cw-logs', strength: 'look', title: 'CloudWatch Logs ingestion', why: 'Log volume and retention',
|
|
88
|
+
test: (u, service) => u === 'DataProcessing-Bytes' && CLOUDWATCH.includes(service),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'old-gen', strength: 'strong', title: 'Older instance generations', why: 'Newer generations cost less for the same work',
|
|
92
|
+
test: (u) => OLD_FAMILIES.some((f) => u.startsWith(`BoxUsage:${f}.`)),
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// EC2 and EBS (EC2 price list: AmazonEC2).
|
|
96
|
+
{
|
|
97
|
+
id: 'ec2-dedicated', strength: 'look', title: 'Dedicated Instances', why: 'Single-tenant hardware costs more per hour than shared',
|
|
98
|
+
test: (u) => u === 'DedicatedUsage' || u.startsWith('DedicatedUsage:'),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'ec2-cpu-credits', strength: 'strong', title: 'Burstable CPU credit charges', why: 'Billed when unlimited-mode T instances run above baseline',
|
|
102
|
+
test: (u) => /^CPUCredits:t\d/.test(u),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'ebs-piops', strength: 'look', title: 'Provisioned IOPS volumes', why: 'Provisioned IOPS are billed whether or not they are used',
|
|
106
|
+
test: (u) => /^EBS:Volume(?:Usage|P-IOPS)\.(?:piops|io2)(?:\.tier\d)?$/.test(u),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'ebs-hdd', strength: 'look', title: 'HDD and magnetic volumes', why: 'Older volume types; check each still suits its workload',
|
|
110
|
+
test: (u) => ['EBS:VolumeUsage', 'EBS:VolumeIOUsage', 'EBS:VolumeUsage.st1', 'EBS:VolumeUsage.sc1'].includes(u),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'ebs-fsr', strength: 'look', title: 'Fast snapshot restore', why: 'Billed per snapshot and zone for as long as it stays enabled',
|
|
114
|
+
test: (u) => u === 'EBS:FastSnapshotRestore',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'public-ipv4', strength: 'look', title: 'Public IPv4 addresses in use', why: 'Each in-use public IPv4 address is billed by the hour',
|
|
118
|
+
test: (u) => u === 'PublicIPv4:InUseAddress',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'inter-region', strength: 'look', title: 'Inter-region data transfer', why: 'Traffic leaving one region for another, replication included',
|
|
122
|
+
test: (u, s, raw) => /(?:^|-)AWS-Out-Bytes$/.test(raw),
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Elastic Load Balancing (AWSELB). Classic and application load balancers share the hours usage type,
|
|
126
|
+
// so only the data processing line, which only Classic has, can be told apart.
|
|
127
|
+
{
|
|
128
|
+
id: 'elb-classic', strength: 'strong', title: 'Classic Load Balancer traffic', why: 'Classic is the previous generation of load balancer',
|
|
129
|
+
test: (u, service) => u === 'DataProcessing-Bytes' && isElb(service),
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// RDS and Aurora (AmazonRDS).
|
|
133
|
+
{
|
|
134
|
+
id: 'rds-old-gen', strength: 'strong', title: 'Older RDS instance generations', why: 'Newer generations cost less for the same work',
|
|
135
|
+
test: (u) => OLD_RDS.test(u),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'rds-multi-az', strength: 'look', title: 'RDS Multi-AZ instances', why: 'A standby in a second zone is billed too; worth it where uptime matters',
|
|
139
|
+
test: (u) => u.startsWith('Multi-AZUsage:db.') && !OLD_RDS.test(u),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'rds-piops', strength: 'look', title: 'RDS provisioned IOPS', why: 'Provisioned IOPS are billed whether or not they are used',
|
|
143
|
+
test: (u) => /^RDS:(?:Multi-AZ-)?(?:PIOPS|PIOPS-Storage|IO2-PIOPS|PIOPS-Storage-IO2)$/.test(u),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'rds-backup', strength: 'look', title: 'RDS and Aurora backup storage', why: 'Billed beyond the free allowance; retention and old snapshots drive it',
|
|
147
|
+
test: (u) => u === 'RDS:ChargedBackupUsage' || u === 'Aurora:BackupUsage',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'rds-gp2', strength: 'look', title: 'RDS gp2 storage', why: 'gp3 storage is available for RDS',
|
|
151
|
+
test: (u) => /^RDS:(?:Multi-AZ-)?GP2-Storage$/.test(u),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'aurora-io', strength: 'look', title: 'Aurora I/O requests', why: 'I/O-Optimized has no per-request charge; compare it for I/O-heavy clusters',
|
|
155
|
+
test: (u) => u === 'Aurora:StorageIOUsage',
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// S3 (AmazonS3).
|
|
159
|
+
{
|
|
160
|
+
id: 's3-standard', strength: 'look', title: 'S3 Standard storage', why: 'Data that is rarely read can sit in a cheaper class',
|
|
161
|
+
test: (u, service) => u === 'TimedStorage-ByteHrs' && isS3(service),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 's3-requests', strength: 'look', title: 'S3 requests', why: 'Request volume, not storage, can be what drives the bill',
|
|
165
|
+
test: (u, service) => (u === 'Requests-Tier1' || u === 'Requests-Tier2') && isS3(service),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 's3-early-delete', strength: 'strong', title: 'S3 early-delete fees', why: 'Objects removed before their storage class minimum period',
|
|
169
|
+
test: (u) => u.startsWith('EarlyDelete-'),
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// CloudWatch (AmazonCloudWatch). Logs ingestion is the first-release `cw-logs` rule.
|
|
173
|
+
{
|
|
174
|
+
id: 'cw-metrics', strength: 'look', title: 'CloudWatch metrics and alarms', why: 'Each custom metric and alarm bills monthly, so they add up',
|
|
175
|
+
test: (u) => ['CW:MetricMonitorUsage', 'CW:AlarmMonitorUsage', 'CW:HighResAlarmMonitorUsage', 'CW:CompositeAlarmMonitorUsage'].includes(u),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'cw-logs-storage', strength: 'look', title: 'CloudWatch Logs storage', why: 'Retention keeps stored logs billing; ingestion is a separate line',
|
|
179
|
+
test: (u, service) => u === 'TimedStorage-ByteHrs' && isCloudWatch(service),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'cw-api', strength: 'look', title: 'CloudWatch API requests', why: 'GetMetricData and other calls, often from dashboards or exporters',
|
|
183
|
+
test: (u) => u === 'CW:GMD-Metrics' || u === 'CW:Requests',
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
// Lambda (AWSLambda).
|
|
187
|
+
{
|
|
188
|
+
id: 'lambda-provisioned', strength: 'look', title: 'Lambda provisioned concurrency', why: 'Billed while configured, whether or not it is invoked',
|
|
189
|
+
test: (u) => /^Lambda-Provisioned-(?:Concurrency|GB-Second)(?:-ARM)?$/.test(u),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'lambda-x86', strength: 'look', title: 'Lambda duration on x86', why: 'Arm duration is priced lower per GB-second',
|
|
193
|
+
test: (u) => u === 'Lambda-GB-Second',
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// DynamoDB (AmazonDynamoDB). On-demand request units are not a candidate.
|
|
197
|
+
{
|
|
198
|
+
id: 'ddb-provisioned', strength: 'look', title: 'DynamoDB provisioned capacity', why: 'Billed by the hour whether or not it is used; on-demand is the alternative',
|
|
199
|
+
test: (u) => /^(?:IA-)?(?:Read|Write)CapacityUnit-Hrs$/.test(u),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'ddb-backups', strength: 'look', title: 'DynamoDB backups and point-in-time recovery', why: 'Retention and old on-demand backups keep billing',
|
|
203
|
+
test: (u) => u === 'TimedBackupStorage-ByteHrs' || u === 'TimedPITRStorage-ByteHrs',
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Caches, search and warehouses, older node generations (AmazonElastiCache, AmazonES, AmazonRedshift).
|
|
207
|
+
{
|
|
208
|
+
id: 'elasticache-old-gen', strength: 'strong', title: 'Older ElastiCache node types', why: 'Newer node types cost less for the same work',
|
|
209
|
+
test: (u) => /^NodeUsage:cache\.(?:m4|r4|t2)\./.test(u),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'opensearch-old-gen', strength: 'strong', title: 'Older OpenSearch instance types', why: 'Newer instance types cost less for the same work',
|
|
213
|
+
test: (u) => /^ESInstance:(?:c4|i2|m3|m4|r3|r4|t2)\./.test(u),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: 'redshift-old-gen', strength: 'look', title: 'Older Redshift node types', why: 'RA3 is the current node generation',
|
|
217
|
+
test: (u) => u.startsWith('Node:dc2.'),
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// VPC (AmazonVPC).
|
|
221
|
+
{
|
|
222
|
+
id: 'vpc-endpoints', strength: 'look', title: 'VPC interface endpoints', why: 'Billed per endpoint, zone and hour, plus data processed',
|
|
223
|
+
test: (u) => u === 'VpcEndpoint-Hours' || u === 'VpcEndpoint-Bytes',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'tgw', strength: 'look', title: 'Transit Gateway', why: 'Attachment hours and data processing bill on top of the traffic',
|
|
227
|
+
test: (u) => u === 'TransitGateway-Hours' || u === 'TransitGateway-Bytes',
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
// Containers and registries (AmazonEKS, AmazonECR).
|
|
231
|
+
{
|
|
232
|
+
id: 'eks-clusters', strength: 'look', title: 'EKS cluster hours', why: 'Every cluster bills a control plane by the hour',
|
|
233
|
+
test: (u) => u === 'AmazonEKS-Hours:perCluster',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'ecr-storage', strength: 'look', title: 'ECR image storage', why: 'Old and untagged images accumulate without a lifecycle policy',
|
|
237
|
+
test: (u, service) => u === 'TimedStorage-ByteHrs' && isEcr(service),
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
// Keys, secrets and audit (awskms, AWSSecretsManager, AWSCloudTrail, AWSConfig).
|
|
241
|
+
{
|
|
242
|
+
id: 'kms', strength: 'look', title: 'KMS keys and requests', why: 'Each customer managed key bills monthly, and every request bills',
|
|
243
|
+
test: (u, s, raw) => /(?:^|-)KMS-(?:Keys|Requests)$/.test(raw),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: 'secrets', strength: 'look', title: 'Secrets Manager secrets', why: 'Each secret bills monthly, used or not',
|
|
247
|
+
test: (u) => u === 'AWSSecretsManager-Secrets',
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'cloudtrail-events', strength: 'look', title: 'CloudTrail paid and data events', why: 'Extra trails and data event logging bill per event',
|
|
251
|
+
test: (u) => u === 'PaidEventsRecorded' || u === 'DataEventsRecorded',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'config', strength: 'look', title: 'AWS Config recording and rules', why: 'Billed per configuration item and per rule evaluation',
|
|
255
|
+
test: (u) => ['ConfigurationItemRecorded', 'ConfigurationItemRecordedDaily', 'ConfigRuleEvaluations', 'ConformancePackEvaluations'].includes(u),
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// Streaming and machine learning (AmazonKinesis, AmazonSageMaker).
|
|
259
|
+
{
|
|
260
|
+
id: 'kinesis-shards', strength: 'look', title: 'Kinesis provisioned shard hours', why: 'Billed per shard-hour whether or not data flows',
|
|
261
|
+
test: (u) => u === 'Storage-ShardHour' || u === 'Extended-ShardHour',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'sagemaker-hours', strength: 'look', title: 'SageMaker endpoint and notebook hours', why: 'Billed for every hour the instance is up, used or not',
|
|
265
|
+
test: (u) => /^(?:Host|Notebk):ml\./.test(u),
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
/** The AWS rule table, each rule tagged with its provider. */
|
|
270
|
+
export const CANDIDATE_RULES = AWS_RULES.map((r) => ({ ...r, provider: 'aws' }));
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Every rule table by provider slug. A row is tested only against its own provider's rules, so the AWS
|
|
274
|
+
* usage-type patterns never see an Azure description or the reverse (see candidate-rules-focus.js).
|
|
275
|
+
* @type {Record<string, CandidateRule[]>}
|
|
276
|
+
*/
|
|
277
|
+
export const RULES_BY_PROVIDER = { aws: CANDIDATE_RULES, azure: AZURE_CANDIDATE_RULES, gcp: GCP_CANDIDATE_RULES };
|
|
278
|
+
const ALL_RULES = Object.values(RULES_BY_PROVIDER).flat();
|
|
279
|
+
|
|
280
|
+
/** Candidates Plutus can fix come first, then strong, then look. */
|
|
281
|
+
const groupRank = (/** @type {Candidate} */ c) => (c.plutusFix ? 0 : c.strength === 'strong' ? 1 : 2);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The order of every output: the groups above, each by last-30-day cost, biggest first. The sort is
|
|
285
|
+
* stable, so candidates of equal cost keep table order.
|
|
286
|
+
* @param {Candidate} a @param {Candidate} b
|
|
287
|
+
*/
|
|
288
|
+
export const compareCandidates = (a, b) => groupRank(a) - groupRank(b) || b.cost30d - a.cost30d;
|
|
289
|
+
|
|
290
|
+
/** Strip one region prefix such as USE1- or APS2-. */
|
|
291
|
+
const stripRegion = (usageType) => usageType.replace(/^[A-Z0-9]+-/, '');
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Candidates to review over the last 30 days of detail rows (AWS, Azure and Google Cloud). Commitment rows are never candidates.
|
|
295
|
+
* Each row matches at most one rule (they are disjoint; see the table), and every candidate over the
|
|
296
|
+
* minimum is returned, in the order `compareCandidates` gives. Each output decides how many it shows.
|
|
297
|
+
* @param {import('../model.js').Row[]} detailRows
|
|
298
|
+
* @param {import('../model.js').Window} window
|
|
299
|
+
* @returns {Candidate[]}
|
|
300
|
+
*/
|
|
301
|
+
export function findCandidates(detailRows, window) {
|
|
302
|
+
const { start, end } = lastNDays(window, 30);
|
|
303
|
+
/** @type {Map<string, Map<string, number>>} */
|
|
304
|
+
const byRule = new Map();
|
|
305
|
+
for (const r of detailRows) {
|
|
306
|
+
const rules = RULES_BY_PROVIDER[r.provider];
|
|
307
|
+
if (!rules || r.kind === 'commitment' || !r.usageType || r.date < start || r.date > end) continue;
|
|
308
|
+
// AWS usage types carry a region prefix; Azure and Google descriptions are matched as written.
|
|
309
|
+
const name = r.provider === 'aws' ? stripRegion(r.usageType) : r.usageType;
|
|
310
|
+
const rule = rules.find((c) => c.test(name, r.service, r.usageType));
|
|
311
|
+
if (!rule) continue;
|
|
312
|
+
const types = byRule.get(rule.id) ?? new Map();
|
|
313
|
+
types.set(r.usageType, (types.get(r.usageType) ?? 0) + r.cost);
|
|
314
|
+
byRule.set(rule.id, types);
|
|
315
|
+
}
|
|
316
|
+
/** @type {Candidate[]} */
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const rule of ALL_RULES) {
|
|
319
|
+
const types = byRule.get(rule.id);
|
|
320
|
+
if (!types) continue;
|
|
321
|
+
const usageTypes = [...types].map(([usageType, cost]) => ({ usageType, cost })).sort((a, b) => b.cost - a.cost);
|
|
322
|
+
const cost30d = usageTypes.reduce((s, u) => s + u.cost, 0);
|
|
323
|
+
if (cost30d >= CANDIDATE_MIN_COST) out.push({ id: rule.id, provider: rule.provider, title: rule.title, why: rule.why, cost30d, strength: rule.strength, plutusFix: rule.plutusFix ?? null, usageTypes });
|
|
324
|
+
}
|
|
325
|
+
return out.sort(compareCandidates);
|
|
326
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost drivers: which services moved the bill most, 30 days against the 30 before.
|
|
3
|
+
* Adapted from Plutus's private backend (plutus-backend/lib/cost-drivers.js, rankDrivers,
|
|
4
|
+
* commit 8f3e0d23).
|
|
5
|
+
*/
|
|
6
|
+
import { addDays, lastNDays } from '../model.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../model.js').Row} Row */
|
|
9
|
+
/** @typedef {import('../model.js').Window} Window */
|
|
10
|
+
/** @typedef {{ usageType: string, current: number, previous: number, change: number }} UsageTypeItem */
|
|
11
|
+
/** @typedef {{ provider: string, kind: import('../model.js').Kind, service: string, current: number, previous: number,
|
|
12
|
+
* change: number, sharePct: number, usageTypes: UsageTypeItem[] }} DriverItem */
|
|
13
|
+
/** @typedef {{ current: { start: string, end: string }, previous: { start: string, end: string },
|
|
14
|
+
* totalCurrent: number, totalPrevious: number, items: DriverItem[] }} Drivers */
|
|
15
|
+
|
|
16
|
+
export const DRIVERS_MIN_DAYS = 60;
|
|
17
|
+
const PERIOD_DAYS = 30;
|
|
18
|
+
const USAGE_TYPE_LIMIT = 3;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sum rows into groups keyed by `keyOf`, split by period.
|
|
22
|
+
* @param {Row[]} rows
|
|
23
|
+
* @param {{ start: string, end: string }} current
|
|
24
|
+
* @param {{ start: string, end: string }} previous
|
|
25
|
+
* @param {(r: Row) => string} keyOf
|
|
26
|
+
*/
|
|
27
|
+
function groupByPeriod(rows, current, previous, keyOf) {
|
|
28
|
+
/** @type {Map<string, { current: number, previous: number, first: Row }>} */
|
|
29
|
+
const groups = new Map();
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
const inCur = r.date >= current.start && r.date <= current.end;
|
|
32
|
+
const inPrev = r.date >= previous.start && r.date <= previous.end;
|
|
33
|
+
if (!inCur && !inPrev) continue;
|
|
34
|
+
const key = keyOf(r);
|
|
35
|
+
let g = groups.get(key);
|
|
36
|
+
if (!g) groups.set(key, (g = { current: 0, previous: 0, first: r }));
|
|
37
|
+
if (inCur) g.current += r.cost;
|
|
38
|
+
else g.previous += r.cost;
|
|
39
|
+
}
|
|
40
|
+
return groups;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {{ change: number }} a @param {{ change: number }} b */
|
|
44
|
+
const byMagnitude = (a, b) => Math.abs(b.change) - Math.abs(a.change);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Row[]} rows
|
|
48
|
+
* @param {Window} window
|
|
49
|
+
* @returns {{ current: {start:string,end:string}, previous: {start:string,end:string}, items: DriverItem[] }}
|
|
50
|
+
*/
|
|
51
|
+
function rankAll(rows, window) {
|
|
52
|
+
const current = lastNDays(window, PERIOD_DAYS);
|
|
53
|
+
const previous = { start: addDays(current.start, -PERIOD_DAYS), end: addDays(current.start, -1) };
|
|
54
|
+
// Kind is part of the key, so commitment spend never merges with usage of the same service.
|
|
55
|
+
const groups = groupByPeriod(rows, current, previous, (r) => `${r.provider}\0${r.kind}\0${r.service}`);
|
|
56
|
+
/** @type {DriverItem[]} */
|
|
57
|
+
const items = [];
|
|
58
|
+
for (const g of groups.values()) {
|
|
59
|
+
const change = g.current - g.previous;
|
|
60
|
+
if (change === 0) continue;
|
|
61
|
+
items.push({
|
|
62
|
+
provider: g.first.provider, kind: g.first.kind, service: g.first.service,
|
|
63
|
+
current: g.current, previous: g.previous, change, sharePct: 0, usageTypes: [],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
items.sort((a, b) => byMagnitude(a, b)
|
|
67
|
+
|| (a.provider < b.provider ? -1 : a.provider > b.provider ? 1 : 0)
|
|
68
|
+
|| (a.service < b.service ? -1 : a.service > b.service ? 1 : 0)
|
|
69
|
+
|| (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0));
|
|
70
|
+
return { current, previous, items };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rank services by absolute change between the last 30 days and the 30 before.
|
|
75
|
+
* @param {Row[]} rows
|
|
76
|
+
* @param {Window} window
|
|
77
|
+
* @param {{ detailRows?: Row[], limit?: number }} [opts]
|
|
78
|
+
* @returns {Drivers|null} null when the window is shorter than DRIVERS_MIN_DAYS
|
|
79
|
+
*/
|
|
80
|
+
export function rankDrivers(rows, window, { detailRows = [], limit = 10 } = {}) {
|
|
81
|
+
if (window.days < DRIVERS_MIN_DAYS) return null;
|
|
82
|
+
const { current, previous, items } = rankAll(rows, window);
|
|
83
|
+
const totalAbs = items.reduce((s, i) => s + Math.abs(i.change), 0);
|
|
84
|
+
for (const i of items) {
|
|
85
|
+
i.sharePct = totalAbs > 0 ? Math.round((Math.abs(i.change) / totalAbs) * 1000) / 10 : 0;
|
|
86
|
+
}
|
|
87
|
+
const top = items.slice(0, limit);
|
|
88
|
+
const usage = groupByPeriod(detailRows, current, previous,
|
|
89
|
+
(r) => `${r.provider}\0${r.kind}\0${r.service}\0${r.usageType ?? ''}`);
|
|
90
|
+
for (const item of top) {
|
|
91
|
+
const prefix = `${item.provider}\0${item.kind}\0${item.service}\0`;
|
|
92
|
+
/** @type {UsageTypeItem[]} */
|
|
93
|
+
const types = [];
|
|
94
|
+
for (const [key, g] of usage) {
|
|
95
|
+
if (!key.startsWith(prefix)) continue;
|
|
96
|
+
const change = g.current - g.previous;
|
|
97
|
+
if (change === 0) continue;
|
|
98
|
+
types.push({ usageType: key.slice(prefix.length), current: g.current, previous: g.previous, change });
|
|
99
|
+
}
|
|
100
|
+
types.sort((a, b) => byMagnitude(a, b) || (a.usageType < b.usageType ? -1 : 1));
|
|
101
|
+
item.usageTypes = types.slice(0, USAGE_TYPE_LIMIT);
|
|
102
|
+
}
|
|
103
|
+
let totalCurrent = 0;
|
|
104
|
+
let totalPrevious = 0;
|
|
105
|
+
for (const r of rows) {
|
|
106
|
+
if (r.date >= current.start && r.date <= current.end) totalCurrent += r.cost;
|
|
107
|
+
else if (r.date >= previous.start && r.date <= previous.end) totalPrevious += r.cost;
|
|
108
|
+
}
|
|
109
|
+
return { current, previous, totalCurrent, totalPrevious, items: top };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The first n (provider, service) pairs of the change ranking. Works with whatever history exists.
|
|
114
|
+
* @param {Row[]} rows @param {Window} window @param {number} n
|
|
115
|
+
* @returns {{ provider: string, service: string }[]}
|
|
116
|
+
*/
|
|
117
|
+
export function topServicesByChange(rows, window, n) {
|
|
118
|
+
return rankAll(rows, window).items.slice(0, n).map((i) => ({ provider: i.provider, service: i.service }));
|
|
119
|
+
}
|