@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.
- package/Server/Infrastructure/Queue.ts +103 -16
- package/Server/Services/AlertService.ts +302 -202
- package/Server/Services/AnalyticsDatabaseService.ts +9 -5
- package/Server/Services/IncidentService.ts +410 -306
- package/Server/Services/TeamMemberService.ts +38 -1
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +31 -0
- package/Server/Utils/Monitor/IncomingRequestIncidentGrouping.ts +428 -0
- package/Server/Utils/Monitor/MonitorAlert.ts +158 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +26 -0
- package/Server/Utils/Monitor/MonitorIncident.ts +147 -2
- package/Server/Utils/Monitor/MonitorResource.ts +74 -0
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +2 -1
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +32 -0
- package/Tests/Server/Utils/Monitor/IncomingRequestIncidentGrouping.test.ts +389 -0
- package/Tests/Server/Utils/Monitor/SeriesAbsenceResolutionGuard.test.ts +177 -0
- package/Tests/Types/Events/Recurring.test.ts +475 -0
- package/Tests/Types/Monitor/CriteriaFilter.test.ts +348 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +331 -0
- package/Tests/UI/Components/Alert.test.tsx +23 -15
- package/Tests/UI/Components/Breadcrumbs.test.tsx +18 -6
- package/Tests/UI/Components/Button.test.tsx +3 -3
- package/Tests/UI/Components/DictionaryFilterOperator.test.ts +174 -0
- package/Tests/UI/Components/EmptyState/EmptyState.test.tsx +4 -3
- package/Tests/UI/Components/Input.test.tsx +3 -2
- package/Tests/UI/Components/Pill.test.tsx +4 -2
- package/Tests/UI/Components/SideMenuItem.test.tsx +18 -8
- package/Tests/UI/Components/TextArea.test.tsx +6 -4
- package/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.ts +74 -0
- package/Types/Monitor/MonitorCriteriaInstance.ts +22 -0
- package/UI/Components/Dictionary/Dictionary.tsx +123 -50
- package/UI/Components/Dictionary/DictionaryFilterOperator.ts +96 -1
- package/UI/Components/MasterPage/MasterPage.tsx +5 -3
- package/build/dist/Server/Infrastructure/Queue.js +83 -15
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +255 -161
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +9 -5
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +322 -236
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +37 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js +288 -0
- package/build/dist/Server/Utils/Monitor/IncomingRequestIncidentGrouping.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +119 -8
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +24 -0
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +103 -2
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +62 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js +8 -0
- package/build/dist/Types/Monitor/IncomingMonitor/IncidentGroupingConfig.js.map +1 -0
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +10 -0
- package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +60 -9
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +69 -0
- package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +5 -3
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- 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:
|
|
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
|
+
}
|