@meyicloud/meyi-cost-server 1.8.5 → 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/README.md CHANGED
@@ -48,7 +48,7 @@ flowchart LR
48
48
  Router --> Controller[Cost controller]
49
49
  Controller --> Customer[Tenant CUR metadata]
50
50
  Controller --> SaaS[SaaS Athena context]
51
- SaaS --> Athena[Central tenant-partitioned CUR]
51
+ SaaS --> Athena[Central tenant and connection-partitioned CUR]
52
52
  Controller --> Budget[Budget services]
53
53
  Budget --> DB[(PostgreSQL)]
54
54
  ```
@@ -319,6 +319,7 @@ failed and do not change CUR, standard reports, or onboarding.
319
319
  | `AWS_REGION` | `us-east-1` | No | Used as the CUR region fallback. |
320
320
  | `COST_CUR_TENANT_COLUMN` | `tenant_id` | No | CUR column used to isolate the Hive-style tenant partition. |
321
321
  | `COST_CUR_TENANT_PARTITION` | Request tenant ID | No | Overrides the partition value. Avoid a global override in multi-tenant production unless every request is intentionally mapped to that partition. |
322
+ | `COST_CUR_CONNECTION_COLUMN` | `connection_id` | No | CUR column used to isolate the selected AWS source connection. The connection value is resolved from onboarding data and is not a global environment override. |
322
323
  | `COST_CUR_MAX_ROWS` | `1000` | No | Resource-report row cap, clamped between 1 and 5000. |
323
324
  | `COST_CUR_ROLE_ARN` | Empty | No | Dedicated SaaS-side role assumed only for central Athena/Glue/S3 queries. |
324
325
  | `COST_CUR_EXTERNAL_ID` | Empty | No | External ID for the dedicated SaaS CUR role. |
@@ -344,6 +345,7 @@ COST_CUR_OUTPUT_LOCATION=s3://meyi-saas-athena-results/
344
345
  COST_CUR_REGION=us-east-1
345
346
  COST_CUR_WORKGROUP=meyi-cost
346
347
  COST_CUR_TENANT_COLUMN=tenant_id
348
+ COST_CUR_CONNECTION_COLUMN=connection_id
347
349
  COST_CUR_MAX_ROWS=1000
348
350
  COST_CUR_ROLE_ARN=arn:aws:iam::SAAS_ACCOUNT_ID:role/meyi-cur-query
349
351
  COST_CUR_ALLOW_TENANT_CATALOG=false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.8.5",
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",
@@ -27,8 +27,9 @@ export class CurDiscoveryAws {
27
27
  this.env = env;
28
28
  this.region = String(env.COST_CUR_REGION || env.AWS_REGION || "us-east-1");
29
29
  this.database = String(env.COST_CUR_DATABASE || "").trim();
30
- this.tableOverride = String(env.COST_CUR_TABLE || "").trim();
31
- this.tenantColumn = String(env.COST_CUR_TENANT_COLUMN || "tenant_id").trim();
30
+ this.tableOverride = String(env.COST_CUR_TABLE || "").trim();
31
+ this.tenantColumn = String(env.COST_CUR_TENANT_COLUMN || "tenant_id").trim();
32
+ this.connectionColumn = String(env.COST_CUR_CONNECTION_COLUMN || "connection_id").trim();
32
33
  const credentials = centralCredentials(env, credentialProvider);
33
34
  this.glue = glueClient || new GlueClient({ region: this.region, credentials });
34
35
  this.s3 = s3Client || new S3Client({ region: this.region, credentials });
@@ -60,9 +61,10 @@ export class CurDiscoveryAws {
60
61
  const columns = new Set((table.StorageDescriptor?.Columns || []).map((item) => String(item.Name || "").toLowerCase()));
61
62
  const partitions = new Set((table.PartitionKeys || []).map((item) => String(item.Name || "").toLowerCase()));
62
63
  const pathMatches = location && (tenantUri.startsWith(location) || location.startsWith(tenantUri));
63
- const tenantSafe = partitions.has(this.tenantColumn.toLowerCase()) || columns.has(this.tenantColumn.toLowerCase());
64
- const curSchema = columns.has("line_item_usage_start_date");
65
- if (pathMatches && tenantSafe && curSchema) {
64
+ const tenantSafe = partitions.has(this.tenantColumn.toLowerCase()) || columns.has(this.tenantColumn.toLowerCase());
65
+ const connectionSafe = partitions.has(this.connectionColumn.toLowerCase()) || columns.has(this.connectionColumn.toLowerCase());
66
+ const curSchema = columns.has("line_item_usage_start_date");
67
+ if (pathMatches && tenantSafe && connectionSafe && curSchema) {
66
68
  candidates.push({
67
69
  database: this.database,
68
70
  name: table.Name,
@@ -61,7 +61,8 @@ export class CurDiscoveryService {
61
61
  curSourceBucket: job.cur_bucket,
62
62
  curSourcePrefix: job.cur_prefix,
63
63
  curSourceRegion: job.cur_region,
64
- curIngestionMode: "central",
64
+ curIngestionMode: "central",
65
+ connectionId: job.connection_id,
65
66
  },
66
67
  };
67
68
  const context = this.athenaContextService.resolve(customerContext);
@@ -201,6 +201,8 @@ export class CostAnalyserService {
201
201
  COST_CUR_REGION: athena.region,
202
202
  COST_CUR_TENANT_COLUMN: athena.tenantColumn,
203
203
  COST_CUR_TENANT_PARTITION: athena.tenantPartition || tenantId,
204
+ COST_CUR_CONNECTION_COLUMN: required(athena.connectionColumn, "COST_CUR_CONNECTION_COLUMN"),
205
+ COST_CUR_CONNECTION_PARTITION: required(athena.connectionPartition, "COST_CUR_CONNECTION_PARTITION"),
204
206
  AI_PROVIDER_SECRET_ARN: providerSecretArn,
205
207
  AI_PROVIDER_SECRET_REGION: this.region,
206
208
  TOP_N_SERVICES: this.topServices,
@@ -27,9 +27,11 @@ export function getCurConfig(env = process.env, metadata = {}) {
27
27
  table: tableName ? identifier(tableName, "table", true) : "",
28
28
  outputLocation,
29
29
  region: String(metadata.curRegion || env.COST_CUR_REGION || env.AWS_REGION || "us-east-1"),
30
- workgroup: identifier(metadata.curWorkgroup || env.COST_CUR_WORKGROUP || "meyi-cost", "workgroup", true),
31
- tenantColumn: identifier(metadata.curTenantColumn || env.COST_CUR_TENANT_COLUMN || "tenant_id", "tenant column"),
32
- tenantPartition: String(metadata.curTenantPartition || env.COST_CUR_TENANT_PARTITION || "").trim(),
30
+ workgroup: identifier(metadata.curWorkgroup || env.COST_CUR_WORKGROUP || "meyi-cost", "workgroup", true),
31
+ tenantColumn: identifier(metadata.curTenantColumn || env.COST_CUR_TENANT_COLUMN || "tenant_id", "tenant column"),
32
+ tenantPartition: String(metadata.curTenantPartition || env.COST_CUR_TENANT_PARTITION || "").trim(),
33
+ connectionColumn: identifier(metadata.curConnectionColumn || env.COST_CUR_CONNECTION_COLUMN || "connection_id", "connection column"),
34
+ connectionPartition: String(metadata.curConnectionPartition || "").trim(),
33
35
  maxRows: Math.min(Math.max(Number(env.COST_CUR_MAX_ROWS || 1000), 1), 5000),
34
36
  };
35
37
  }
@@ -82,10 +84,13 @@ function table(config) {
82
84
  return `"${config.database}"."${config.table}"`;
83
85
  }
84
86
 
85
- function tenantWhere(config, tenant) {
86
- const partition = config.tenantPartition || tenant;
87
- return `"${config.tenantColumn}" = ${sqlString(partition)}`;
88
- }
87
+ function sourceWhere(config, tenant) {
88
+ const partition = config.tenantPartition || tenant;
89
+ if (!config.connectionPartition) {
90
+ throw new Error("CUR connection partition is required for source-isolated queries");
91
+ }
92
+ return `"${config.tenantColumn}" = ${sqlString(partition)}\n AND "${config.connectionColumn}" = ${sqlString(config.connectionPartition)}`;
93
+ }
89
94
 
90
95
  export async function getCurDataStatus({ client, config, tenant }) {
91
96
  const statusRows = await execute(client, config, `
@@ -93,7 +98,7 @@ export async function getCurDataStatus({ client, config, tenant }) {
93
98
  CAST(COUNT(*) AS BIGINT) AS record_count,
94
99
  CAST(MAX(CAST(line_item_usage_start_date AS TIMESTAMP)) AS VARCHAR) AS last_data_at
95
100
  FROM ${table(config)}
96
- WHERE ${tenantWhere(config, tenant)}
101
+ WHERE ${sourceWhere(config, tenant)}
97
102
  `);
98
103
  const status = statusRows[0] || {};
99
104
  return {
@@ -107,27 +112,37 @@ function rangeWhere(range) {
107
112
  AND CAST(line_item_usage_start_date AS TIMESTAMP) < TIMESTAMP ${sqlString(`${range.End} 00:00:00`)}`;
108
113
  }
109
114
 
110
- function inCondition(column, values = []) {
115
+ function inCondition(column, values = []) {
111
116
  if (!values.length) return "";
112
117
  return `AND CAST("${column}" AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
113
- }
114
-
115
- function cur2TagExpression(tagKey) {
116
- const key = String(tagKey || "Name").trim();
117
- if (!key) throw new Error("Invalid CUR tag key");
118
- return `COALESCE(element_at(resource_tags, ${sqlString(`user:${key.replace(/^user:/, "")}`)}), element_at(resource_tags, ${sqlString(key.replace(/^user:/, ""))}))`;
119
- }
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
+ )`;
120
135
 
121
136
  function inExpression(expression, values = []) {
122
137
  if (!values.length) return "";
123
138
  return `AND CAST(${expression} AS VARCHAR) IN (${values.map(sqlString).join(", ")})`;
124
139
  }
125
140
 
126
- function filterWhere(filters = {}) {
127
- const conditions = [
128
- inCondition("product_product_name", filters.services),
129
- inCondition("product_region_code", filters.regions),
130
- 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),
131
146
  ];
132
147
  if (filters.tagKey && filters.tagValues?.length) {
133
148
  conditions.push(inExpression(cur2TagExpression(filters.tagKey), filters.tagValues));
@@ -136,14 +151,15 @@ function filterWhere(filters = {}) {
136
151
  }
137
152
 
138
153
  function dimensionExpression(groupBy, tagKey) {
139
- const dimensions = {
140
- service: "product_product_name",
141
- account: "line_item_usage_account_id",
142
- region: "product_region_code",
143
- resource: "line_item_resource_id",
144
- };
145
- if (groupBy === "tag") return cur2TagExpression(tagKey);
146
- 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;
147
163
  }
148
164
 
149
165
  function summarize(periods, accountNames = new Map()) {
@@ -161,7 +177,7 @@ function summarize(periods, accountNames = new Map()) {
161
177
  .sort((a, b) => b.amount - a.amount);
162
178
  }
163
179
 
164
- 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 }) {
165
181
  const column = dimensionExpression(groupBy, tagKey);
166
182
  if (!column) throw new Error("Unsupported CUR report group");
167
183
  const bucket = granularity === "DAILY" ? "day" : "month";
@@ -172,10 +188,11 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
172
188
  COALESCE(NULLIF(CAST(${column} AS VARCHAR), ''), 'Unallocated') AS item_key,
173
189
  CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
174
190
  FROM ${table(config)}
175
- WHERE ${tenantWhere(config, tenant)}
176
- AND ${rangeWhere(range)}
177
- AND line_item_unblended_cost > 0
178
- ${filterWhere(filters)}
191
+ WHERE ${sourceWhere(config, tenant)}
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)}
179
196
  GROUP BY 1, 2
180
197
  ORDER BY 1, 3 DESC
181
198
  ${limit}
@@ -183,18 +200,19 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
183
200
  const rows = await execute(client, config, query);
184
201
  const periods = rows.map((row) => ({ start: String(row.period_start).slice(0, 10), key: row.item_key || "Unallocated", amount: amount(row.amount) }));
185
202
  let summary = summarize(periods, accountNames);
186
- if (includeBreakdown && groupBy !== "resource") {
203
+ if (includeBreakdown) {
187
204
  const relationQuery = `
188
205
  SELECT
189
206
  COALESCE(NULLIF(CAST(${column} AS VARCHAR), ''), 'Unallocated') AS item_key,
190
207
  COALESCE(NULLIF(CAST(line_item_usage_account_id AS VARCHAR), ''), 'Unallocated') AS account_key,
191
- COALESCE(NULLIF(CAST(product_region_code AS VARCHAR), ''), 'GLOBAL') AS region_key,
208
+ ${regionExpression} AS region_key,
192
209
  CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS amount
193
210
  FROM ${table(config)}
194
- WHERE ${tenantWhere(config, tenant)}
195
- AND ${rangeWhere(range)}
196
- AND line_item_unblended_cost > 0
197
- ${filterWhere(filters)}
211
+ WHERE ${sourceWhere(config, tenant)}
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)}
198
216
  GROUP BY 1, 2, 3
199
217
  `;
200
218
  const relationRows = await execute(client, config, relationQuery);
@@ -208,7 +226,7 @@ export async function getCurReport({ client, config, tenant, range, groupBy, gra
208
226
  values.set(relationKey, (values.get(relationKey) || 0) + amount(row.amount));
209
227
  };
210
228
  add(accountsByItem, row.account_key || "Unallocated");
211
- add(regionsByItem, row.region_key || "GLOBAL");
229
+ add(regionsByItem, row.region_key || "Region unavailable in CUR");
212
230
  }
213
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);
214
232
  summary = summary.map((item) => ({ ...item, accounts: relations(accountsByItem, item.key, "account"), regions: relations(regionsByItem, item.key, "region") }));
@@ -246,7 +264,7 @@ export async function getCurOverview({ client, config, tenant, range, trendRange
246
264
  CAST(SUM(line_item_unblended_cost) AS DOUBLE) AS net_cost,
247
265
  CAST(COUNT(DISTINCT CASE WHEN line_item_unblended_cost > 0 AND line_item_resource_id IS NOT NULL AND line_item_resource_id <> '' THEN line_item_resource_id END) AS BIGINT) AS active_resources
248
266
  FROM ${table(config)}
249
- WHERE ${tenantWhere(config, tenant)} AND ${rangeWhere(range)}
267
+ WHERE ${sourceWhere(config, tenant)} AND ${rangeWhere(range)}
250
268
  `;
251
269
  const [metricsRows, services, accounts, regions, trendReport] = await Promise.all([
252
270
  execute(client, config, metricsQuery),
@@ -297,35 +315,44 @@ export async function getCurOverview({ client, config, tenant, range, trendRange
297
315
  };
298
316
  }
299
317
 
300
- export async function getCurTags({ client, config, tenant }) {
318
+ export async function getCurTags({ client, config, tenant }) {
301
319
  if (!tenant) throw new Error("A tenant is required to list CUR tags");
302
320
  const query = `
303
321
  SELECT DISTINCT tag_key
304
322
  FROM ${table(config)}
305
323
  CROSS JOIN UNNEST(map_keys(resource_tags)) AS tags(tag_key)
306
- WHERE ${tenantWhere(config, tenant)}
324
+ WHERE ${sourceWhere(config, tenant)}
307
325
  AND tag_key IS NOT NULL
308
326
  AND TRIM(tag_key) <> ''
309
327
  ORDER BY tag_key
310
328
  `;
311
329
  const rows = await execute(client, config, query);
312
- return rows.map((row) => String(row.tag_key || "").replace(/^user:/, "")).filter(Boolean);
313
- }
330
+ return rows
331
+ .map((row) => String(row.tag_key || "").replace(/^(?:user:|resourceTags\/)/, ""))
332
+ .filter(Boolean);
333
+ }
314
334
 
315
335
  export async function getCurFilterOptions({ client, config, tenant, range, tagKey, accountNames = new Map() }) {
316
336
  const distinct = async (column) => execute(client, config, `
317
337
  SELECT DISTINCT CAST("${column}" AS VARCHAR) AS value
318
338
  FROM ${table(config)}
319
- WHERE ${tenantWhere(config, tenant)}
339
+ WHERE ${sourceWhere(config, tenant)}
320
340
  AND ${rangeWhere(range)}
321
341
  AND line_item_unblended_cost > 0
322
342
  AND "${column}" IS NOT NULL
323
343
  AND TRIM(CAST("${column}" AS VARCHAR)) <> ''
324
344
  ORDER BY 1
325
345
  `);
326
- const [services, regions, accounts, tags] = await Promise.all([
327
- distinct("product_product_name"),
328
- 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
+ `),
329
356
  distinct("line_item_usage_account_id"),
330
357
  getCurTags({ client, config, tenant }),
331
358
  ]);
@@ -334,7 +361,7 @@ export async function getCurFilterOptions({ client, config, tenant, range, tagKe
334
361
  tagValues = (await execute(client, config, `
335
362
  SELECT DISTINCT CAST(${cur2TagExpression(tagKey)} AS VARCHAR) AS value
336
363
  FROM ${table(config)}
337
- WHERE ${tenantWhere(config, tenant)}
364
+ WHERE ${sourceWhere(config, tenant)}
338
365
  AND ${rangeWhere(range)}
339
366
  AND line_item_unblended_cost > 0
340
367
  AND ${cur2TagExpression(tagKey)} IS NOT NULL
@@ -23,7 +23,11 @@ export class SaasAthenaContextService {
23
23
  this.env.COST_CUR_TENANT_PARTITION
24
24
  || ingestion.tenantPartition,
25
25
  ).trim();
26
- const resolvedConfig = getCurConfig(this.env, { ...catalogMeta, curTenantPartition: tenantPartition });
26
+ const resolvedConfig = getCurConfig(this.env, {
27
+ ...catalogMeta,
28
+ curTenantPartition: tenantPartition,
29
+ curConnectionPartition: tenantMeta.connectionId,
30
+ });
27
31
  const roleArn = String(this.env.COST_CUR_ROLE_ARN || "").trim();
28
32
  const config = resolvedConfig;
29
33
  const credentials = roleArn ? this.credentialProvider({
@@ -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;