Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ The install sets up two git hooks. The pre-commit hook formats the files you sta
4. Run `pnpm verify` before you push. It runs what CI runs.
5. Open one pull request per issue. Say why the change exists, what is in scope and how you verified it, and paste the `pnpm verify` output.

## Add an analyzer

1. Write the rule as `packages/analyzers/src/<provider>/<rule>.ts`. It exports one `Analyzer`, and `analyze` is a pure function of the scan input. `aws/ebs-unattached.ts` is the model.
2. Put its test beside it. Build the input with the helpers in `packages/analyzers/src/fixtures.ts`, and assert the whole finding at least once, including the saving and the commands.
3. Report the lower figure when unsure, round the saving down with `floorToCents`, and put the numbers the estimate rests on in `evidence`.
4. If the rule reads a resource type that does not exist yet, add it to `packages/core/src/resource.ts`.
5. Add the rule to the `analyzers` list in `packages/analyzers/src/index.ts`.

## Formatting and lint

`pnpm format` rewrites files and `pnpm lint:fix` applies lint fixes. Hooks and CI only run the read-only `pnpm format:check` and `pnpm lint`, so a violation is reported and never silently repaired.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ The open-source engine behind Cost Explorer. It reads a cloud cost export and re

The repository is a pnpm workspace. The engine is built as packages under `packages/`, and a Next.js 16 app at the root will host the docs and the rule catalog. The app still serves the create-next-app starting page.

| Package | Holds |
| --------------- | --------------------------------------------------------------------------------------------------- |
| `packages/core` | The cost record schema, aligned to the FinOps FOCUS columns, money math and the `Finding` contract. |
| Package | Holds |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `packages/core` | The cost record schema, aligned to the FinOps FOCUS columns, the resource inventory types, money math and the `Finding` contract. |
| `packages/analyzers` | The waste rules, one file per rule under `src/<provider>/`, and `runAnalyzers`, which runs them over one scan. |

The engine packages are framework-free TypeScript. They import neither React nor Next.js, so a command-line tool and a worker can run them.

Expand Down
1 change: 1 addition & 0 deletions packages/analyzers/node_modules/@costexplorer/core

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions packages/analyzers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@costexplorer/analyzers",
"version": "0.0.0",
"private": true,
"description": "The waste rules. One file per rule, each with fixtures and tests.",
"license": "Apache-2.0",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@costexplorer/core": "workspace:*"
}
}
28 changes: 28 additions & 0 deletions packages/analyzers/src/analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { CostRecord, Finding, Resource } from "@costexplorer/core";

/** Everything a scan knows. Adapters build it, and every analyzer reads the same one. */
export type ScanInput = {
/** The moment the inventory was taken, ISO 8601. Rules measure age against this, not the clock. */
asOf: string;
costRecords: readonly CostRecord[];
resources: readonly Resource[];
};

export type AnalyzerCategory = "idle" | "stale" | "rightsizing" | "pricing" | "network" | "anomaly";

/**
* One waste rule. The metadata feeds the rule catalog, and `analyze` is a pure function of the
* scan input, so a rule can be tested with a fixture and run anywhere.
*/
export type Analyzer = {
/** Provider, service and rule, such as `aws.ebs.unattached`. Every finding carries it. */
id: string;
title: string;
category: AnalyzerCategory;
tier: "free" | "pro";
/** What the rule looks for, in one sentence. */
signal: string;
/** What the finding tells the reader to do, in one sentence. */
fix: string;
analyze(input: ScanInput): Finding[];
};
83 changes: 83 additions & 0 deletions packages/analyzers/src/aws/ebs-unattached.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { findingSchema } from "@costexplorer/core";
import { describe, expect, it } from "vitest";
import { daysBefore, ebsVolume, scan, usage } from "../fixtures.ts";
import { ebsUnattached } from "./ebs-unattached.ts";

const find = (...args: Parameters<typeof scan>) => ebsUnattached.analyze(scan(...args));

describe("aws.ebs.unattached", () => {
it("reports a volume detached 21 days ago with what it costs a month", () => {
const volume = ebsVolume("vol-idle", { unattachedSince: daysBefore(21) });
const findings = find([volume], [usage("vol-idle", 0.24)]);

expect(findings).toEqual([
{
analyzerId: "aws.ebs.unattached",
resourceId: "vol-idle",
account: "111111111111",
region: "us-east-1",
monthlySavingUsd: 7.3,
confidence: "high",
evidence: {
sizeGb: 100,
volumeType: "gp3",
daysUnattached: 21,
unattachedSinceKnown: true,
chargedUsd: 0.24,
chargedHours: 24,
note: "A snapshot kept after the delete is billed separately and is not subtracted here.",
},
remediation: {
cli: [
'SNAPSHOT=$(aws ec2 create-snapshot --region us-east-1 --volume-id vol-idle --description "Final snapshot of vol-idle" --query SnapshotId --output text)',
'aws ec2 wait snapshot-completed --region us-east-1 --snapshot-ids "$SNAPSHOT"',
"aws ec2 delete-volume --region us-east-1 --volume-id vol-idle",
].join("\n"),
},
},
]);
expect(findingSchema.safeParse(findings[0]).success).toBe(true);
});

it("rounds the saving down to the cent", () => {
// 0.25 a day is 7.6041... a month.
const findings = find([ebsVolume("vol-idle")], [usage("vol-idle", 0.25)]);
expect(findings[0]?.monthlySavingUsd).toBe(7.6);
});

it("is only medium confidence when the detach time is unknown", () => {
const findings = find([ebsVolume("vol-old")], [usage("vol-old", 0.24)]);
expect(findings[0]).toMatchObject({
confidence: "medium",
evidence: { daysUnattached: 200, unattachedSinceKnown: false },
});
});

it("reports no saving and low confidence when the export has no charge for the volume", () => {
const findings = find([ebsVolume("vol-unbilled")], [usage("vol-other", 5)]);
expect(findings[0]).toMatchObject({
monthlySavingUsd: 0,
confidence: "low",
evidence: { chargedUsd: null, chargedHours: null },
});
});

it("starts at 14 full days", () => {
const at = (days: number) => ebsVolume(`vol-${days}`, { unattachedSince: daysBefore(days) });
const findings = find([at(13.9), at(14), at(15)]);
expect(findings.map((finding) => finding.resourceId)).toEqual(["vol-14", "vol-15"]);
});

it("leaves attached and transitional volumes alone", () => {
const volumes = ["in-use", "creating", "deleting", "deleted", "error"].map((state) =>
ebsVolume(`vol-${state}`, { state }),
);
expect(find(volumes)).toEqual([]);
});

it("uses the volume's own region in the commands", () => {
const findings = find([ebsVolume("vol-eu", { region: "eu-west-1" })]);
expect(findings[0]?.region).toBe("eu-west-1");
expect(findings[0]?.remediation.cli).toContain("delete-volume --region eu-west-1");
});
});
57 changes: 57 additions & 0 deletions packages/analyzers/src/aws/ebs-unattached.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { floorToCents, type Finding } from "@costexplorer/core";
import type { Analyzer } from "../analyzer.ts";
import { runRate } from "../run-rate.ts";

const ID = "aws.ebs.unattached";
const MS_PER_DAY = 86_400_000;
const MIN_DAYS_UNATTACHED = 14;

export const ebsUnattached: Analyzer = {
id: ID,
title: "Unattached EBS volumes",
category: "idle",
tier: "free",
signal: `An EBS volume has been attached to no instance for ${MIN_DAYS_UNATTACHED} days or more.`,
fix: "Snapshot the volume if the data matters, then delete it.",

analyze({ asOf, costRecords, resources }) {
const findings: Finding[] = [];
for (const volume of resources) {
if (volume.type !== "aws.ebs.volume" || volume.state !== "available") continue;

// The inventory API does not say when a volume was detached. Without that, the creation
// time is the only bound, and the volume may have been in use until yesterday.
const since = volume.unattachedSince ?? volume.createdAt;
const daysUnattached = Math.floor((Date.parse(asOf) - Date.parse(since)) / MS_PER_DAY);
if (daysUnattached < MIN_DAYS_UNATTACHED) continue;

const rate = runRate(costRecords, volume.id);
const at = `--region ${volume.region}`;
findings.push({
analyzerId: ID,
resourceId: volume.id,
account: volume.account,
region: volume.region,
monthlySavingUsd: rate ? floorToCents(rate.monthlyUsd) : 0,
confidence: !rate ? "low" : volume.unattachedSince ? "high" : "medium",
evidence: {
sizeGb: volume.sizeGb,
volumeType: volume.volumeType,
daysUnattached,
unattachedSinceKnown: volume.unattachedSince !== undefined,
chargedUsd: rate?.chargedUsd ?? null,
chargedHours: rate?.hours ?? null,
note: "A snapshot kept after the delete is billed separately and is not subtracted here.",
},
remediation: {
cli: [
`SNAPSHOT=$(aws ec2 create-snapshot ${at} --volume-id ${volume.id} --description "Final snapshot of ${volume.id}" --query SnapshotId --output text)`,
`aws ec2 wait snapshot-completed ${at} --snapshot-ids "$SNAPSHOT"`,
`aws ec2 delete-volume ${at} --volume-id ${volume.id}`,
].join("\n"),
},
});
}
return findings;
},
};
49 changes: 49 additions & 0 deletions packages/analyzers/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
costRecordSchema,
resourceSchema,
type CostRecord,
type Resource,
} from "@costexplorer/core";
import type { ScanInput } from "./analyzer.ts";

/** The scan moment every fixture is measured against. */
export const AS_OF = "2026-09-30T00:00:00Z";

export function daysBefore(days: number, asOf = AS_OF): string {
return new Date(Date.parse(asOf) - days * 86_400_000).toISOString();
}

/** One day of usage for a resource, ending at the scan moment unless a period is given. */
export function usage(resourceId: string, effectiveCost: number, overrides = {}): CostRecord {
return costRecordSchema.parse({
providerName: "AWS",
billingAccountId: "111111111111",
chargePeriodStart: daysBefore(1),
chargePeriodEnd: AS_OF,
chargeCategory: "Usage",
billingCurrency: "USD",
billedCost: effectiveCost,
effectiveCost,
serviceName: "Amazon Elastic Block Store",
resourceId,
...overrides,
});
}

export function ebsVolume(id: string, overrides = {}): Resource {
return resourceSchema.parse({
type: "aws.ebs.volume",
id,
account: "111111111111",
region: "us-east-1",
sizeGb: 100,
volumeType: "gp3",
state: "available",
createdAt: daysBefore(200),
...overrides,
});
}

export function scan(resources: Resource[], costRecords: CostRecord[] = []): ScanInput {
return { asOf: AS_OF, resources, costRecords };
}
32 changes: 32 additions & 0 deletions packages/analyzers/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { ebsVolume, scan, usage } from "./fixtures.ts";
import { analyzers, runAnalyzers } from "./index.ts";

describe("the public rule set", () => {
it("gives every rule a provider.service.rule id and catalog text", () => {
for (const analyzer of analyzers) {
expect(analyzer.id).toMatch(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*){2}$/);
expect(analyzer.title).not.toBe("");
expect(analyzer.signal).toMatch(/\.$/);
expect(analyzer.fix).toMatch(/\.$/);
}
});

it("runs clean on an empty scan", () => {
expect(runAnalyzers(analyzers, scan([]))).toEqual({ findings: [], errors: [] });
});

it("finds the planted waste end to end", () => {
const result = runAnalyzers(
analyzers,
scan(
[ebsVolume("vol-idle"), ebsVolume("vol-busy", { state: "in-use" })],
[usage("vol-idle", 0.24), usage("vol-busy", 0.24)],
),
);
expect(result.errors).toEqual([]);
expect(result.findings.map((f) => [f.analyzerId, f.resourceId, f.monthlySavingUsd])).toEqual([
["aws.ebs.unattached", "vol-idle", 7.3],
]);
});
});
9 changes: 9 additions & 0 deletions packages/analyzers/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Analyzer } from "./analyzer.ts";
import { ebsUnattached } from "./aws/ebs-unattached.ts";

export type { Analyzer, AnalyzerCategory, ScanInput } from "./analyzer.ts";
export { runAnalyzers, type AnalyzerError, type ScanResult } from "./run.ts";
export { runRate, type RunRate } from "./run-rate.ts";

/** Every public rule, in catalog order. */
export const analyzers: readonly Analyzer[] = [ebsUnattached];
41 changes: 41 additions & 0 deletions packages/analyzers/src/run-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { daysBefore, usage } from "./fixtures.ts";
import { runRate } from "./run-rate.ts";

describe("runRate", () => {
it("scales what a resource was charged to a 730-hour month", () => {
expect(runRate([usage("vol-1", 0.24)], "vol-1")).toEqual({
monthlyUsd: 7.3,
chargedUsd: 0.24,
hours: 24,
});
});

it("counts an hour once when several line items cover it", () => {
const records = [usage("vol-1", 0.24), usage("vol-1", 0.12, { skuId: "iops" })];
expect(runRate(records, "vol-1")).toMatchObject({ chargedUsd: 0.36, hours: 24 });
});

it("spans from the first charge to the last", () => {
const records = [
usage("vol-1", 0.24, { chargePeriodStart: daysBefore(3), chargePeriodEnd: daysBefore(2) }),
usage("vol-1", 0.24),
];
expect(runRate(records, "vol-1")).toMatchObject({ chargedUsd: 0.48, hours: 72 });
});

it("ignores other resources and charges that are not usage", () => {
const records = [
usage("vol-1", 0.24),
usage("vol-2", 99),
usage("vol-1", -0.2, { chargeCategory: "Credit" }),
usage("vol-1", 0.02, { chargeCategory: "Tax" }),
];
expect(runRate(records, "vol-1")?.chargedUsd).toBe(0.24);
});

it("returns null when the export has no usage charge for the resource", () => {
expect(runRate([usage("vol-2", 1)], "vol-1")).toBeNull();
expect(runRate([usage("vol-1", -1, { chargeCategory: "Credit" })], "vol-1")).toBeNull();
});
});
30 changes: 30 additions & 0 deletions packages/analyzers/src/run-rate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { HOURS_PER_MONTH, sumUsd, type CostRecord } from "@costexplorer/core";

const MS_PER_HOUR = 3_600_000;

export type RunRate = {
/** What the resource costs per 730-hour month at the rate it was charged in the export. */
monthlyUsd: number;
/** The usage charges the rate was computed from. */
chargedUsd: number;
/** The hours between the first and the last charge. */
hours: number;
};

/**
* The monthly cost of one resource, from what it was actually charged. Uses the amortized cost
* of usage lines only, so a tax, a credit or a one-off purchase does not move the rate.
* Returns `null` when the export has no usage charge for the resource.
*/
export function runRate(records: readonly CostRecord[], resourceId: string): RunRate | null {
const usage = records.filter(
(record) => record.resourceId === resourceId && record.chargeCategory === "Usage",
);
if (usage.length === 0) return null;

const start = Math.min(...usage.map((record) => Date.parse(record.chargePeriodStart)));
const end = Math.max(...usage.map((record) => Date.parse(record.chargePeriodEnd)));
const hours = (end - start) / MS_PER_HOUR;
const chargedUsd = sumUsd(usage.map((record) => record.effectiveCost));
return { monthlyUsd: (chargedUsd / hours) * HOURS_PER_MONTH, chargedUsd, hours };
}
Loading
Loading