@oneuptime/common 11.3.5 → 11.3.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.
Files changed (65) hide show
  1. package/Server/Infrastructure/Queue.ts +103 -16
  2. package/Server/Services/AlertService.ts +302 -202
  3. package/Server/Services/AnalyticsDatabaseService.ts +9 -5
  4. package/Server/Services/IncidentService.ts +410 -306
  5. package/Server/Services/TeamMemberService.ts +38 -1
  6. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
  7. package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
  8. package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
  9. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
  10. package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
  11. package/Server/Utils/Monitor/MonitorResource.ts +74 -0
  12. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
  13. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
  14. package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
  15. package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
  16. package/Tests/Types/Events/Recurring.test.ts +475 -0
  17. package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
  18. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
  19. package/Tests/UI/Components/Alert.test.tsx +23 -15
  20. package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
  21. package/Tests/UI/Components/Button.test.tsx +3 -3
  22. package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
  23. package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
  24. package/Tests/UI/Components/Input.test.tsx +3 -2
  25. package/Tests/UI/Components/Pill.test.tsx +4 -2
  26. package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
  27. package/Tests/UI/Components/TextArea.test.tsx +6 -4
  28. package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
  29. package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
  30. package/UI/Components/Dictionary/Dictionary.tsx +123 -50
  31. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
  32. package/UI/Components/MasterPage/MasterPage.tsx +5 -3
  33. package/build/dist/Server/Infrastructure/Queue.js +83 -15
  34. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  35. package/build/dist/Server/Services/AlertService.js +255 -161
  36. package/build/dist/Server/Services/AlertService.js.map +1 -1
  37. package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
  38. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  39. package/build/dist/Server/Services/IncidentService.js +322 -236
  40. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  41. package/build/dist/Server/Services/TeamMemberService.js +37 -1
  42. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  43. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
  44. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  45. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
  46. package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
  47. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
  48. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  49. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
  50. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  51. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
  52. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  53. package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
  54. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  55. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
  56. package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
  57. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
  58. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  59. package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
  60. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  61. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
  62. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
  63. package/build/dist/UI/Components/MasterPage/MasterPage.js +5 -3
  64. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  65. package/package.json +1 -1
@@ -28,6 +28,7 @@ import SubscriptionPlan, {
28
28
  import LIMIT_MAX from "../../Types/Database/LimitMax";
29
29
  import Email from "../../Types/Email";
30
30
  import EmailTemplateType from "../../Types/Email/EmailTemplateType";
31
+ import Name from "../../Types/Name";
31
32
  import BadDataException from "../../Types/Exception/BadDataException";
32
33
  import ObjectID from "../../Types/ObjectID";
33
34
  import PositiveNumber from "../../Types/PositiveNumber";
@@ -125,6 +126,15 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
125
126
  if (createBy.miscDataProps && createBy.miscDataProps["email"]) {
126
127
  const email: Email = new Email(createBy.miscDataProps["email"] as string);
127
128
 
129
+ /*
130
+ * Optional name supplied on the invite form. Used only to set the name on
131
+ * a brand-new user, or to backfill an existing user who has no name yet —
132
+ * we never overwrite a name the user has already set.
133
+ */
134
+ const nameValue: string | undefined = createBy.miscDataProps["name"]
135
+ ? (createBy.miscDataProps["name"] as string).trim()
136
+ : undefined;
137
+
128
138
  let user: User | null = await UserService.findByEmail(email, {
129
139
  isRoot: true,
130
140
  });
@@ -133,13 +143,40 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
133
143
 
134
144
  if (!user) {
135
145
  isNewUser = true;
146
+
136
147
  user = await UserService.createByEmail({
137
148
  email,
138
- name: undefined, // name is not required for now.
149
+ name: nameValue ? new Name(nameValue) : undefined,
139
150
  props: {
140
151
  isRoot: true,
141
152
  },
142
153
  });
154
+ } else if (nameValue) {
155
+ /*
156
+ * User already exists. Backfill their name only if they don't have one
157
+ * yet; if they already have a name, leave it untouched.
158
+ */
159
+ const existingUser: User | null = await UserService.findOneById({
160
+ id: user.id!,
161
+ select: {
162
+ name: true,
163
+ },
164
+ props: {
165
+ isRoot: true,
166
+ },
167
+ });
168
+
169
+ if (existingUser && !existingUser.name?.toString()) {
170
+ await UserService.updateOneById({
171
+ id: user.id!,
172
+ data: {
173
+ name: new Name(nameValue),
174
+ },
175
+ props: {
176
+ isRoot: true,
177
+ },
178
+ });
179
+ }
143
180
  }
144
181
 
145
182
  createBy.data.userId = user.id!;
@@ -29,6 +29,7 @@ import GreaterThan from "../../../Types/BaseDatabase/GreaterThan";
29
29
  import GreaterThanOrEqual from "../../../Types/BaseDatabase/GreaterThanOrEqual";
30
30
  import InBetween from "../../../Types/BaseDatabase/InBetween";
31
31
  import Includes from "../../../Types/BaseDatabase/Includes";
32
+ import IncludesNone from "../../../Types/BaseDatabase/IncludesNone";
32
33
  import ObjectID from "../../../Types/ObjectID";
33
34
  import IsNull from "../../../Types/BaseDatabase/IsNull";
34
35
  import LessThan from "../../../Types/BaseDatabase/LessThan";
@@ -887,6 +888,36 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
887
888
  continue;
888
889
  }
889
890
 
891
+ /*
892
+ * Multi-value exclusion (IncludesNone / "is none of"):
893
+ * `attributes['k'] NOT IN (...)`. Map subscript returns '' for a
894
+ * missing key, so (like NotEqual above) rows lacking the attribute
895
+ * pass the NOT IN test, which matches "the value is none of these".
896
+ * An empty IncludesNone is treated as "All" — skip the predicate
897
+ * rather than emit `NOT IN ()`. Values bind via a fresh `Includes`
898
+ * since Statement only types `Includes` as Array(String).
899
+ */
900
+ if (mapEntry instanceof IncludesNone) {
901
+ const excludeValues: Array<string> = (
902
+ (mapEntry as IncludesNone).values || []
903
+ ).map((v: string | ObjectID | number) => {
904
+ return String(v);
905
+ });
906
+ if (excludeValues.length === 0) {
907
+ continue;
908
+ }
909
+ whereStatement.append(
910
+ SQL`AND ${key}[${{
911
+ value: mapKey,
912
+ type: TableColumnType.Text,
913
+ }}] NOT IN ${{
914
+ value: new Includes(excludeValues),
915
+ type: TableColumnType.Text,
916
+ }}`,
917
+ );
918
+ continue;
919
+ }
920
+
890
921
  // Bare string/number/boolean — direct Map subscript.
891
922
  whereStatement.append(
892
923
  SQL`AND ${key}[${{
@@ -0,0 +1,428 @@
1
+ import logger from "../Logger";
2
+ import VMUtil from "../VM/VMAPI";
3
+ import MetricSeriesFingerprint from "../../../Utils/Metrics/MetricSeriesFingerprint";
4
+ import DataToProcess from "./DataToProcess";
5
+ import IncomingMonitorRequest from "../../../Types/Monitor/IncomingMonitor/IncomingMonitorRequest";
6
+ import IncidentGroupingConfig from "../../../Types/Monitor/IncomingMonitor/IncidentGroupingConfig";
7
+ import MonitorCriteriaInstance from "../../../Types/Monitor/MonitorCriteriaInstance";
8
+ import { PerSeriesCriteriaMatch } from "../../../Types/Probe/ProbeApiIngestResponse";
9
+ import { JSONObject, JSONValue } from "../../../Types/JSON";
10
+ import Typeof from "../../../Types/Typeof";
11
+
12
+ /**
13
+ * One key extracted from an incoming webhook payload by an
14
+ * {@link IncidentGroupingConfig}.
15
+ */
16
+ export interface IncomingRequestGroupingItem {
17
+ /** Stable dedupe key derived from the extracted value. */
18
+ fingerprint: string;
19
+ /** Label map exposed to incident title/description templates. */
20
+ labels: JSONObject;
21
+ /** The raw extracted value (e.g. the Grafana alert name). */
22
+ keyValue: string;
23
+ /** True when the payload classifies this key as resolved/recovered. */
24
+ isResolved: boolean;
25
+ }
26
+
27
+ const DEFAULT_MAX_KEYS_PER_PAYLOAD: number = 100;
28
+ const ARRAY_WILDCARD: string = "[*]";
29
+
30
+ /**
31
+ * Turns an incoming-request / webhook payload into per-key incident
32
+ * matches, so a single monitor can hold multiple concurrent incidents —
33
+ * one per distinct value (Grafana alert name, fingerprint, etc.). This
34
+ * reuses the same `seriesFingerprint` mechanism metric monitors use for
35
+ * per-series incidents; see {@link IncidentGroupingConfig}.
36
+ */
37
+ export default class IncomingRequestIncidentGrouping {
38
+ /**
39
+ * Parse the request body into a JSON object. Bodies arrive either
40
+ * pre-parsed (JSONObject) or as a raw JSON string depending on the
41
+ * ingestion path; both are handled. Non-JSON / non-object bodies
42
+ * (e.g. a bare string heartbeat) return null — grouping is a no-op.
43
+ */
44
+ public static getRequestBodyObject(
45
+ dataToProcess: DataToProcess,
46
+ ): JSONObject | null {
47
+ const body: string | JSONObject | undefined = (
48
+ dataToProcess as IncomingMonitorRequest
49
+ ).requestBody;
50
+
51
+ if (!body) {
52
+ return null;
53
+ }
54
+
55
+ if (typeof body === Typeof.String) {
56
+ try {
57
+ const parsed: unknown = JSON.parse(body as string);
58
+ return parsed &&
59
+ typeof parsed === Typeof.Object &&
60
+ !Array.isArray(parsed)
61
+ ? (parsed as JSONObject)
62
+ : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ if (typeof body === Typeof.Object && !Array.isArray(body)) {
69
+ return body as JSONObject;
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Firing matches for one criteria — used by the criteria evaluator to
77
+ * fan a matched incoming-request criteria into one incident per key.
78
+ * Returns an empty array when grouping is not configured (preserving
79
+ * the legacy single-incident behaviour) or when there is no usable
80
+ * payload (e.g. the heartbeat-timeout re-evaluation cron, which carries
81
+ * no fresh body).
82
+ */
83
+ public static collectFiringMatches(input: {
84
+ dataToProcess: DataToProcess;
85
+ criteriaInstance: MonitorCriteriaInstance;
86
+ rootCause?: string | undefined;
87
+ }): Array<PerSeriesCriteriaMatch> {
88
+ const grouping: IncidentGroupingConfig | undefined =
89
+ input.criteriaInstance.data?.incidentGrouping;
90
+
91
+ if (!grouping?.groupByJSONPath) {
92
+ return [];
93
+ }
94
+
95
+ if (
96
+ (input.dataToProcess as IncomingMonitorRequest)
97
+ .onlyCheckForIncomingRequestReceivedAt
98
+ ) {
99
+ return [];
100
+ }
101
+
102
+ const criteriaId: string | undefined = input.criteriaInstance.data?.id;
103
+ if (!criteriaId) {
104
+ return [];
105
+ }
106
+
107
+ const body: JSONObject | null = this.getRequestBodyObject(
108
+ input.dataToProcess,
109
+ );
110
+ if (!body) {
111
+ return [];
112
+ }
113
+
114
+ const matches: Array<PerSeriesCriteriaMatch> = [];
115
+
116
+ for (const item of this.extractItems({ requestBody: body, grouping })) {
117
+ if (item.isResolved) {
118
+ // A resolved event never opens an incident.
119
+ continue;
120
+ }
121
+
122
+ matches.push({
123
+ criteriaMetId: criteriaId,
124
+ fingerprint: item.fingerprint,
125
+ labels: item.labels,
126
+ rootCause:
127
+ input.rootCause ||
128
+ `Incoming request matched grouping key \`${item.keyValue}\`.`,
129
+ metricContext: undefined,
130
+ });
131
+ }
132
+
133
+ return matches;
134
+ }
135
+
136
+ /**
137
+ * Fingerprints the payload explicitly classifies as resolved, across
138
+ * every grouping-enabled criteria on the monitor's step. The caller
139
+ * resolves the matching open incidents (event-driven resolution: only
140
+ * keys the payload says recovered are closed — never by absence).
141
+ */
142
+ public static collectResolvedFingerprints(input: {
143
+ dataToProcess: DataToProcess;
144
+ criteriaInstances: Array<MonitorCriteriaInstance>;
145
+ }): Array<string> {
146
+ if (
147
+ (input.dataToProcess as IncomingMonitorRequest)
148
+ .onlyCheckForIncomingRequestReceivedAt
149
+ ) {
150
+ return [];
151
+ }
152
+
153
+ const body: JSONObject | null = this.getRequestBodyObject(
154
+ input.dataToProcess,
155
+ );
156
+ if (!body) {
157
+ return [];
158
+ }
159
+
160
+ const fingerprints: Set<string> = new Set<string>();
161
+
162
+ for (const criteriaInstance of input.criteriaInstances) {
163
+ const grouping: IncidentGroupingConfig | undefined =
164
+ criteriaInstance.data?.incidentGrouping;
165
+
166
+ if (!grouping?.groupByJSONPath) {
167
+ continue;
168
+ }
169
+
170
+ for (const item of this.extractItems({ requestBody: body, grouping })) {
171
+ if (item.isResolved) {
172
+ fingerprints.add(item.fingerprint);
173
+ }
174
+ }
175
+ }
176
+
177
+ return Array.from(fingerprints);
178
+ }
179
+
180
+ /**
181
+ * Whether any criteria on the step has grouping configured. Used to
182
+ * force per-series mode so a grouped criteria never falls back to a
183
+ * single whole-monitor incident on a payload that yields no firing key.
184
+ */
185
+ public static isGroupingConfigured(
186
+ criteriaInstance: MonitorCriteriaInstance | undefined,
187
+ ): boolean {
188
+ return Boolean(criteriaInstance?.data?.incidentGrouping?.groupByJSONPath);
189
+ }
190
+
191
+ /**
192
+ * Extract all distinct keys from a payload for one grouping config,
193
+ * classifying each as firing or resolved. Deduplicates by fingerprint
194
+ * and caps the count at `maxKeysPerPayload` to bound cardinality.
195
+ */
196
+ public static extractItems(input: {
197
+ requestBody: JSONObject;
198
+ grouping: IncidentGroupingConfig;
199
+ }): Array<IncomingRequestGroupingItem> {
200
+ const { requestBody, grouping } = input;
201
+
202
+ const groupByPath: string = this.normalizePath(grouping.groupByJSONPath);
203
+ if (!groupByPath) {
204
+ return [];
205
+ }
206
+
207
+ const labelKey: string = this.labelKeyFromPath(groupByPath);
208
+ const maxKeys: number =
209
+ grouping.maxKeysPerPayload && grouping.maxKeysPerPayload > 0
210
+ ? grouping.maxKeysPerPayload
211
+ : DEFAULT_MAX_KEYS_PER_PAYLOAD;
212
+
213
+ const items: Array<IncomingRequestGroupingItem> = [];
214
+ const seenFingerprints: Set<string> = new Set<string>();
215
+
216
+ const addItem: (keyValue: string, isResolved: boolean) => void = (
217
+ keyValue: string,
218
+ isResolved: boolean,
219
+ ): void => {
220
+ const labels: JSONObject = { [labelKey]: keyValue };
221
+ const fingerprint: string =
222
+ MetricSeriesFingerprint.computeFingerprint(labels);
223
+
224
+ if (seenFingerprints.has(fingerprint)) {
225
+ // Same key twice in one payload — keep the first occurrence.
226
+ return;
227
+ }
228
+
229
+ seenFingerprints.add(fingerprint);
230
+ items.push({ fingerprint, labels, keyValue, isResolved });
231
+ };
232
+
233
+ const wildcardIndex: number = groupByPath.indexOf(ARRAY_WILDCARD);
234
+
235
+ // Simple (non-array) path: a single key for the whole payload.
236
+ if (wildcardIndex === -1) {
237
+ const keyValue: string | null = this.toScalarString(
238
+ VMUtil.deepFind(requestBody, groupByPath),
239
+ );
240
+
241
+ if (keyValue === null) {
242
+ return [];
243
+ }
244
+
245
+ addItem(
246
+ keyValue,
247
+ this.isResolvedForScope(requestBody, requestBody, grouping, false),
248
+ );
249
+ return items;
250
+ }
251
+
252
+ // Array fan-out: one key per element of the array at the `[*]` prefix.
253
+ const prefixPath: string = groupByPath
254
+ .slice(0, wildcardIndex)
255
+ .replace(/\.$/, "");
256
+ const elementSuffix: string = this.stripLeadingDot(
257
+ groupByPath.slice(wildcardIndex + ARRAY_WILDCARD.length),
258
+ );
259
+
260
+ const arrayValue: JSONValue | undefined = prefixPath
261
+ ? VMUtil.deepFind(requestBody, prefixPath)
262
+ : (requestBody as unknown as JSONValue);
263
+
264
+ if (!Array.isArray(arrayValue)) {
265
+ return [];
266
+ }
267
+
268
+ for (const element of arrayValue) {
269
+ if (items.length >= maxKeys) {
270
+ logger.warn(
271
+ `IncomingRequestIncidentGrouping: payload produced more than ${maxKeys} keys for path "${groupByPath}"; remaining keys ignored.`,
272
+ );
273
+ break;
274
+ }
275
+
276
+ const isObjectElement: boolean =
277
+ Boolean(element) && typeof element === Typeof.Object;
278
+
279
+ let keyValue: string | null;
280
+
281
+ if (elementSuffix) {
282
+ // `prefix[*].sub.path` — element must be an object to dig into.
283
+ if (!isObjectElement) {
284
+ continue;
285
+ }
286
+ keyValue = this.toScalarString(
287
+ VMUtil.deepFind(element as JSONObject, elementSuffix),
288
+ );
289
+ } else {
290
+ // `prefix[*]` — the array element itself is the key (array of scalars).
291
+ keyValue = this.toScalarString(element as JSONValue);
292
+ }
293
+
294
+ if (keyValue === null) {
295
+ continue;
296
+ }
297
+
298
+ /*
299
+ * Per-element resolve classification only applies when the element
300
+ * is an object (so `[*].status` can be read off it). For scalar
301
+ * elements there is no per-element field, so fall back to root scope.
302
+ */
303
+ addItem(
304
+ keyValue,
305
+ this.isResolvedForScope(
306
+ isObjectElement ? (element as JSONObject) : requestBody,
307
+ requestBody,
308
+ grouping,
309
+ isObjectElement,
310
+ ),
311
+ );
312
+ }
313
+
314
+ return items;
315
+ }
316
+
317
+ /**
318
+ * Classify whether the configured resolve condition holds. When the
319
+ * resolve path uses `[*]` AND we are evaluating a specific array element
320
+ * (`isElementContext`), it is read off that element (`elementScope`).
321
+ * Otherwise it is evaluated against the whole payload (`rootScope`).
322
+ *
323
+ * If the resolve path uses `[*]` but the group-by path does NOT (so we
324
+ * are not in element context — a self-contradictory config), the path is
325
+ * evaluated verbatim against the root: `deepFind` does not interpret
326
+ * `[*]`, so it returns undefined (not resolved) rather than silently
327
+ * rebinding to a same-named top-level field.
328
+ */
329
+ private static isResolvedForScope(
330
+ elementScope: JSONObject,
331
+ rootScope: JSONObject,
332
+ grouping: IncidentGroupingConfig,
333
+ isElementContext: boolean,
334
+ ): boolean {
335
+ if (
336
+ !grouping.resolvedWhenJSONPath ||
337
+ grouping.resolvedWhenValue === undefined
338
+ ) {
339
+ return false;
340
+ }
341
+
342
+ const resolvedPath: string = this.normalizePath(
343
+ grouping.resolvedWhenJSONPath,
344
+ );
345
+ const wildcardIndex: number = resolvedPath.indexOf(ARRAY_WILDCARD);
346
+
347
+ let scope: JSONObject = rootScope;
348
+ let path: string = resolvedPath;
349
+
350
+ if (wildcardIndex !== -1 && isElementContext) {
351
+ scope = elementScope;
352
+ path = this.stripLeadingDot(
353
+ resolvedPath.slice(wildcardIndex + ARRAY_WILDCARD.length),
354
+ );
355
+ }
356
+
357
+ if (!path) {
358
+ return false;
359
+ }
360
+
361
+ const value: string | null = this.toScalarString(
362
+ VMUtil.deepFind(scope, path),
363
+ );
364
+
365
+ return value !== null && value === grouping.resolvedWhenValue;
366
+ }
367
+
368
+ /**
369
+ * Derive a human-friendly label key from a JSON path — the last named
370
+ * segment, with array/wildcard syntax stripped. `alerts[*].labels.alertname`
371
+ * → `alertname`. Used as the template variable name (so titles can say
372
+ * `{{alertname}}`) and as the fingerprint label key.
373
+ */
374
+ private static labelKeyFromPath(path: string): string {
375
+ const cleaned: string = path
376
+ .replace(/\[\*\]/g, "")
377
+ .replace(/\[[^\]]*\]/g, "");
378
+ const parts: Array<string> = cleaned.split(".").filter(Boolean);
379
+ return parts.length > 0 ? parts[parts.length - 1]! : "key";
380
+ }
381
+
382
+ private static stripLeadingDot(path: string): string {
383
+ return path.startsWith(".") ? path.slice(1) : path;
384
+ }
385
+
386
+ /**
387
+ * Resolve the `requestBody`-rooted path users write — the same convention
388
+ * incident templates and JavaScript-expression filters use — into a path
389
+ * relative to the body (the actual scope `deepFind` resolves against).
390
+ * Both of these forms are accepted and resolve identically:
391
+ *
392
+ * `{{requestBody.alerts[*].labels.alertname}}` (template wrapper)
393
+ * `requestBody.alerts[*].labels.alertname`
394
+ *
395
+ * The `requestBody` root is REQUIRED — a path not rooted there resolves
396
+ * to nothing, so the field has exactly one convention.
397
+ */
398
+ private static normalizePath(rawPath: string): string {
399
+ let path: string = rawPath.trim();
400
+
401
+ if (path.startsWith("{{") && path.endsWith("}}")) {
402
+ path = path.slice(2, -2).trim();
403
+ }
404
+
405
+ if (path.startsWith("requestBody.")) {
406
+ return path.slice("requestBody.".length);
407
+ }
408
+
409
+ return "";
410
+ }
411
+
412
+ /**
413
+ * Coerce an extracted value to a non-empty scalar string. Objects and
414
+ * arrays cannot be a grouping key and return null (the item is skipped).
415
+ */
416
+ private static toScalarString(value: JSONValue | undefined): string | null {
417
+ if (value === undefined || value === null) {
418
+ return null;
419
+ }
420
+
421
+ if (typeof value === Typeof.Object) {
422
+ return null;
423
+ }
424
+
425
+ const stringValue: string = String(value);
426
+ return stringValue.length > 0 ? stringValue : null;
427
+ }
428
+ }