@meyicloud/meyi-cost-server 1.8.6 → 1.8.7

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.8.6",
3
+ "version": "1.8.7",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -112,27 +112,37 @@ function rangeWhere(range) {
112
112
  AND CAST(line_item_usage_start_date AS TIMESTAMP) < TIMESTAMP ${sqlString(`${range.End} 00:00:00`)}`;
113
113
  }
114
114
 
115
- function inCondition(column, values = []) {
115
+ function inCondition(column, values = []) {
116
116
  if (!values.length) return "";
117
117
  return `AND CAST("${column}" AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
118
- }
119
-
120
- function cur2TagExpression(tagKey) {
121
- const key = String(tagKey || "Name").trim();
122
- if (!key) throw new Error("Invalid CUR tag key");
123
- return `COALESCE(element_at(resource_tags, ${sqlString(`user:${key.replace(/^user:/, "")}`)}), element_at(resource_tags, ${sqlString(key.replace(/^user:/, ""))}))`;
124
- }
118
+ }
119
+
120
+ function cur2TagExpression(tagKey) {
121
+ const key = String(tagKey || "").trim();
122
+ if (!key) throw new Error("Invalid CUR tag key");
123
+ const plainKey = key.replace(/^(?:user:|resourceTags\/)/, "");
124
+ const candidates = key.includes("/")
125
+ ? [key]
126
+ : [`resourceTags/${plainKey}`, `user:${plainKey}`, plainKey];
127
+ return `COALESCE(${[...new Set(candidates)].map((candidate) => `element_at(resource_tags, ${sqlString(candidate)})`).join(", ")})`;
128
+ }
129
+
130
+ const regionExpression = `COALESCE(
131
+ NULLIF(TRIM(CAST(product_region_code AS VARCHAR)), ''),
132
+ NULLIF(regexp_extract(COALESCE(line_item_resource_id, ''), '^arn:[^:]+:[^:]+:([^:]+):', 1), ''),
133
+ 'Region unavailable in CUR'
134
+ )`;
125
135
 
126
136
  function inExpression(expression, values = []) {
127
137
  if (!values.length) return "";
128
138
  return `AND CAST(${expression} AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
129
139
  }
130
140
 
131
- function filterWhere(filters = {}) {
132
- const conditions = [
133
- inCondition("product_product_name", filters.services),
134
- inCondition("product_region_code", filters.regions),
135
- inCondition("line_item_usage_account_id", filters.accountIds),
141
+ function filterWhere(filters = {}) {
142
+ const conditions = [
143
+ inCondition("product_product_name", filters.services),
144
+ inExpression(regionExpression, filters.regions),
145
+ inCondition("line_item_usage_account_id", filters.accountIds),
136
146
  ];
137
147
  if (filters.tagKey && filters.tagValues?.length) {
138
148
  conditions.push(inExpression(cur2TagExpression(filters.tagKey), filters.tagValues));
@@ -141,14 +151,15 @@ function filterWhere(filters = {}) {
141
151
  }
142
152
 
143
153
  function dimensionExpression(groupBy, tagKey) {
144
- const dimensions = {
145
- service: "product_product_name",
146
- account: "line_item_usage_account_id",
147
- region: "product_region_code",
148
- resource: "line_item_resource_id",
149
- };
150
- if (groupBy === "tag") return cur2TagExpression(tagKey);
151
- return dimensions[groupBy] ? `"${dimensions[groupBy]}"` : undefined;
154
+ const dimensions = {
155
+ service: "product_product_name",
156
+ account: "line_item_usage_account_id",
157
+ region: regionExpression,
158
+ resource: "line_item_resource_id",
159
+ };
160
+ if (groupBy === "tag") return cur2TagExpression(tagKey);
161
+ if (groupBy === "region") return dimensions.region;
162
+ return dimensions[groupBy] ? `"${dimensions[groupBy]}"` : undefined;
152
163
  }
153
164
 
154
165
  function summarize(periods, accountNames = new Map()) {
@@ -166,7 +177,7 @@ function summarize(periods, accountNames = new Map()) {
166
177
  .sort((a, b) => b.amount - a.amount);
167
178
  }
168
179
 
169
- export async function getCurReport({ client, config, tenant, range, groupBy, granularity = "MONTHLY", tagKey, accountNames = new Map(), filters = {}, includeBreakdown = false }) {
180
+ export async function getCurReport({ client, config, tenant, range, groupBy, granularity = "MONTHLY", tagKey, accountNames = new Map(), filters = {}, includeBreakdown = false }) {
170
181
  const column = dimensionExpression(groupBy, tagKey);
171
182
  if (!column) throw new Error("Unsupported CUR report group");
172
183
  const bucket = granularity === "DAILY" ? "day" : "month";
@@ -178,9 +189,10 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
178
189
  CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
179
190
  FROM ${table(config)}
180
191
  WHERE ${sourceWhere(config, tenant)}
181
- AND ${rangeWhere(range)}
182
- AND line_item_unblended_cost > 0
183
- ${filterWhere(filters)}
192
+ AND ${rangeWhere(range)}
193
+ AND line_item_unblended_cost > 0
194
+ ${groupBy === "tag" ? `AND NULLIF(TRIM(CAST(${column} AS VARCHAR)), '') IS NOT NULL` : ""}
195
+ ${filterWhere(filters)}
184
196
  GROUP BY 1, 2
185
197
  ORDER BY 1, 3 DESC
186
198
  ${limit}
@@ -188,18 +200,19 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
188
200
  const rows = await execute(client, config, query);
189
201
  const periods = rows.map((row) => ({ start: String(row.period_start).slice(0, 10), key: row.item_key || "Unallocated", amount: amount(row.amount) }));
190
202
  let summary = summarize(periods, accountNames);
191
- if (includeBreakdown && groupBy !== "resource") {
203
+ if (includeBreakdown) {
192
204
  const relationQuery = `
193
205
  SELECT
194
206
  COALESCE(NULLIF(CAST(${column} AS VARCHAR), ''), 'Unallocated') AS item_key,
195
207
  COALESCE(NULLIF(CAST(line_item_usage_account_id AS VARCHAR), ''), 'Unallocated') AS account_key,
196
- COALESCE(NULLIF(CAST(product_region_code AS VARCHAR), ''), 'GLOBAL') AS region_key,
208
+ ${regionExpression} AS region_key,
197
209
  CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
198
210
  FROM ${table(config)}
199
211
  WHERE ${sourceWhere(config, tenant)}
200
- AND ${rangeWhere(range)}
201
- AND line_item_unblended_cost > 0
202
- ${filterWhere(filters)}
212
+ AND ${rangeWhere(range)}
213
+ AND line_item_unblended_cost > 0
214
+ ${groupBy === "tag" ? `AND NULLIF(TRIM(CAST(${column} AS VARCHAR)), '') IS NOT NULL` : ""}
215
+ ${filterWhere(filters)}
203
216
  GROUP BY 1, 2, 3
204
217
  `;
205
218
  const relationRows = await execute(client, config, relationQuery);
@@ -213,7 +226,7 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
213
226
  values.set(relationKey, (values.get(relationKey) || 0) + amount(row.amount));
214
227
  };
215
228
  add(accountsByItem, row.account_key || "Unallocated");
216
- add(regionsByItem, row.region_key || "GLOBAL");
229
+ add(regionsByItem, row.region_key || "Region unavailable in CUR");
217
230
  }
218
231
  const relations = (target, key, type) => [...(target.get(key) || new Map()).entries()].map(([value, cost]) => ({ key: value, label: type === "account" ? accountNames.get(value) || value : value, amount: round(cost) })).sort((a, b) => b.amount - a.amount);
219
232
  summary = summary.map((item) => ({ ...item, accounts: relations(accountsByItem, item.key, "account"), regions: relations(regionsByItem, item.key, "region") }));
@@ -302,7 +315,7 @@ export async function getCurOverview({ client, config, tenant, range, trendRange
302
315
  };
303
316
  }
304
317
 
305
- export async function getCurTags({ client, config, tenant }) {
318
+ export async function getCurTags({ client, config, tenant }) {
306
319
  if (!tenant) throw new Error("A tenant is required to list CUR tags");
307
320
  const query = `
308
321
  SELECT DISTINCT tag_key
@@ -314,8 +327,10 @@ export async function getCurTags({ client, config, tenant }) {
314
327
  ORDER BY tag_key
315
328
  `;
316
329
  const rows = await execute(client, config, query);
317
- return rows.map((row) => String(row.tag_key || "").replace(/^user:/, "")).filter(Boolean);
318
- }
330
+ return rows
331
+ .map((row) => String(row.tag_key || "").replace(/^(?:user:|resourceTags\/)/, ""))
332
+ .filter(Boolean);
333
+ }
319
334
 
320
335
  export async function getCurFilterOptions({ client, config, tenant, range, tagKey, accountNames = new Map() }) {
321
336
  const distinct = async (column) => execute(client, config, `
@@ -328,9 +343,16 @@ export async function getCurFilterOptions({ client, config, tenant, range, tagKe
328
343
  AND TRIM(CAST("${column}" AS VARCHAR)) <> ''
329
344
  ORDER BY 1
330
345
  `);
331
- const [services, regions, accounts, tags] = await Promise.all([
332
- distinct("product_product_name"),
333
- distinct("product_region_code"),
346
+ const [services, regions, accounts, tags] = await Promise.all([
347
+ distinct("product_product_name"),
348
+ execute(client, config, `
349
+ SELECT DISTINCT CAST(${regionExpression} AS VARCHAR) AS value
350
+ FROM ${table(config)}
351
+ WHERE ${sourceWhere(config, tenant)}
352
+ AND ${rangeWhere(range)}
353
+ AND line_item_unblended_cost > 0
354
+ ORDER BY 1
355
+ `),
334
356
  distinct("line_item_usage_account_id"),
335
357
  getCurTags({ client, config, tenant }),
336
358
  ]);
@@ -7,7 +7,13 @@ export class CostAnalysisReportWorker {
7
7
  this.logger = logger;
8
8
  this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
9
9
  this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
10
- this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 30 * 60_000), 60_000);
10
+ // Must exceed COST_AI_ANALYSER_TIMEOUT_MS (45 min). claimDue holds a
11
+ // schedule by pushing next_run_at forward by the lease; run() releases it
12
+ // on every exit path, so the value only matters when the process dies
13
+ // mid-run. A lease shorter than the analyser timeout lets a second worker
14
+ // re-claim a schedule whose Fargate task is still running - a duplicate
15
+ // report row and a duplicate billed model run.
16
+ this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 60 * 60_000), 60_000);
11
17
  this.batchSize = Math.min(Math.max(Number(env.COST_AI_REPORT_BATCH_SIZE || 5), 1), 25);
12
18
  this.running = false;
13
19
  this.timer = null;