@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
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, error,
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, error,
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) {