@oneuptime/common 10.0.70 → 10.0.72

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.
Files changed (144) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/Incident.ts +55 -0
  3. package/Models/DatabaseModels/KubernetesCluster.ts +6 -4
  4. package/Models/DatabaseModels/Project.ts +5 -5
  5. package/Models/DatabaseModels/StatusPage.ts +80 -0
  6. package/Server/API/StatusPageAPI.ts +4 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.ts +6 -3
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +17 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1776940714709-MigrationName.ts +41 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1776971364783-AddStatusPageLanguageSettings.ts +25 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  12. package/Server/Services/AIBillingService.ts +2 -2
  13. package/Server/Services/AnalyticsDatabaseService.ts +17 -7
  14. package/Server/Services/BillingService.ts +116 -48
  15. package/Server/Services/NotificationService.ts +2 -2
  16. package/Server/Types/Database/QueryUtil.ts +13 -7
  17. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +175 -29
  18. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +71 -0
  19. package/Server/Utils/Monitor/MonitorAlert.ts +170 -7
  20. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +171 -2
  21. package/Server/Utils/Monitor/MonitorIncident.ts +212 -8
  22. package/Server/Utils/Monitor/MonitorMetricUtil.ts +423 -1
  23. package/Server/Utils/Monitor/MonitorResource.ts +2 -0
  24. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +99 -0
  25. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +268 -0
  26. package/Types/BaseDatabase/IncludesNone.ts +1 -4
  27. package/Types/Email.ts +50 -0
  28. package/Types/Infrastructure/BasicMetrics.ts +75 -0
  29. package/Types/Metrics/MetricQueryData.ts +11 -0
  30. package/Types/Monitor/CriteriaFilter.ts +10 -0
  31. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +11 -0
  32. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +10 -0
  33. package/Types/Monitor/MetricMonitor/MetricSeriesResult.ts +20 -0
  34. package/Types/Monitor/MonitorMetricType.ts +34 -0
  35. package/Types/Monitor/ServerMonitor/ServerMonitorResponse.ts +8 -0
  36. package/Types/Probe/ProbeApiIngestResponse.ts +25 -0
  37. package/Types/StatusPage/StatusPageLanguage.ts +29 -0
  38. package/UI/Components/Charts/Area/AreaChart.tsx +17 -12
  39. package/UI/Components/Charts/Bar/BarChart.tsx +16 -11
  40. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +23 -0
  41. package/UI/Components/Charts/Line/LineChart.tsx +16 -11
  42. package/UI/Components/Filters/DateFilter.tsx +16 -8
  43. package/UI/Components/Filters/EntityFilter.tsx +33 -18
  44. package/UI/Components/Filters/FilterViewer.tsx +7 -5
  45. package/UI/Components/Filters/FiltersForm.tsx +27 -5
  46. package/UI/Components/Filters/NumberFilter.tsx +3 -2
  47. package/UI/Components/Filters/TextFilter.tsx +5 -4
  48. package/UI/Components/ModelTable/BaseModelTable.tsx +5 -3
  49. package/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.ts +453 -0
  50. package/UI/Components/MonitorTemplateVariables/TemplateVariablesModal.tsx +229 -0
  51. package/Utils/Metrics/MetricSeriesFingerprint.ts +97 -0
  52. package/Utils/Monitor/MonitorMetricType.ts +309 -19
  53. package/build/dist/Models/DatabaseModels/Alert.js +57 -0
  54. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  55. package/build/dist/Models/DatabaseModels/Incident.js +57 -0
  56. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  57. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +6 -4
  58. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  59. package/build/dist/Models/DatabaseModels/Project.js +5 -5
  60. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  61. package/build/dist/Models/DatabaseModels/StatusPage.js +82 -0
  62. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  63. package/build/dist/Server/API/StatusPageAPI.js +4 -0
  64. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  65. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js +4 -2
  66. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js.map +1 -1
  67. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +12 -0
  68. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +1 -0
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776940714709-MigrationName.js +22 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776940714709-MigrationName.js.map +1 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776971364783-AddStatusPageLanguageSettings.js +14 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776971364783-AddStatusPageLanguageSettings.js.map +1 -0
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  75. package/build/dist/Server/Services/AIBillingService.js +2 -2
  76. package/build/dist/Server/Services/AIBillingService.js.map +1 -1
  77. package/build/dist/Server/Services/AnalyticsDatabaseService.js +14 -4
  78. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  79. package/build/dist/Server/Services/BillingService.js +99 -39
  80. package/build/dist/Server/Services/BillingService.js.map +1 -1
  81. package/build/dist/Server/Services/NotificationService.js +2 -2
  82. package/build/dist/Server/Services/NotificationService.js.map +1 -1
  83. package/build/dist/Server/Types/Database/QueryUtil.js +13 -7
  84. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  85. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +132 -30
  86. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  87. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +58 -7
  88. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  89. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +134 -12
  90. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  91. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +112 -0
  92. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  93. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +159 -15
  94. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  95. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +373 -0
  96. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  97. package/build/dist/Server/Utils/Monitor/MonitorResource.js +2 -0
  98. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  99. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +65 -0
  100. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  101. package/build/dist/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.js +199 -0
  102. package/build/dist/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.js.map +1 -1
  103. package/build/dist/Types/BaseDatabase/IncludesNone.js.map +1 -1
  104. package/build/dist/Types/Email.js +42 -0
  105. package/build/dist/Types/Email.js.map +1 -1
  106. package/build/dist/Types/Monitor/CriteriaFilter.js +10 -0
  107. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  108. package/build/dist/Types/Monitor/MetricMonitor/MetricSeriesResult.js +2 -0
  109. package/build/dist/Types/Monitor/MetricMonitor/MetricSeriesResult.js.map +1 -0
  110. package/build/dist/Types/Monitor/MonitorMetricType.js +28 -0
  111. package/build/dist/Types/Monitor/MonitorMetricType.js.map +1 -1
  112. package/build/dist/Types/StatusPage/StatusPageLanguage.js +21 -0
  113. package/build/dist/Types/StatusPage/StatusPageLanguage.js.map +1 -0
  114. package/build/dist/UI/Components/Charts/Area/AreaChart.js +13 -12
  115. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  116. package/build/dist/UI/Components/Charts/Bar/BarChart.js +12 -11
  117. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  118. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +11 -3
  119. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  120. package/build/dist/UI/Components/Charts/Line/LineChart.js +12 -11
  121. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  122. package/build/dist/UI/Components/Filters/DateFilter.js +1 -4
  123. package/build/dist/UI/Components/Filters/DateFilter.js.map +1 -1
  124. package/build/dist/UI/Components/Filters/EntityFilter.js +21 -14
  125. package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
  126. package/build/dist/UI/Components/Filters/FilterViewer.js +1 -2
  127. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  128. package/build/dist/UI/Components/Filters/FiltersForm.js +7 -3
  129. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  130. package/build/dist/UI/Components/Filters/NumberFilter.js +0 -1
  131. package/build/dist/UI/Components/Filters/NumberFilter.js.map +1 -1
  132. package/build/dist/UI/Components/Filters/TextFilter.js +5 -4
  133. package/build/dist/UI/Components/Filters/TextFilter.js.map +1 -1
  134. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +5 -3
  135. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  136. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js +383 -0
  137. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js.map +1 -0
  138. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesModal.js +109 -0
  139. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesModal.js.map +1 -0
  140. package/build/dist/Utils/Metrics/MetricSeriesFingerprint.js +81 -0
  141. package/build/dist/Utils/Metrics/MetricSeriesFingerprint.js.map +1 -0
  142. package/build/dist/Utils/Monitor/MonitorMetricType.js +287 -19
  143. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  144. package/package.json +1 -1
@@ -138,13 +138,37 @@ export class BillingService extends BaseService {
138
138
  line2 = rest.substring(0, 200);
139
139
  }
140
140
 
141
+ /*
142
+ * financeAccountingEmail may be a comma/semicolon-separated list. Parse the first
143
+ * valid address for Stripe (which stores a single email), and keep the full list
144
+ * in metadata so OneUptime can fan out invoice emails to every recipient.
145
+ */
146
+ const parsedEmails: Array<Email> = financeAccountingEmail
147
+ ? (() => {
148
+ try {
149
+ return Email.parseList(financeAccountingEmail);
150
+ } catch (err) {
151
+ logger.error(
152
+ `[Invoice Email] Failed to parse financeAccountingEmail for customer ${id}: ${err}`,
153
+ );
154
+ return [];
155
+ }
156
+ })()
157
+ : [];
158
+
159
+ const normalizedEmailList: string = parsedEmails
160
+ .map((e: Email): string => {
161
+ return e.toString();
162
+ })
163
+ .join(", ");
164
+
141
165
  const metadata: Record<string, string> = {
142
166
  business_details_full: businessDetails.substring(0, 5000),
143
167
  };
144
- if (financeAccountingEmail) {
145
- metadata["finance_accounting_email"] = financeAccountingEmail.substring(
168
+ if (normalizedEmailList) {
169
+ metadata["finance_accounting_email"] = normalizedEmailList.substring(
146
170
  0,
147
- 200,
171
+ 500,
148
172
  );
149
173
  } else {
150
174
  // Remove if cleared
@@ -165,11 +189,11 @@ export class BillingService extends BaseService {
165
189
  };
166
190
 
167
191
  /*
168
- * If finance / accounting email provided, set it as the customer email so Stripe sends
169
- * invoices / receipts there. (Stripe only supports a single email via API currently.)
192
+ * Stripe's customer.email only accepts one address. Use the first parsed email;
193
+ * the rest are delivered by OneUptime's own webhook-driven invoice email path.
170
194
  */
171
- if (financeAccountingEmail && financeAccountingEmail.trim().length > 0) {
172
- updateParams.email = financeAccountingEmail.trim();
195
+ if (parsedEmails.length > 0) {
196
+ updateParams.email = parsedEmails[0]!.toString();
173
197
  }
174
198
 
175
199
  if (line1) {
@@ -956,11 +980,16 @@ export class BillingService extends BaseService {
956
980
  @CaptureSpan()
957
981
  public async sendInvoiceByEmail(
958
982
  invoiceId: string,
959
- recipientEmail?: Email,
983
+ recipientEmails?: Array<Email>,
960
984
  projectId?: ObjectID,
961
985
  ): Promise<void> {
986
+ const recipientsForLog: string = (recipientEmails || [])
987
+ .map((e: Email): string => {
988
+ return e.toString();
989
+ })
990
+ .join(", ");
962
991
  logger.debug(
963
- `[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipientEmail: ${recipientEmail?.toString()}`,
992
+ `[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipients: ${recipientsForLog}`,
964
993
  );
965
994
 
966
995
  if (!this.isBillingEnabled()) {
@@ -986,13 +1015,22 @@ export class BillingService extends BaseService {
986
1015
  return;
987
1016
  }
988
1017
 
989
- // Determine recipient email
990
- let toEmail: Email | undefined = recipientEmail;
991
- if (!toEmail && stripeInvoice.customer_email) {
992
- toEmail = new Email(stripeInvoice.customer_email);
1018
+ /*
1019
+ * Determine recipient list. Fall back to the invoice's customer email when no
1020
+ * explicit recipients were supplied by the caller.
1021
+ */
1022
+ let toEmails: Array<Email> = recipientEmails || [];
1023
+ if (toEmails.length === 0 && stripeInvoice.customer_email) {
1024
+ try {
1025
+ toEmails = Email.parseList(stripeInvoice.customer_email);
1026
+ } catch (err) {
1027
+ logger.error(
1028
+ `[Invoice Email] Failed to parse Stripe customer_email "${stripeInvoice.customer_email}" for invoice ${invoiceId}: ${err}`,
1029
+ );
1030
+ }
993
1031
  }
994
1032
 
995
- if (!toEmail) {
1033
+ if (toEmails.length === 0) {
996
1034
  logger.error(
997
1035
  `[Invoice Email] No recipient email found for invoice ${invoiceId}`,
998
1036
  { projectId: projectId?.toString() } as LogAttributes,
@@ -1021,33 +1059,41 @@ export class BillingService extends BaseService {
1021
1059
  dashboardLink = `${DashboardClientUrl.toString()}/dashboard/${projectId.toString()}/settings/billing`;
1022
1060
  }
1023
1061
 
1024
- logger.debug(
1025
- `[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
1026
- );
1027
-
1028
- // Send email via OneUptime MailService
1029
- await MailService.sendMail(
1030
- {
1031
- toEmail: toEmail,
1032
- templateType: EmailTemplateType.Invoice,
1033
- vars: {
1034
- invoiceNumber: invoiceNumber,
1035
- invoiceDate: invoiceDate,
1036
- amount: amount,
1037
- description: description || "",
1038
- invoicePdfUrl: invoicePdfUrl || "",
1039
- dashboardLink: dashboardLink || "",
1040
- },
1041
- subject: `Invoice #${invoiceNumber} from OneUptime`,
1042
- },
1043
- {
1044
- projectId: projectId,
1045
- },
1046
- );
1062
+ for (const toEmail of toEmails) {
1063
+ logger.debug(
1064
+ `[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
1065
+ );
1066
+ try {
1067
+ await MailService.sendMail(
1068
+ {
1069
+ toEmail: toEmail,
1070
+ templateType: EmailTemplateType.Invoice,
1071
+ vars: {
1072
+ invoiceNumber: invoiceNumber,
1073
+ invoiceDate: invoiceDate,
1074
+ amount: amount,
1075
+ description: description || "",
1076
+ invoicePdfUrl: invoicePdfUrl || "",
1077
+ dashboardLink: dashboardLink || "",
1078
+ },
1079
+ subject: `Invoice #${invoiceNumber} from OneUptime`,
1080
+ },
1081
+ {
1082
+ projectId: projectId,
1083
+ },
1084
+ );
1047
1085
 
1048
- logger.debug(
1049
- `[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
1050
- );
1086
+ logger.debug(
1087
+ `[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
1088
+ );
1089
+ } catch (perRecipientErr) {
1090
+ // Keep going for the remaining recipients if one fails.
1091
+ logger.error(
1092
+ `[Invoice Email] Failed to send invoice ${invoiceId} to ${toEmail.toString()}: ${perRecipientErr}`,
1093
+ { projectId: projectId?.toString() } as LogAttributes,
1094
+ );
1095
+ }
1096
+ }
1051
1097
  } catch (err) {
1052
1098
  logger.error(
1053
1099
  `[Invoice Email] Failed to send invoice ${invoiceId} by email: ${err}`,
@@ -1108,16 +1154,21 @@ export class BillingService extends BaseService {
1108
1154
  amountInUsd: number,
1109
1155
  options?: {
1110
1156
  sendInvoiceByEmail?: boolean | undefined;
1111
- recipientEmail?: Email | undefined;
1157
+ recipientEmails?: Array<Email> | undefined;
1112
1158
  projectId?: ObjectID | undefined;
1113
1159
  },
1114
1160
  ): Promise<void> {
1115
1161
  const sendInvoiceByEmail: boolean = options?.sendInvoiceByEmail || false;
1116
- const recipientEmail: Email | undefined = options?.recipientEmail;
1162
+ const recipientEmails: Array<Email> | undefined = options?.recipientEmails;
1117
1163
  const projectId: ObjectID | undefined = options?.projectId;
1118
1164
 
1165
+ const recipientsForLog: string = (recipientEmails || [])
1166
+ .map((e: Email): string => {
1167
+ return e.toString();
1168
+ })
1169
+ .join(", ");
1119
1170
  logger.debug(
1120
- `[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipientEmail: ${recipientEmail?.toString()}, projectId: ${projectId?.toString()}`,
1171
+ `[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipients: ${recipientsForLog}, projectId: ${projectId?.toString()}`,
1121
1172
  );
1122
1173
 
1123
1174
  const invoice: Stripe.Invoice = await this.stripe.invoices.create({
@@ -1161,7 +1212,7 @@ export class BillingService extends BaseService {
1161
1212
  logger.debug(
1162
1213
  `[Invoice Email] sendInvoiceByEmail is true, sending invoice ${invoice.id} by email`,
1163
1214
  );
1164
- await this.sendInvoiceByEmail(invoice.id!, recipientEmail, projectId);
1215
+ await this.sendInvoiceByEmail(invoice.id!, recipientEmails, projectId);
1165
1216
  } else {
1166
1217
  logger.debug(
1167
1218
  `[Invoice Email] sendInvoiceByEmail is false, skipping email for invoice ${invoice.id}`,
@@ -1356,16 +1407,33 @@ export class BillingService extends BaseService {
1356
1407
  },
1357
1408
  });
1358
1409
 
1359
- let recipientEmail: Email | undefined = undefined;
1410
+ let recipientEmails: Array<Email> | undefined = undefined;
1360
1411
  let projectId: ObjectID | undefined = undefined;
1361
1412
 
1362
1413
  if (project) {
1363
1414
  projectId = project.id || undefined;
1364
1415
  if (project.financeAccountingEmail) {
1365
- recipientEmail = new Email(project.financeAccountingEmail);
1416
+ try {
1417
+ recipientEmails = Email.parseList(
1418
+ project.financeAccountingEmail,
1419
+ );
1420
+ } catch (parseErr) {
1421
+ /*
1422
+ * Log and fall back to Stripe customer email rather than dropping
1423
+ * the invoice delivery entirely due to a malformed address.
1424
+ */
1425
+ logger.error(
1426
+ `[Invoice Email] Failed to parse financeAccountingEmail for project ${projectId?.toString()}: ${parseErr}`,
1427
+ );
1428
+ }
1366
1429
  }
1430
+ const parsedForLog: string = (recipientEmails || [])
1431
+ .map((e: Email): string => {
1432
+ return e.toString();
1433
+ })
1434
+ .join(", ");
1367
1435
  logger.debug(
1368
- `[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail: ${recipientEmail?.toString()}`,
1436
+ `[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail recipients: ${parsedForLog}`,
1369
1437
  );
1370
1438
  } else {
1371
1439
  logger.debug(
@@ -1376,7 +1444,7 @@ export class BillingService extends BaseService {
1376
1444
  logger.debug(
1377
1445
  `[Invoice Email] Sending invoice ${invoice.id} by email`,
1378
1446
  );
1379
- await this.sendInvoiceByEmail(invoice.id, recipientEmail, projectId);
1447
+ await this.sendInvoiceByEmail(invoice.id, recipientEmails, projectId);
1380
1448
  logger.debug(
1381
1449
  `[Invoice Email] Successfully processed invoice.finalized - sent invoice ${invoice.id} by email`,
1382
1450
  );
@@ -90,8 +90,8 @@ export class NotificationService extends BaseService {
90
90
  amountInUSD,
91
91
  {
92
92
  sendInvoiceByEmail: project.sendInvoicesByEmail || false,
93
- recipientEmail: project.financeAccountingEmail
94
- ? new Email(project.financeAccountingEmail)
93
+ recipientEmails: project.financeAccountingEmail
94
+ ? Email.parseList(project.financeAccountingEmail)
95
95
  : undefined,
96
96
  projectId: project.id || undefined,
97
97
  },
@@ -212,8 +212,10 @@ export default class QueryUtil {
212
212
  relationColumnName: manyToManyMeta.relationColumnName,
213
213
  });
214
214
 
215
- // Remove the relation-based filter so TypeORM does not create a
216
- // JOIN that would yield OR semantics.
215
+ /*
216
+ * Remove the relation-based filter so TypeORM does not create a
217
+ * JOIN that would yield OR semantics.
218
+ */
217
219
  delete query[key];
218
220
 
219
221
  const existingIdFilter: any = (query as any)._id;
@@ -445,11 +447,13 @@ export default class QueryUtil {
445
447
  return null;
446
448
  }
447
449
 
448
- // Only the owning side of a many-to-many has join/inverse columns. Follow
449
- // the inverse relation when needed.
450
+ /*
451
+ * Only the owning side of a many-to-many has join/inverse columns. Follow
452
+ * the inverse relation when needed.
453
+ */
450
454
  const owningRelation: RelationMetadata = relation.isOwning
451
455
  ? relation
452
- : (relation.inverseRelation ?? relation);
456
+ : relation.inverseRelation ?? relation;
453
457
 
454
458
  const joinTableName: string | undefined =
455
459
  owningRelation.junctionEntityMetadata?.tableName;
@@ -458,8 +462,10 @@ export default class QueryUtil {
458
462
  return null;
459
463
  }
460
464
 
461
- // When `modelType` is the owning side, its id lives on joinColumns. When
462
- // it is the inverse side, its id lives on inverseJoinColumns.
465
+ /*
466
+ * When `modelType` is the owning side, its id lives on joinColumns. When
467
+ * it is the inverse side, its id lives on inverseJoinColumns.
468
+ */
463
469
  const ownerColumns: Array<any> = relation.isOwning
464
470
  ? owningRelation.joinColumns
465
471
  : owningRelation.inverseJoinColumns;
@@ -8,6 +8,7 @@ import MetricCriteriaContext, {
8
8
  MetricComponent,
9
9
  MetricComponentValue,
10
10
  } from "../../../../Types/Monitor/MetricMonitor/MetricCriteriaContext";
11
+ import MetricSeriesResult from "../../../../Types/Monitor/MetricMonitor/MetricSeriesResult";
11
12
  import MonitorStep from "../../../../Types/Monitor/MonitorStep";
12
13
  import { JSONObject } from "../../../../Types/JSON";
13
14
  import DataToProcess from "../DataToProcess";
@@ -23,6 +24,20 @@ import CaptureSpan from "../../Telemetry/CaptureSpan";
23
24
  import MetricUnitUtil from "../../../../Utils/MetricUnitUtil";
24
25
  import MetricFormulaEvaluator from "../../../../Utils/Metrics/MetricFormulaEvaluator";
25
26
 
27
+ /**
28
+ * Result of evaluating a single criteria filter against a single metric
29
+ * series. `rootCause` is null when the filter did not match; otherwise
30
+ * it's the human-readable comparison message. `context` always reflects
31
+ * the metric identity for this series (used to render the metric
32
+ * details + breaching samples section of the incident root cause).
33
+ */
34
+ export interface MetricSeriesEvaluationResult {
35
+ fingerprint: string | undefined;
36
+ labels: JSONObject;
37
+ rootCause: string | null;
38
+ context: MetricCriteriaContext;
39
+ }
40
+
26
41
  export default class MetricMonitorCriteria {
27
42
  @CaptureSpan()
28
43
  public static async isMonitorInstanceCriteriaFilterMet(input: {
@@ -30,8 +45,50 @@ export default class MetricMonitorCriteria {
30
45
  criteriaFilter: CriteriaFilter;
31
46
  monitorStep: MonitorStep;
32
47
  }): Promise<string | null> {
33
- // Metric Monitoring Check
48
+ const evaluations: Array<MetricSeriesEvaluationResult> =
49
+ MetricMonitorCriteria.evaluateAllSeries(input);
50
+
51
+ /*
52
+ * Backwards-compat: the scalar entrypoint collapses per-series
53
+ * evaluation down to the first matching series so existing callers
54
+ * (single-incident path) keep working. The per-series code path uses
55
+ * `evaluateAllSeries` directly.
56
+ */
57
+ const match: MetricSeriesEvaluationResult | undefined = evaluations.find(
58
+ (e: MetricSeriesEvaluationResult) => {
59
+ return e.rootCause !== null;
60
+ },
61
+ );
62
+
63
+ /*
64
+ * Always populate the legacy single-context field so the root-cause
65
+ * renderer can still read metric identity from the criteria filter
66
+ * even when nothing matched. Pick the first evaluation's context.
67
+ */
68
+ if (evaluations.length > 0) {
69
+ input.criteriaFilter.metricCriteriaContext = (
70
+ match || evaluations[0]!
71
+ ).context;
72
+ }
34
73
 
74
+ return match ? match.rootCause : null;
75
+ }
76
+
77
+ /**
78
+ * Evaluate a single criteria filter against every series produced by
79
+ * the monitor. For monitors without group-by, this returns a single
80
+ * evaluation covering all aggregated results (legacy behavior). For
81
+ * monitors with group-by attributes set, it returns one evaluation
82
+ * per unique series fingerprint — each with its own
83
+ * `MetricCriteriaContext` carrying that series' breaching samples
84
+ * and labels. The caller fans this out into one incident per
85
+ * breaching series.
86
+ */
87
+ public static evaluateAllSeries(input: {
88
+ dataToProcess: DataToProcess;
89
+ criteriaFilter: CriteriaFilter;
90
+ monitorStep: MonitorStep;
91
+ }): Array<MetricSeriesEvaluationResult> {
35
92
  if (
36
93
  input.criteriaFilter.metricMonitorOptions &&
37
94
  !input.criteriaFilter.metricMonitorOptions.metricAggregationType
@@ -41,20 +98,14 @@ export default class MetricMonitorCriteria {
41
98
  }
42
99
 
43
100
  if (input.criteriaFilter.checkOn !== CheckOn.MetricValue) {
44
- return null;
101
+ return [];
45
102
  }
46
103
 
47
- const rawThreshold: number | null = CompareCriteria.convertToNumber(
48
- input.criteriaFilter.value,
49
- );
50
-
51
- const metricAlias: string =
52
- input.criteriaFilter.metricMonitorOptions?.metricAlias || "";
53
-
54
104
  const metricResponse: MetricMonitorResponse =
55
105
  input.dataToProcess as MetricMonitorResponse;
56
- const metricAggregatedResult: Array<AggregatedResult> =
57
- metricResponse.metricResult || [];
106
+
107
+ const seriesBreakdown: Array<MetricSeriesResult> | undefined =
108
+ metricResponse.seriesBreakdown;
58
109
 
59
110
  const queryConfigs: Array<MetricQueryConfigData> =
60
111
  input.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs ||
@@ -63,6 +114,60 @@ export default class MetricMonitorCriteria {
63
114
  input.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs ||
64
115
  [];
65
116
 
117
+ /*
118
+ * Series-less path: one synthetic "all-series" evaluation over the
119
+ * flat metricResult. Preserves the pre-group-by behavior exactly.
120
+ */
121
+ if (!seriesBreakdown || seriesBreakdown.length === 0) {
122
+ const result: MetricSeriesEvaluationResult =
123
+ MetricMonitorCriteria.evaluateOneSeries({
124
+ criteriaFilter: input.criteriaFilter,
125
+ aggregatedResults: metricResponse.metricResult || [],
126
+ queryConfigs,
127
+ formulaConfigs,
128
+ seriesFingerprint: undefined,
129
+ seriesLabels: {},
130
+ });
131
+ return [result];
132
+ }
133
+
134
+ return seriesBreakdown.map((series: MetricSeriesResult) => {
135
+ return MetricMonitorCriteria.evaluateOneSeries({
136
+ criteriaFilter: input.criteriaFilter,
137
+ aggregatedResults: series.aggregatedResults,
138
+ queryConfigs,
139
+ formulaConfigs,
140
+ seriesFingerprint: series.fingerprint,
141
+ seriesLabels: series.labels,
142
+ });
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Core evaluation loop: compare the samples for one metric series
148
+ * against the criteria threshold. Builds the metric identity context,
149
+ * identifies breaching samples, and assembles the human-readable
150
+ * root-cause message. Factored out so `evaluateAllSeries` can invoke
151
+ * it once per series without duplicating logic.
152
+ */
153
+ private static evaluateOneSeries(input: {
154
+ criteriaFilter: CriteriaFilter;
155
+ aggregatedResults: Array<AggregatedResult>;
156
+ queryConfigs: Array<MetricQueryConfigData>;
157
+ formulaConfigs: Array<MetricFormulaConfigData>;
158
+ seriesFingerprint: string | undefined;
159
+ seriesLabels: JSONObject;
160
+ }): MetricSeriesEvaluationResult {
161
+ const rawThreshold: number | null = CompareCriteria.convertToNumber(
162
+ input.criteriaFilter.value,
163
+ );
164
+
165
+ const metricAlias: string =
166
+ input.criteriaFilter.metricMonitorOptions?.metricAlias || "";
167
+
168
+ const metricAggregatedResult: Array<AggregatedResult> =
169
+ input.aggregatedResults;
170
+
66
171
  /*
67
172
  * Resolve which query/formula the alias refers to. Use explicit index
68
173
  * checks (not `findIndex() || -1`, which incorrectly falls back to -1
@@ -73,25 +178,25 @@ export default class MetricMonitorCriteria {
73
178
  let aliasIndex: number = -1;
74
179
 
75
180
  if (metricAlias) {
76
- const qIdx: number = queryConfigs.findIndex(
181
+ const qIdx: number = input.queryConfigs.findIndex(
77
182
  (q: MetricQueryConfigData) => {
78
183
  return q.metricAliasData?.metricVariable === metricAlias;
79
184
  },
80
185
  );
81
186
 
82
187
  if (qIdx >= 0) {
83
- matchedQuery = queryConfigs[qIdx] || null;
188
+ matchedQuery = input.queryConfigs[qIdx] || null;
84
189
  aliasIndex = qIdx;
85
190
  } else {
86
- const fIdx: number = formulaConfigs.findIndex(
191
+ const fIdx: number = input.formulaConfigs.findIndex(
87
192
  (f: MetricFormulaConfigData) => {
88
193
  return f.metricAliasData?.metricVariable === metricAlias;
89
194
  },
90
195
  );
91
196
 
92
197
  if (fIdx >= 0) {
93
- matchedFormula = formulaConfigs[fIdx] || null;
94
- aliasIndex = queryConfigs.length + fIdx;
198
+ matchedFormula = input.formulaConfigs[fIdx] || null;
199
+ aliasIndex = input.queryConfigs.length + fIdx;
95
200
  }
96
201
  }
97
202
  }
@@ -105,8 +210,8 @@ export default class MetricMonitorCriteria {
105
210
  ? metricAggregatedResult[aliasIndex]
106
211
  : metricAggregatedResult[0];
107
212
 
108
- if (!matchedQuery && !matchedFormula && queryConfigs[0]) {
109
- matchedQuery = queryConfigs[0];
213
+ if (!matchedQuery && !matchedFormula && input.queryConfigs[0]) {
214
+ matchedQuery = input.queryConfigs[0];
110
215
  }
111
216
 
112
217
  /*
@@ -119,14 +224,24 @@ export default class MetricMonitorCriteria {
119
224
  matchedFormula,
120
225
  metricAlias,
121
226
  criteriaFilter: input.criteriaFilter,
122
- queryConfigs,
123
- formulaConfigs,
227
+ queryConfigs: input.queryConfigs,
228
+ formulaConfigs: input.formulaConfigs,
124
229
  });
125
230
 
126
- input.criteriaFilter.metricCriteriaContext = metricContext;
231
+ if (input.seriesFingerprint) {
232
+ metricContext.seriesFingerprint = input.seriesFingerprint;
233
+ }
234
+ if (input.seriesLabels && Object.keys(input.seriesLabels).length > 0) {
235
+ metricContext.seriesLabels = input.seriesLabels;
236
+ }
127
237
 
128
238
  if (rawThreshold === null) {
129
- return null;
239
+ return {
240
+ fingerprint: input.seriesFingerprint,
241
+ labels: input.seriesLabels,
242
+ rootCause: null,
243
+ context: metricContext,
244
+ };
130
245
  }
131
246
 
132
247
  /*
@@ -180,11 +295,21 @@ export default class MetricMonitorCriteria {
180
295
  NoDataPolicy.Ignore;
181
296
 
182
297
  if (policy === NoDataPolicy.Ignore) {
183
- return null;
298
+ return {
299
+ fingerprint: input.seriesFingerprint,
300
+ labels: input.seriesLabels,
301
+ rootCause: null,
302
+ context: metricContext,
303
+ };
184
304
  }
185
305
 
186
306
  if (policy === NoDataPolicy.Trigger) {
187
- return `No data received for ${metricContext.metricName} in the evaluation window — triggering per no-data policy.`;
307
+ return {
308
+ fingerprint: input.seriesFingerprint,
309
+ labels: input.seriesLabels,
310
+ rootCause: `No data received for ${metricContext.metricName} in the evaluation window — triggering per no-data policy.`,
311
+ context: metricContext,
312
+ };
188
313
  }
189
314
 
190
315
  // TreatAsZero: fall through to the comparator with value 0.
@@ -206,7 +331,12 @@ export default class MetricMonitorCriteria {
206
331
  });
207
332
 
208
333
  if (!comparisonMessage) {
209
- return null;
334
+ return {
335
+ fingerprint: input.seriesFingerprint,
336
+ labels: input.seriesLabels,
337
+ rootCause: null,
338
+ context: metricContext,
339
+ };
210
340
  }
211
341
 
212
342
  /*
@@ -232,8 +362,8 @@ export default class MetricMonitorCriteria {
232
362
  matchedFormula
233
363
  ? MetricMonitorCriteria.buildComponentValueLookup({
234
364
  components: metricContext.components || [],
235
- queryConfigs,
236
- formulaConfigs,
365
+ queryConfigs: input.queryConfigs,
366
+ formulaConfigs: input.formulaConfigs,
237
367
  metricAggregatedResult,
238
368
  })
239
369
  : null;
@@ -276,7 +406,12 @@ export default class MetricMonitorCriteria {
276
406
  metricContext.breachingSamples = breachingSamples;
277
407
  }
278
408
 
279
- return comparisonMessage;
409
+ return {
410
+ fingerprint: input.seriesFingerprint,
411
+ labels: input.seriesLabels,
412
+ rootCause: comparisonMessage,
413
+ context: metricContext,
414
+ };
280
415
  }
281
416
 
282
417
  private static buildComponentValueLookup(input: {
@@ -438,6 +573,17 @@ export default class MetricMonitorCriteria {
438
573
  ? Object.keys(q.metricQueryData.groupBy as Record<string, unknown>)
439
574
  : [];
440
575
 
576
+ /*
577
+ * Include user-selected attribute keys as part of the groupBy view
578
+ * so the root-cause block shows "Grouped By: host.name" not just the
579
+ * raw columns ClickHouse was asked to partition on.
580
+ */
581
+ const groupByAttributeKeys: Array<string> =
582
+ q?.metricQueryData?.groupByAttributeKeys || [];
583
+ const allGroupBy: Array<string> = Array.from(
584
+ new Set([...groupBy, ...groupByAttributeKeys]),
585
+ );
586
+
441
587
  const components: Array<MetricComponent> | undefined = f
442
588
  ? MetricMonitorCriteria.buildFormulaComponents({
443
589
  formulaConfig: f,
@@ -454,7 +600,7 @@ export default class MetricMonitorCriteria {
454
600
  isFormula: Boolean(f),
455
601
  formulaExpression: f?.metricFormulaData?.metricFormula,
456
602
  filterAttributes,
457
- groupBy,
603
+ groupBy: allGroupBy,
458
604
  timeWindowMinutes:
459
605
  input.criteriaFilter.evaluateOverTimeOptions?.timeValueInMinutes,
460
606
  ...(components && components.length > 0 ? { components } : {}),