@meyicloud/meyi-cost-server 1.5.0 → 1.7.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/AGENTS.md +13 -27
- package/README.md +29 -55
- package/index.js +0 -1
- package/package.json +4 -4
- package/src/controllers/cost-analysis.controller.js +11 -1
- package/src/plugin.js +7 -13
- package/src/repositories/aws-onboarding.repository.js +6 -4
- package/src/repositories/cost-analysis-report.repository.js +22 -3
- package/src/routes/index.js +1 -0
- package/src/schema/cost-analysis-report.schema.js +14 -0
- package/src/services/cost-analyser.service.js +256 -0
- package/src/services/cost-analysis-report.service.js +23 -6
- package/src/services/cost-report-artifact.service.js +22 -0
- package/src/workers/cost-analysis-report.worker.js +10 -9
- package/src/lib/cost-analysis.js +0 -116
- package/src/lib/llm-provider.js +0 -239
- package/src/repositories/cost-analysis.repository.js +0 -50
- package/src/schema/cost-analysis.schema.js +0 -7
- package/src/services/cost-analysis-data.service.js +0 -39
- package/src/services/cost-analysis-pdf.service.js +0 -42
- package/src/services/cost-analysis.service.js +0 -190
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DescribeTasksCommand,
|
|
3
|
+
ECSClient,
|
|
4
|
+
RunTaskCommand,
|
|
5
|
+
StopTaskCommand,
|
|
6
|
+
} from "@aws-sdk/client-ecs";
|
|
7
|
+
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
8
|
+
import {
|
|
9
|
+
CreateSecretCommand,
|
|
10
|
+
DeleteSecretCommand,
|
|
11
|
+
SecretsManagerClient,
|
|
12
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
13
|
+
import { truthy } from "../lib/cost-utils.js";
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
const list = (value) => String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
17
|
+
|
|
18
|
+
function normalizeProvider(value) {
|
|
19
|
+
const name = String(value || "").trim().toLowerCase();
|
|
20
|
+
if (name.includes("bedrock")) return "bedrock";
|
|
21
|
+
if (name.includes("anthropic") || name.includes("claude")) return "anthropic";
|
|
22
|
+
if (name.includes("openai") || name.includes("gpt")) return "openai";
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function required(value, name) {
|
|
27
|
+
const text = String(value || "").trim();
|
|
28
|
+
if (!text) throw Object.assign(new Error(`${name} is required for external cost analysis`), { name: "CostAnalyserConfigError", statusCode: 503 });
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function environment(values) {
|
|
33
|
+
return Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== "").map(([name, value]) => ({ name, value: String(value) }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function bodyText(body) {
|
|
37
|
+
if (!body) return "";
|
|
38
|
+
if (typeof body.transformToString === "function") return body.transformToString();
|
|
39
|
+
const chunks = [];
|
|
40
|
+
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
|
41
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CostAnalyserService {
|
|
45
|
+
constructor({ contextService, athenaContextService, providerResolver = null, logger = console, env = process.env, ecsClient = null, s3Client = null, secretsClient = null } = {}) {
|
|
46
|
+
this.contextService = contextService;
|
|
47
|
+
this.athenaContextService = athenaContextService;
|
|
48
|
+
this.providerResolver = providerResolver;
|
|
49
|
+
this.logger = logger;
|
|
50
|
+
this.enabled = truthy(env.COST_AI_ENABLED);
|
|
51
|
+
this.region = String(env.COST_AI_ANALYSER_REGION || env.AWS_REGION || env.AWS_DEFAULT_REGION || "us-east-1");
|
|
52
|
+
this.cluster = String(env.COST_AI_ANALYSER_CLUSTER || "").trim();
|
|
53
|
+
this.taskDefinition = String(env.COST_AI_ANALYSER_TASK_DEFINITION || "").trim();
|
|
54
|
+
this.containerName = String(env.COST_AI_ANALYSER_CONTAINER_NAME || "cost-ai-analyser").trim();
|
|
55
|
+
this.subnets = list(env.COST_AI_ANALYSER_SUBNETS);
|
|
56
|
+
this.securityGroups = list(env.COST_AI_ANALYSER_SECURITY_GROUPS);
|
|
57
|
+
this.assignPublicIp = truthy(env.COST_AI_ANALYSER_ASSIGN_PUBLIC_IP) ? "ENABLED" : "DISABLED";
|
|
58
|
+
this.reportBucket = String(env.COST_AI_ANALYSER_REPORT_BUCKET || "").trim();
|
|
59
|
+
this.targetRegions = String(env.COST_AI_ANALYSER_TARGET_REGIONS || env.AWS_REGION || "us-east-1").trim();
|
|
60
|
+
this.providerSecretPrefix = String(env.COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX || "").trim().replace(/\/+$/, "");
|
|
61
|
+
this.topServices = String(env.COST_AI_ANALYSER_TOP_N_SERVICES || "10").trim();
|
|
62
|
+
this.pollMs = Math.max(Number(env.COST_AI_ANALYSER_POLL_INTERVAL_MS || 15_000), 2_000);
|
|
63
|
+
this.timeoutMs = Math.max(Number(env.COST_AI_ANALYSER_TIMEOUT_MS || 45 * 60_000), 60_000);
|
|
64
|
+
this.ecs = ecsClient || new ECSClient({ region: this.region });
|
|
65
|
+
this.s3 = s3Client || new S3Client({ region: this.region });
|
|
66
|
+
this.secrets = secretsClient || new SecretsManagerClient({ region: this.region });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
infrastructureConfigured() {
|
|
70
|
+
return Boolean(this.cluster && this.taskDefinition && this.subnets.length && this.securityGroups.length && this.reportBucket && this.providerSecretPrefix);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async resolveProvider(tenantId) {
|
|
74
|
+
if (!this.providerResolver) {
|
|
75
|
+
throw Object.assign(new Error("Meyi Connect AI provider resolution is not configured"), { name: "CostAiProviderResolverError", statusCode: 503 });
|
|
76
|
+
}
|
|
77
|
+
const resolved = await this.providerResolver(tenantId);
|
|
78
|
+
if (!resolved) {
|
|
79
|
+
throw Object.assign(new Error("Configure an active AI provider before enabling AI cost reports"), { name: "CostAiProviderNotConfiguredError", statusCode: 409 });
|
|
80
|
+
}
|
|
81
|
+
const provider = normalizeProvider(resolved.provider);
|
|
82
|
+
if (!["bedrock", "anthropic", "openai"].includes(provider)) {
|
|
83
|
+
throw Object.assign(new Error(`Unsupported AI provider "${resolved.provider}"`), { name: "CostAiProviderConfigError", statusCode: 400 });
|
|
84
|
+
}
|
|
85
|
+
const modelId = required(resolved.modelId, "active AI provider model ID");
|
|
86
|
+
if ((provider === "anthropic" || provider === "openai") && !resolved.apiKey) {
|
|
87
|
+
throw Object.assign(new Error(`${provider} requires an API key in the active AI provider`), { name: "CostAiProviderConfigError", statusCode: 400 });
|
|
88
|
+
}
|
|
89
|
+
if (provider === "bedrock" && !resolved.region) {
|
|
90
|
+
throw Object.assign(new Error("Bedrock requires an AWS region in the active AI provider"), { name: "CostAiProviderConfigError", statusCode: 400 });
|
|
91
|
+
}
|
|
92
|
+
if (Boolean(resolved.accessKeyId) !== Boolean(resolved.secretAccessKey)) {
|
|
93
|
+
throw Object.assign(new Error("The active Bedrock provider must contain both AWS access-key fields"), { name: "CostAiProviderConfigError", statusCode: 400 });
|
|
94
|
+
}
|
|
95
|
+
return { ...resolved, provider, modelId, source: "tenant-configuration" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async status(req) {
|
|
99
|
+
const configured = this.infrastructureConfigured();
|
|
100
|
+
if (!this.enabled || !configured) {
|
|
101
|
+
return { enabled: false, providerConfigured: false, provider: null, modelId: null, region: null, source: null, reason: this.enabled ? "ANALYSER_NOT_CONFIGURED" : "COST_AI_DISABLED" };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const tenantId = this.contextService.tenantId(req);
|
|
105
|
+
const provider = await this.resolveProvider(tenantId);
|
|
106
|
+
return { enabled: true, providerConfigured: true, provider: provider.provider, modelId: provider.modelId, region: provider.region || null, source: provider.source };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.name !== "CostAiProviderNotConfiguredError") throw error;
|
|
109
|
+
return { enabled: false, providerConfigured: false, provider: null, modelId: null, region: null, source: null, reason: "AI_PROVIDER_REQUIRED" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async createProviderSecret({ tenantId, reportId, provider }) {
|
|
114
|
+
const response = await this.secrets.send(new CreateSecretCommand({
|
|
115
|
+
Name: `${this.providerSecretPrefix}/${reportId}`,
|
|
116
|
+
Description: "Temporary provider configuration for one Meyi cost-analysis task",
|
|
117
|
+
SecretString: JSON.stringify({
|
|
118
|
+
provider: provider.provider,
|
|
119
|
+
modelId: provider.modelId,
|
|
120
|
+
apiKey: provider.apiKey,
|
|
121
|
+
authType: provider.authType,
|
|
122
|
+
region: provider.region,
|
|
123
|
+
accessKeyId: provider.accessKeyId,
|
|
124
|
+
secretAccessKey: provider.secretAccessKey,
|
|
125
|
+
}),
|
|
126
|
+
Tags: [
|
|
127
|
+
{ Key: "Application", Value: "meyi-connect" },
|
|
128
|
+
{ Key: "Component", Value: "cost-ai-analyser" },
|
|
129
|
+
{ Key: "TenantId", Value: String(tenantId).slice(0, 256) },
|
|
130
|
+
{ Key: "ReportId", Value: String(reportId).slice(0, 256) },
|
|
131
|
+
],
|
|
132
|
+
}));
|
|
133
|
+
return required(response.ARN, "temporary AI provider secret ARN");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async deleteProviderSecret(secretArn) {
|
|
137
|
+
if (!secretArn) return;
|
|
138
|
+
try {
|
|
139
|
+
await this.secrets.send(new DeleteSecretCommand({ SecretId: secretArn, ForceDeleteWithoutRecovery: true }));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error?.name === "ResourceNotFoundException") return;
|
|
142
|
+
this.logger.error?.("[Cost AI Reports] Failed to delete temporary provider secret", { secretArn, message: error.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
statusSummary(provider) {
|
|
147
|
+
return {
|
|
148
|
+
provider: provider.provider,
|
|
149
|
+
modelId: provider.modelId,
|
|
150
|
+
region: provider.region || null,
|
|
151
|
+
source: provider.source,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
validate() {
|
|
156
|
+
if (!this.enabled) throw Object.assign(new Error("AI cost analysis is not enabled for this deployment"), { name: "CostAiDisabledError", statusCode: 503 });
|
|
157
|
+
required(this.cluster, "COST_AI_ANALYSER_CLUSTER");
|
|
158
|
+
required(this.taskDefinition, "COST_AI_ANALYSER_TASK_DEFINITION");
|
|
159
|
+
if (!this.subnets.length) required("", "COST_AI_ANALYSER_SUBNETS");
|
|
160
|
+
if (!this.securityGroups.length) required("", "COST_AI_ANALYSER_SECURITY_GROUPS");
|
|
161
|
+
required(this.reportBucket, "COST_AI_ANALYSER_REPORT_BUCKET");
|
|
162
|
+
required(this.providerSecretPrefix, "COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async readJson(key) {
|
|
166
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.reportBucket, Key: key }));
|
|
167
|
+
return JSON.parse(await bodyText(response.Body));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async execute({ tenantId, reportId, range, frequency, onStarted = null }) {
|
|
171
|
+
this.validate();
|
|
172
|
+
const provider = await this.resolveProvider(tenantId);
|
|
173
|
+
const customer = await this.contextService.resolve({ headers: {}, user: { tenant_id: tenantId, tenantId } });
|
|
174
|
+
const athena = this.athenaContextService.resolve(customer).config;
|
|
175
|
+
if (!athena.enabled) throw Object.assign(new Error("CUR discovery has not produced a queryable Athena table for this tenant"), { name: "CurDataNotReadyError", statusCode: 503 });
|
|
176
|
+
const targetRoleArn = required(customer.meta?.payerRoleArn, "customer TARGET_ROLE_ARN");
|
|
177
|
+
const targetAccountId = required(customer.meta?.managementAccountId || customer.accounts?.[0]?.id, "customer management account ID");
|
|
178
|
+
const prefix = `tenants/${tenantId}/reports/${reportId}`;
|
|
179
|
+
let providerSecretArn;
|
|
180
|
+
try {
|
|
181
|
+
providerSecretArn = await this.createProviderSecret({ tenantId, reportId, provider });
|
|
182
|
+
const values = {
|
|
183
|
+
REPORT_JOB_ID: reportId,
|
|
184
|
+
REPORT_TENANT_ID: tenantId,
|
|
185
|
+
REPORT_KEY_PREFIX: prefix,
|
|
186
|
+
REPORT_START_DATE: range.Start,
|
|
187
|
+
REPORT_END_DATE: range.End,
|
|
188
|
+
REPORT_FREQUENCY: frequency,
|
|
189
|
+
REPORT_BUCKET: this.reportBucket,
|
|
190
|
+
REPORT_BUCKET_REGION: this.region,
|
|
191
|
+
TARGET_ACCOUNT_ID: targetAccountId,
|
|
192
|
+
TARGET_ROLE_ARN: targetRoleArn,
|
|
193
|
+
TARGET_ROLE_EXTERNAL_ID: customer.meta?.externalId,
|
|
194
|
+
TARGET_REGIONS: this.targetRegions,
|
|
195
|
+
COST_CUR_DATABASE: athena.database,
|
|
196
|
+
COST_CUR_TABLE: athena.table,
|
|
197
|
+
COST_CUR_WORKGROUP: athena.workgroup,
|
|
198
|
+
COST_CUR_OUTPUT_LOCATION: athena.outputLocation,
|
|
199
|
+
COST_CUR_REGION: athena.region,
|
|
200
|
+
COST_CUR_TENANT_COLUMN: athena.tenantColumn,
|
|
201
|
+
COST_CUR_TENANT_PARTITION: athena.tenantPartition || tenantId,
|
|
202
|
+
AI_PROVIDER_SECRET_ARN: providerSecretArn,
|
|
203
|
+
AI_PROVIDER_SECRET_REGION: this.region,
|
|
204
|
+
TOP_N_SERVICES: this.topServices,
|
|
205
|
+
};
|
|
206
|
+
const launched = await this.ecs.send(new RunTaskCommand({
|
|
207
|
+
cluster: this.cluster,
|
|
208
|
+
taskDefinition: this.taskDefinition,
|
|
209
|
+
launchType: "FARGATE",
|
|
210
|
+
count: 1,
|
|
211
|
+
enableExecuteCommand: false,
|
|
212
|
+
networkConfiguration: { awsvpcConfiguration: { subnets: this.subnets, securityGroups: this.securityGroups, assignPublicIp: this.assignPublicIp } },
|
|
213
|
+
overrides: { containerOverrides: [{ name: this.containerName, environment: environment(values) }] },
|
|
214
|
+
startedBy: `meyi-cost-${reportId}`.slice(0, 36),
|
|
215
|
+
}));
|
|
216
|
+
if (launched.failures?.length || !launched.tasks?.[0]?.taskArn) {
|
|
217
|
+
const reason = launched.failures?.map((item) => item.reason || item.detail).filter(Boolean).join("; ") || "ECS did not return a task ARN";
|
|
218
|
+
throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
|
|
219
|
+
}
|
|
220
|
+
const taskArn = launched.tasks[0].taskArn;
|
|
221
|
+
await onStarted?.(taskArn);
|
|
222
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
|
|
225
|
+
const task = response.tasks?.[0];
|
|
226
|
+
if (task?.lastStatus === "STOPPED") {
|
|
227
|
+
const container = task.containers?.find((item) => item.name === this.containerName) || task.containers?.[0];
|
|
228
|
+
let status;
|
|
229
|
+
try { status = await this.readJson(`${prefix}/status.json`); } catch { status = null; }
|
|
230
|
+
if (Number(container?.exitCode ?? 1) !== 0 || status?.status !== "COMPLETED") {
|
|
231
|
+
throw Object.assign(new Error(status?.error || container?.reason || task.stoppedReason || "Cost analyser task failed"), { name: "CostAnalyserTaskError", statusCode: 502, taskArn });
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
taskArn,
|
|
235
|
+
artifactBucket: this.reportBucket,
|
|
236
|
+
markdownKey: status.markdownKey || `${prefix}/report.md`,
|
|
237
|
+
pdfKey: status.pdfKey || `${prefix}/report.pdf`,
|
|
238
|
+
ticketsKey: status.ticketsKey || `${prefix}/tickets.json`,
|
|
239
|
+
ticketCount: Number(status.summary?.ticketCount || 0),
|
|
240
|
+
markdownSize: Number(status.markdownSize || 0),
|
|
241
|
+
pdfSize: Number(status.pdfSize || 0),
|
|
242
|
+
...this.statusSummary(provider),
|
|
243
|
+
dataSource: "AWS CUR",
|
|
244
|
+
facts: { currentTotal: Number(status.summary?.totalMonthlyCost || 0), currency: "USD" },
|
|
245
|
+
summary: status.summary || {},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
await sleep(this.pollMs);
|
|
249
|
+
}
|
|
250
|
+
await this.ecs.send(new StopTaskCommand({ cluster: this.cluster, task: taskArn, reason: "Meyi cost analysis timeout" }));
|
|
251
|
+
throw Object.assign(new Error("Cost analyser task timed out"), { name: "CostAnalyserTimeoutError", statusCode: 504, taskArn });
|
|
252
|
+
} finally {
|
|
253
|
+
await this.deleteProviderSecret(providerSecretArn);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -9,19 +9,20 @@ function reportDto(row) {
|
|
|
9
9
|
return {
|
|
10
10
|
id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
|
|
11
11
|
period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
|
|
12
|
-
status: row.status, attempts: Number(row.attempt_count || 0),
|
|
12
|
+
status: row.status, attempts: Number(row.attempt_count || 0), provider: row.provider_name,
|
|
13
|
+
providerSource: row.provider_source, modelId: row.model_id,
|
|
13
14
|
dataSource: row.data_source, facts: row.facts || null, ...(row.result || {}),
|
|
14
|
-
pdfReady: row.status === "COMPLETED" && Number(row.pdf_size || 0) > 0,
|
|
15
|
+
pdfReady: row.status === "COMPLETED" && Boolean(row.pdf_key || row.pdf_data || Number(row.pdf_size || 0) > 0),
|
|
15
16
|
pdfSize: Number(row.pdf_size || 0), error: row.error, startedAt: row.started_at,
|
|
16
17
|
completedAt: row.completed_at, createdAt: row.created_at,
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class CostAnalysisReportService {
|
|
21
|
-
constructor({ contextService, repository,
|
|
22
|
+
constructor({ contextService, repository, artifactService }) {
|
|
22
23
|
this.contextService = contextService;
|
|
23
24
|
this.repository = repository;
|
|
24
|
-
this.
|
|
25
|
+
this.artifactService = artifactService;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
tenant(req) { return this.contextService.tenantId(req); }
|
|
@@ -37,6 +38,11 @@ export class CostAnalysisReportService {
|
|
|
37
38
|
|
|
38
39
|
async list(req, limit) { return (await this.repository.list(this.tenant(req), limit)).map(reportDto); }
|
|
39
40
|
|
|
41
|
+
async latest(req) {
|
|
42
|
+
const reports = await this.list(req, 1);
|
|
43
|
+
return reports[0] || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
async get(req, id) {
|
|
41
47
|
const row = await this.repository.find(this.tenant(req), id);
|
|
42
48
|
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
@@ -46,7 +52,18 @@ export class CostAnalysisReportService {
|
|
|
46
52
|
async pdf(req, id) {
|
|
47
53
|
const row = await this.repository.find(this.tenant(req), id, true);
|
|
48
54
|
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
49
|
-
if (row.status !== "COMPLETED"
|
|
50
|
-
|
|
55
|
+
if (row.status !== "COMPLETED") throw Object.assign(new Error("The PDF is not ready"), { statusCode: 409 });
|
|
56
|
+
const data = row.pdf_key ? await this.artifactService.get(row.artifact_bucket, row.pdf_key) : row.pdf_data ? Buffer.from(row.pdf_data) : null;
|
|
57
|
+
if (!data?.length) throw Object.assign(new Error("The PDF artifact is unavailable"), { statusCode: 409 });
|
|
58
|
+
return { data, filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.pdf` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async markdown(req, id) {
|
|
62
|
+
const row = await this.repository.find(this.tenant(req), id, true);
|
|
63
|
+
if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
|
|
64
|
+
if (row.status !== "COMPLETED" || !row.markdown_key) throw Object.assign(new Error("The Markdown report is not ready"), { statusCode: 409 });
|
|
65
|
+
const data = await this.artifactService.get(row.artifact_bucket, row.markdown_key);
|
|
66
|
+
if (!data?.length) throw Object.assign(new Error("The Markdown artifact is unavailable"), { statusCode: 409 });
|
|
67
|
+
return { data, filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.md` };
|
|
51
68
|
}
|
|
52
69
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
|
|
3
|
+
async function bytes(body) {
|
|
4
|
+
if (!body) return Buffer.alloc(0);
|
|
5
|
+
if (typeof body.transformToByteArray === "function") return Buffer.from(await body.transformToByteArray());
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
|
8
|
+
return Buffer.concat(chunks);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CostReportArtifactService {
|
|
12
|
+
constructor({ client = null, region = process.env.COST_AI_ANALYSER_REGION || process.env.AWS_REGION } = {}) {
|
|
13
|
+
this.client = client || new S3Client({ region });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async get(bucket, key) {
|
|
17
|
+
if (!bucket || !key) return null;
|
|
18
|
+
const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
19
|
+
return bytes(response.Body);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
|
|
2
|
-
import { createCostAnalysisPdf } from "../services/cost-analysis-pdf.service.js";
|
|
3
|
-
|
|
4
2
|
export class CostAnalysisReportWorker {
|
|
5
|
-
constructor({ repository,
|
|
3
|
+
constructor({ repository, analyserService, logger = console, env = process.env }) {
|
|
6
4
|
this.repository = repository;
|
|
7
|
-
this.
|
|
5
|
+
this.analyserService = analyserService;
|
|
8
6
|
this.logger = logger;
|
|
9
|
-
this.pdfGenerator = pdfGenerator;
|
|
10
7
|
this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
|
|
11
8
|
this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
|
|
12
9
|
this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 30 * 60_000), 60_000);
|
|
@@ -25,10 +22,14 @@ export class CostAnalysisReportWorker {
|
|
|
25
22
|
const claimed = await this.repository.markRunning(report.id);
|
|
26
23
|
if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
|
|
27
24
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
const result = await this.analyserService.execute({
|
|
26
|
+
tenantId: schedule.tenant_id,
|
|
27
|
+
reportId: report.id,
|
|
28
|
+
range,
|
|
29
|
+
frequency: schedule.frequency,
|
|
30
|
+
onStarted: (taskArn) => this.repository.setTaskArn(report.id, taskArn),
|
|
31
|
+
});
|
|
32
|
+
await this.repository.completeExternal(report.id, result);
|
|
32
33
|
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
33
34
|
} catch (error) {
|
|
34
35
|
await this.repository.fail(report.id, error);
|
package/src/lib/cost-analysis.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { round } from "./cost-utils.js";
|
|
3
|
-
|
|
4
|
-
const text = (value, max = 1200) => String(value || "").trim().slice(0, max);
|
|
5
|
-
const severity = (value) => ["low", "medium", "high"].includes(value) ? value : "medium";
|
|
6
|
-
|
|
7
|
-
function median(values) {
|
|
8
|
-
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
|
9
|
-
if (!sorted.length) return null;
|
|
10
|
-
const middle = Math.floor(sorted.length / 2);
|
|
11
|
-
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function deltaRows(current = [], previous = [], limit = 8) {
|
|
15
|
-
const before = new Map(previous.map((item) => [item.label, Number(item.amount || 0)]));
|
|
16
|
-
return current.slice(0, limit).map((item) => {
|
|
17
|
-
const currentAmount = Number(item.amount || 0);
|
|
18
|
-
const previousAmount = before.get(item.label) || 0;
|
|
19
|
-
return {
|
|
20
|
-
label: text(item.label, 160),
|
|
21
|
-
current: round(currentAmount),
|
|
22
|
-
previous: round(previousAmount),
|
|
23
|
-
change: round(currentAmount - previousAmount),
|
|
24
|
-
changePercentage: previousAmount > 0 ? round((currentAmount - previousAmount) / previousAmount * 100) : null,
|
|
25
|
-
sharePercentage: Number.isFinite(Number(item.percentage)) ? round(Number(item.percentage)) : null,
|
|
26
|
-
};
|
|
27
|
-
}).sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function buildCostFacts({ current, previous }) {
|
|
31
|
-
const currentTotal = round(current.totalCost || 0);
|
|
32
|
-
const previousTotal = round(previous.totalCost || 0);
|
|
33
|
-
const change = round(currentTotal - previousTotal);
|
|
34
|
-
const changePercentage = previousTotal > 0 ? round(change / previousTotal * 100) : null;
|
|
35
|
-
const historicalTotals = (current.trends || [])
|
|
36
|
-
.filter((item) => String(item.month || "") < String(current.period?.Start || ""))
|
|
37
|
-
.map((item) => Number(item.amount || 0))
|
|
38
|
-
.filter((value) => value > 0);
|
|
39
|
-
const baseline = median(historicalTotals);
|
|
40
|
-
const currentVsBaseline = baseline && baseline > 0 ? round((currentTotal - baseline) / baseline * 100) : null;
|
|
41
|
-
const serviceChanges = deltaRows(current.topServices, previous.topServices);
|
|
42
|
-
const accountChanges = deltaRows(current.topAccounts, previous.topAccounts, 6);
|
|
43
|
-
const topServiceShare = Number(current.topServices?.[0]?.percentage || 0);
|
|
44
|
-
const calculatedSignals = [];
|
|
45
|
-
|
|
46
|
-
if (changePercentage != null && Math.abs(changePercentage) >= 10) {
|
|
47
|
-
calculatedSignals.push({
|
|
48
|
-
severity: Math.abs(changePercentage) >= 25 ? "high" : "medium",
|
|
49
|
-
kind: "period_change",
|
|
50
|
-
message: `Spend ${changePercentage > 0 ? "increased" : "decreased"} ${Math.abs(changePercentage)}% compared with the preceding period.`,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (currentVsBaseline != null && currentVsBaseline >= 25 && historicalTotals.length >= 3) {
|
|
54
|
-
calculatedSignals.push({ severity: "high", kind: "baseline_anomaly", message: `Spend is ${currentVsBaseline}% above the historical monthly median.` });
|
|
55
|
-
}
|
|
56
|
-
if (topServiceShare >= 50) {
|
|
57
|
-
calculatedSignals.push({ severity: "medium", kind: "service_concentration", message: `${text(current.topServices[0]?.label, 160)} represents ${round(topServiceShare)}% of spend.` });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
period: current.period,
|
|
62
|
-
comparisonPeriod: previous.period,
|
|
63
|
-
currency: current.currency || "USD",
|
|
64
|
-
dataSource: current.dataSource,
|
|
65
|
-
currentTotal,
|
|
66
|
-
previousTotal,
|
|
67
|
-
change,
|
|
68
|
-
changePercentage,
|
|
69
|
-
activeAccounts: Number(current.activeAccounts || 0),
|
|
70
|
-
activeResources: current.activeResources == null ? null : Number(current.activeResources),
|
|
71
|
-
historyMonths: historicalTotals.length,
|
|
72
|
-
historicalMonthlyMedian: baseline == null ? null : round(baseline),
|
|
73
|
-
currentVsBaselinePercentage: currentVsBaseline,
|
|
74
|
-
serviceChanges,
|
|
75
|
-
accountChanges,
|
|
76
|
-
topRegions: (current.topRegions || []).slice(0, 6).map((item) => ({ label: text(item.label, 160), amount: round(item.amount || 0), sharePercentage: round(item.percentage || 0) })),
|
|
77
|
-
calculatedSignals,
|
|
78
|
-
limitations: historicalTotals.length < 3 ? ["Fewer than three non-zero monthly data points are available, so anomaly confidence is limited."] : [],
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function redactFactsForModel(facts) {
|
|
83
|
-
return {
|
|
84
|
-
...facts,
|
|
85
|
-
accountChanges: facts.accountChanges.map((item, index) => ({ ...item, label: `Account ${index + 1}` })),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function analysisFingerprint(facts, modelId) {
|
|
90
|
-
return createHash("sha256").update(JSON.stringify({ facts, modelId })).digest("hex");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function parseAnalysisResponse(value) {
|
|
94
|
-
const raw = String(value || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
95
|
-
const start = raw.indexOf("{");
|
|
96
|
-
const end = raw.lastIndexOf("}");
|
|
97
|
-
if (start < 0 || end <= start) throw new Error("The AI response did not contain a JSON object");
|
|
98
|
-
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
99
|
-
const summary = text(parsed.summary, 3000);
|
|
100
|
-
const findings = Array.isArray(parsed.findings) ? parsed.findings.slice(0, 6).map((item) => ({
|
|
101
|
-
severity: severity(item?.severity),
|
|
102
|
-
title: text(item?.title, 160),
|
|
103
|
-
explanation: text(item?.explanation, 1000),
|
|
104
|
-
evidence: text(item?.evidence, 500),
|
|
105
|
-
estimatedImpact: Number.isFinite(Number(item?.estimatedImpact)) ? round(Number(item.estimatedImpact)) : null,
|
|
106
|
-
})).filter((item) => item.title && item.explanation) : [];
|
|
107
|
-
const recommendations = Array.isArray(parsed.recommendations) ? parsed.recommendations.slice(0, 6).map((item) => ({
|
|
108
|
-
priority: Math.min(Math.max(Number(item?.priority) || 2, 1), 3),
|
|
109
|
-
title: text(item?.title, 160),
|
|
110
|
-
action: text(item?.action, 800),
|
|
111
|
-
rationale: text(item?.rationale, 600),
|
|
112
|
-
})).filter((item) => item.title && item.action) : [];
|
|
113
|
-
const limitations = Array.isArray(parsed.limitations) ? parsed.limitations.slice(0, 5).map((item) => text(item, 400)).filter(Boolean) : [];
|
|
114
|
-
if (!summary || !findings.length || !recommendations.length) throw new Error("The AI response was missing required analysis fields");
|
|
115
|
-
return { summary, findings, recommendations, limitations };
|
|
116
|
-
}
|