@meyicloud/meyi-cost-server 1.7.1 → 1.8.1
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/package.json
CHANGED
package/src/plugin.js
CHANGED
|
@@ -17,6 +17,7 @@ import { CostAnalyserService } from "./services/cost-analyser.service.js";
|
|
|
17
17
|
import { CostAnalysisReportService } from "./services/cost-analysis-report.service.js";
|
|
18
18
|
import { CostReportArtifactService } from "./services/cost-report-artifact.service.js";
|
|
19
19
|
import { CostAnalysisReportWorker } from "./workers/cost-analysis-report.worker.js";
|
|
20
|
+
import { CostIncidentSyncService } from "./services/cost-incident-sync.service.js";
|
|
20
21
|
import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
|
|
21
22
|
import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
|
|
22
23
|
import { CurDiscoveryAws } from "./cur-discovery/cur-discovery.aws.js";
|
|
@@ -24,7 +25,7 @@ import { CurDiscoveryService } from "./cur-discovery/cur-discovery.service.js";
|
|
|
24
25
|
import { CurDiscoveryWorker } from "./cur-discovery/cur-discovery.worker.js";
|
|
25
26
|
import { safeSchema } from "./lib/cost-utils.js";
|
|
26
27
|
|
|
27
|
-
export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null } = {}) {
|
|
28
|
+
export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null, incidentSink = null } = {}) {
|
|
28
29
|
if (!db) throw new Error("db is required");
|
|
29
30
|
const schema = safeSchema(process.env.DB_SCHEMA || "meyiconnect");
|
|
30
31
|
const qSchema = `"${schema}"`;
|
|
@@ -53,8 +54,9 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
|
|
|
53
54
|
const budgetController = new BudgetController({ contextService, budgetService, budgetAlertService });
|
|
54
55
|
const costAnalyserService = new CostAnalyserService({ contextService, athenaContextService, providerResolver, logger });
|
|
55
56
|
const artifactService = new CostReportArtifactService();
|
|
57
|
+
const incidentSyncService = new CostIncidentSyncService({ repository: costAnalysisReportRepository, artifactService, incidentSink, logger });
|
|
56
58
|
const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, artifactService });
|
|
57
|
-
const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, logger });
|
|
59
|
+
const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, incidentSyncService, logger });
|
|
58
60
|
const costAnalysisController = new CostAnalysisController({ service: costAnalyserService, reportService: costAnalysisReportService });
|
|
59
61
|
const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
|
|
60
62
|
let mounted = false;
|
|
@@ -89,11 +89,35 @@ export class CostAnalysisReportRepository {
|
|
|
89
89
|
result = ${JSON.stringify(result.summary || {})}::jsonb, artifact_bucket = ${result.artifactBucket},
|
|
90
90
|
markdown_key = ${result.markdownKey}, markdown_size = ${Number(result.markdownSize || 0)},
|
|
91
91
|
pdf_key = ${result.pdfKey}, pdf_size = ${Number(result.pdfSize || 0)}, pdf_data = NULL,
|
|
92
|
+
tickets_key = ${result.ticketsKey || null}, ticket_count = ${Number(result.ticketCount || 0)},
|
|
93
|
+
incident_sync_status = ${Number(result.ticketCount || 0) > 0 ? "PENDING" : "COMPLETED"},
|
|
94
|
+
incident_sync_error = NULL,
|
|
95
|
+
incident_synced_at = ${Number(result.ticketCount || 0) > 0 ? null : new Date()},
|
|
92
96
|
completed_at = now(), updated_at = now()
|
|
93
97
|
WHERE id = ${id}
|
|
94
98
|
`);
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
async findIncidentSyncCandidates(limit = 10) {
|
|
102
|
+
return rows(await this.db.execute(sql`
|
|
103
|
+
SELECT * FROM ${sql.raw(this.reports)}
|
|
104
|
+
WHERE status = 'COMPLETED'
|
|
105
|
+
AND COALESCE(ticket_count, (result->>'ticketCount')::integer, 0) > 0
|
|
106
|
+
AND incident_sync_status IN ('PENDING', 'FAILED')
|
|
107
|
+
ORDER BY completed_at ASC
|
|
108
|
+
LIMIT ${Math.min(Math.max(Number(limit) || 10, 1), 50)}
|
|
109
|
+
`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async setIncidentSyncState(reportId, status, error = null) {
|
|
113
|
+
await this.db.execute(sql`
|
|
114
|
+
UPDATE ${sql.raw(this.reports)}
|
|
115
|
+
SET incident_sync_status = ${status}, incident_sync_error = ${error ? String(error?.message || error).slice(0, 2000) : null},
|
|
116
|
+
incident_synced_at = ${status === "COMPLETED" ? new Date() : null}, updated_at = now()
|
|
117
|
+
WHERE id = ${reportId}
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
async complete(id, analysis, pdf) {
|
|
98
122
|
await this.db.execute(sql`
|
|
99
123
|
UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', model_id = ${analysis.modelId}, data_source = ${analysis.dataSource || null},
|
|
@@ -112,7 +136,8 @@ export class CostAnalysisReportRepository {
|
|
|
112
136
|
return rows(await this.db.execute(sql`
|
|
113
137
|
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
114
138
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
115
|
-
markdown_key, markdown_size, pdf_key, pdf_size,
|
|
139
|
+
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
140
|
+
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
116
141
|
started_at, completed_at, created_at
|
|
117
142
|
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
|
|
118
143
|
ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
|
|
@@ -126,7 +151,8 @@ export class CostAnalysisReportRepository {
|
|
|
126
151
|
return rows(await this.db.execute(sql`
|
|
127
152
|
SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
|
|
128
153
|
attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
|
|
129
|
-
markdown_key, markdown_size, pdf_key, pdf_size,
|
|
154
|
+
markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
|
|
155
|
+
incident_sync_status, incident_sync_error, incident_synced_at, error,
|
|
130
156
|
started_at, completed_at, created_at
|
|
131
157
|
FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
|
|
132
158
|
`))[0] || null;
|
|
@@ -38,6 +38,11 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
38
38
|
markdown_key text,
|
|
39
39
|
markdown_size integer,
|
|
40
40
|
pdf_key text,
|
|
41
|
+
tickets_key text,
|
|
42
|
+
ticket_count integer NOT NULL DEFAULT 0,
|
|
43
|
+
incident_sync_status text NOT NULL DEFAULT 'PENDING' CHECK (incident_sync_status IN ('PENDING', 'COMPLETED', 'FAILED')),
|
|
44
|
+
incident_sync_error text,
|
|
45
|
+
incident_synced_at timestamptz,
|
|
41
46
|
error text,
|
|
42
47
|
started_at timestamptz,
|
|
43
48
|
completed_at timestamptz,
|
|
@@ -54,4 +59,13 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
|
|
|
54
59
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS pdf_key text`));
|
|
55
60
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_name text`));
|
|
56
61
|
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_source text`));
|
|
62
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS tickets_key text`));
|
|
63
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS ticket_count integer NOT NULL DEFAULT 0`));
|
|
64
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_sync_status text NOT NULL DEFAULT 'PENDING'`));
|
|
65
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_sync_error text`));
|
|
66
|
+
await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS incident_synced_at timestamptz`));
|
|
67
|
+
await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_reports
|
|
68
|
+
SET ticket_count = (result->>'ticketCount')::integer
|
|
69
|
+
WHERE ticket_count = 0 AND result ? 'ticketCount' AND (result->>'ticketCount') ~ '^[0-9]+$'`));
|
|
70
|
+
await db.execute(sql.raw(`DROP TABLE IF EXISTS ${qSchema}.cost_ai_incident_tickets`));
|
|
57
71
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class CostIncidentSyncService {
|
|
2
|
+
constructor({ repository, artifactService, incidentSink = null, logger = console }) {
|
|
3
|
+
this.repository = repository;
|
|
4
|
+
this.artifactService = artifactService;
|
|
5
|
+
this.incidentSink = incidentSink;
|
|
6
|
+
this.logger = logger;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ticketsKey(report) {
|
|
10
|
+
if (report.tickets_key) return report.tickets_key;
|
|
11
|
+
const artifactKey = report.markdown_key || report.pdf_key || "";
|
|
12
|
+
const slash = artifactKey.lastIndexOf("/");
|
|
13
|
+
return slash >= 0 ? `${artifactKey.slice(0, slash + 1)}tickets.json` : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async syncReport(report) {
|
|
17
|
+
if (!this.incidentSink) return false;
|
|
18
|
+
try {
|
|
19
|
+
const key = this.ticketsKey(report);
|
|
20
|
+
if (!report.artifact_bucket || !key) throw new Error("FinOps ticket artifact location is missing");
|
|
21
|
+
const data = await this.artifactService.get(report.artifact_bucket, key);
|
|
22
|
+
if (!data) throw new Error("FinOps ticket artifact is empty");
|
|
23
|
+
const parsed = JSON.parse(data.toString("utf8"));
|
|
24
|
+
const tickets = Array.isArray(parsed?.tickets) ? parsed.tickets.filter((ticket) => ticket?.ticket_id) : [];
|
|
25
|
+
|
|
26
|
+
for (const ticket of tickets) {
|
|
27
|
+
const result = await this.incidentSink({ tenantId: report.tenant_id, report, ticket });
|
|
28
|
+
if (!result?.id) throw new Error(`Incident integration did not return an incident ID for ${ticket.ticket_id}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await this.repository.setIncidentSyncState(report.id, "COMPLETED");
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
await this.repository.setIncidentSyncState(report.id, "FAILED", error);
|
|
35
|
+
this.logger.error?.("[Cost AI Reports] Incident synchronization failed", {
|
|
36
|
+
tenantId: report.tenant_id,
|
|
37
|
+
reportId: report.id,
|
|
38
|
+
message: error.message,
|
|
39
|
+
});
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async syncPending(limit = 10) {
|
|
45
|
+
if (!this.incidentSink) return;
|
|
46
|
+
for (const report of await this.repository.findIncidentSyncCandidates(limit)) {
|
|
47
|
+
await this.syncReport(report);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
|
|
2
2
|
export class CostAnalysisReportWorker {
|
|
3
|
-
constructor({ repository, analyserService, logger = console, env = process.env }) {
|
|
3
|
+
constructor({ repository, analyserService, incidentSyncService = null, logger = console, env = process.env }) {
|
|
4
4
|
this.repository = repository;
|
|
5
5
|
this.analyserService = analyserService;
|
|
6
|
+
this.incidentSyncService = incidentSyncService;
|
|
6
7
|
this.logger = logger;
|
|
7
8
|
this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
|
|
8
9
|
this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
|
|
@@ -30,6 +31,7 @@ export class CostAnalysisReportWorker {
|
|
|
30
31
|
onStarted: (metadata) => this.repository.setTaskStarted(report.id, metadata),
|
|
31
32
|
});
|
|
32
33
|
await this.repository.completeExternal(report.id, result);
|
|
34
|
+
await this.incidentSyncService?.syncPending?.();
|
|
33
35
|
await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
|
|
34
36
|
} catch (error) {
|
|
35
37
|
await this.repository.fail(report.id, error);
|
|
@@ -42,6 +44,7 @@ export class CostAnalysisReportWorker {
|
|
|
42
44
|
if (!this.enabled || this.running) return;
|
|
43
45
|
this.running = true;
|
|
44
46
|
try {
|
|
47
|
+
await this.incidentSyncService?.syncPending?.();
|
|
45
48
|
const schedules = await this.repository.claimDue(this.batchSize, this.leaseMs);
|
|
46
49
|
for (const schedule of schedules) await this.run(schedule);
|
|
47
50
|
} catch (error) {
|