@oneuptime/common 12.0.19 → 12.0.21
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/Models/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +15 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +19 -12
- package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
- package/UI/Components/Charts/Line/LineChart.tsx +23 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +14 -5
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import GlobalConfig from "../../Models/DatabaseModels/GlobalConfig";
|
|
2
|
+
import PartialEntity from "../../Types/Database/PartialEntity";
|
|
3
|
+
import EnterpriseLicenseInstanceSummary from "../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
|
|
4
|
+
import { JSONObject } from "../../Types/JSON";
|
|
5
|
+
|
|
6
|
+
export interface EnterpriseLicenseSyncResult {
|
|
7
|
+
/*
|
|
8
|
+
* Only the columns the license server actually spoke about. A column the
|
|
9
|
+
* response did not mention is absent from this object, so updateOneById
|
|
10
|
+
* leaves whatever is stored alone.
|
|
11
|
+
*/
|
|
12
|
+
updateData: PartialEntity<GlobalConfig>;
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Fields the server sent in a shape this build cannot use. Returned rather
|
|
16
|
+
* than logged so this stays a pure function the tests can drive directly;
|
|
17
|
+
* the caller decides how loud to be about them.
|
|
18
|
+
*/
|
|
19
|
+
warnings: Array<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Maps a license-server response (from /enterprise-license/validate or
|
|
24
|
+
* /enterprise-license/report-user-count) onto the GlobalConfig columns a
|
|
25
|
+
* self-hosted installation mirrors locally.
|
|
26
|
+
*
|
|
27
|
+
* Every field is three-state, and the distinction is the whole contract:
|
|
28
|
+
*
|
|
29
|
+
* key absent - the license server did not speak about this field. That is
|
|
30
|
+
* what an oneuptime.com older than this build looks like, so
|
|
31
|
+
* the stored value is left exactly as it is. Collapsing this
|
|
32
|
+
* to null is how a daily report would wipe a perfectly good
|
|
33
|
+
* license expiry and take the installation down.
|
|
34
|
+
* null - the server explicitly says "there is no value". Written as
|
|
35
|
+
* null, so clearing a seat limit on oneuptime.com actually
|
|
36
|
+
* clears it on the customer's installation.
|
|
37
|
+
* a value - validated, then written.
|
|
38
|
+
*
|
|
39
|
+
* Anything that is neither absent, null, nor a usable value is treated as
|
|
40
|
+
* absent and reported as a warning: a malformed response must never be able
|
|
41
|
+
* to corrupt license state that was correct a moment ago.
|
|
42
|
+
*/
|
|
43
|
+
export default class EnterpriseLicenseSyncUtil {
|
|
44
|
+
/*
|
|
45
|
+
* The license key is deliberately NOT synced. The installation authenticates
|
|
46
|
+
* with the key it already holds, and a response must never be able to swap
|
|
47
|
+
* it for another one.
|
|
48
|
+
*/
|
|
49
|
+
public static getGlobalConfigUpdateFromLicenseResponse(data: {
|
|
50
|
+
payload: JSONObject;
|
|
51
|
+
/*
|
|
52
|
+
* Used as the "user count as of" stamp when the server reports a count but
|
|
53
|
+
* no timestamp for it. Passed in rather than read from the clock so the
|
|
54
|
+
* function stays pure.
|
|
55
|
+
*/
|
|
56
|
+
reportedAt: Date;
|
|
57
|
+
}): EnterpriseLicenseSyncResult {
|
|
58
|
+
const payload: JSONObject = data.payload || {};
|
|
59
|
+
const updateData: PartialEntity<GlobalConfig> = {};
|
|
60
|
+
const warnings: Array<string> = [];
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* The seat limit. This is the field customers change on oneuptime.com and
|
|
64
|
+
* then wait for: without it here, buying seats never reaches the
|
|
65
|
+
* installation that is enforcing the old number.
|
|
66
|
+
*/
|
|
67
|
+
if (this.isPresent(payload, "userLimit")) {
|
|
68
|
+
const rawUserLimit: unknown = payload["userLimit"];
|
|
69
|
+
|
|
70
|
+
if (rawUserLimit === null) {
|
|
71
|
+
updateData.enterpriseLicenseUserLimit = null;
|
|
72
|
+
} else if (this.isNonNegativeInteger(rawUserLimit)) {
|
|
73
|
+
updateData.enterpriseLicenseUserLimit = rawUserLimit as number;
|
|
74
|
+
} else {
|
|
75
|
+
warnings.push(
|
|
76
|
+
`Ignored userLimit "${String(
|
|
77
|
+
rawUserLimit,
|
|
78
|
+
)}" from the license server: it is not a non-negative whole number.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* The count and its timestamp move together: a count with a stamp from a
|
|
85
|
+
* previous report would read as "confirmed today", which is the exact
|
|
86
|
+
* lie this whole sync exists to stop telling.
|
|
87
|
+
*/
|
|
88
|
+
if (this.isPresent(payload, "currentUserCount")) {
|
|
89
|
+
const rawUserCount: unknown = payload["currentUserCount"];
|
|
90
|
+
|
|
91
|
+
if (this.isNonNegativeInteger(rawUserCount)) {
|
|
92
|
+
updateData.enterpriseLicenseCurrentUserCount = rawUserCount as number;
|
|
93
|
+
updateData.enterpriseLicenseUserCountUpdatedAt =
|
|
94
|
+
this.parseDate(payload["userCountUpdatedAt"]) || data.reportedAt;
|
|
95
|
+
} else if (rawUserCount === null) {
|
|
96
|
+
/*
|
|
97
|
+
* The server has never had a count for this license. Nothing useful to
|
|
98
|
+
* store, and blanking the last known count would be worse than keeping
|
|
99
|
+
* it, so leave both columns alone.
|
|
100
|
+
*/
|
|
101
|
+
warnings.push(
|
|
102
|
+
"The license server reported no user count for this license. Keeping the previously stored usage.",
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
warnings.push(
|
|
106
|
+
`Ignored currentUserCount "${String(
|
|
107
|
+
rawUserCount,
|
|
108
|
+
)}" from the license server: it is not a non-negative whole number. Keeping the previously stored usage.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Expiry drives licenseValid on the installation, so a renewal that never
|
|
115
|
+
* arrives here shows up as a license that expires on its original date
|
|
116
|
+
* however much the customer paid. Null is treated as absent on purpose: a
|
|
117
|
+
* report is not the place to strip an installation of its expiry.
|
|
118
|
+
*/
|
|
119
|
+
if (this.isPresent(payload, "expiresAt") && payload["expiresAt"] !== null) {
|
|
120
|
+
const expiresAt: Date | null = this.parseDate(payload["expiresAt"]);
|
|
121
|
+
|
|
122
|
+
if (expiresAt) {
|
|
123
|
+
updateData.enterpriseLicenseExpiresAt = expiresAt;
|
|
124
|
+
} else {
|
|
125
|
+
warnings.push(
|
|
126
|
+
`Ignored expiresAt "${String(
|
|
127
|
+
payload["expiresAt"],
|
|
128
|
+
)}" from the license server: it is not a date.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (this.isPresent(payload, "isEvaluationLicense")) {
|
|
134
|
+
const rawIsEvaluation: unknown = payload["isEvaluationLicense"];
|
|
135
|
+
|
|
136
|
+
if (typeof rawIsEvaluation === "boolean") {
|
|
137
|
+
updateData.enterpriseLicenseIsEvaluation = rawIsEvaluation;
|
|
138
|
+
} else {
|
|
139
|
+
warnings.push(
|
|
140
|
+
`Ignored isEvaluationLicense "${String(
|
|
141
|
+
rawIsEvaluation,
|
|
142
|
+
)}" from the license server: it is not a boolean.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.isPresent(payload, "companyName")) {
|
|
148
|
+
const companyName: string = this.parseNonEmptyString(
|
|
149
|
+
payload["companyName"],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* An empty company name is what the server sends for a license with none
|
|
154
|
+
* set, and overwriting a good name with "" would be a visible regression
|
|
155
|
+
* in the modal. Only a real name is worth writing.
|
|
156
|
+
*/
|
|
157
|
+
if (companyName) {
|
|
158
|
+
updateData.enterpriseCompanyName = companyName;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* The token is opaque here - it is signed with the license server's secret
|
|
164
|
+
* and this side only checks that it exists. It is never cleared: a license
|
|
165
|
+
* that has expired is legitimately issued no token, and clearing it on the
|
|
166
|
+
* way past would compound an expiry the customer may be about to renew.
|
|
167
|
+
*/
|
|
168
|
+
if (this.isPresent(payload, "token")) {
|
|
169
|
+
const token: string = this.parseNonEmptyString(payload["token"]);
|
|
170
|
+
|
|
171
|
+
if (token) {
|
|
172
|
+
updateData.enterpriseLicenseToken = token;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.isPresent(payload, "instances")) {
|
|
177
|
+
const rawInstances: unknown = payload["instances"];
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(rawInstances)) {
|
|
180
|
+
updateData.enterpriseLicenseInstances =
|
|
181
|
+
rawInstances as Array<EnterpriseLicenseInstanceSummary>;
|
|
182
|
+
} else {
|
|
183
|
+
warnings.push(
|
|
184
|
+
"Ignored the instance list from the license server: it is not an array.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
updateData: updateData,
|
|
191
|
+
warnings: warnings,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* Present means the key is on the object, even when it carries null or
|
|
197
|
+
* undefined. `"x" in payload` rather than a truthiness or undefined check,
|
|
198
|
+
* because "the server said null" and "the server said nothing" have to stay
|
|
199
|
+
* distinguishable all the way down.
|
|
200
|
+
*/
|
|
201
|
+
private static isPresent(payload: JSONObject, key: string): boolean {
|
|
202
|
+
if (!Object.prototype.hasOwnProperty.call(payload, key)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// An explicit undefined is indistinguishable from silence. Treat it as one.
|
|
207
|
+
return payload[key] !== undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private static isNonNegativeInteger(value: unknown): boolean {
|
|
211
|
+
return (
|
|
212
|
+
typeof value === "number" &&
|
|
213
|
+
Number.isFinite(value) &&
|
|
214
|
+
Number.isInteger(value) &&
|
|
215
|
+
value >= 0
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private static parseNonEmptyString(value: unknown): string {
|
|
220
|
+
if (typeof value !== "string") {
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return value.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static parseDate(value: unknown): Date | null {
|
|
228
|
+
if (value instanceof Date) {
|
|
229
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const parsed: Date = new Date(value.trim());
|
|
237
|
+
|
|
238
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Dictionary from "../../Types/Dictionary";
|
|
2
|
+
import Includes from "../../Types/BaseDatabase/Includes";
|
|
2
3
|
import JSONFunctions from "../../Types/JSONFunctions";
|
|
3
4
|
import OneUptimeDate from "../../Types/Date";
|
|
4
5
|
import MetricFormulaConfigData from "../../Types/Metrics/MetricFormulaConfigData";
|
|
@@ -32,6 +33,15 @@ export enum MetricExplorerUrlParam {
|
|
|
32
33
|
* (which never had a range param) keep working as pinned windows.
|
|
33
34
|
*/
|
|
34
35
|
Range = "range",
|
|
36
|
+
/*
|
|
37
|
+
* One-shot service scope: a JSON array of Service ObjectID strings,
|
|
38
|
+
* emitted by cross-signal pivots (logs/traces -> metrics). The explorer
|
|
39
|
+
* resolves the ids to service names on load, folds them into every
|
|
40
|
+
* query's `resource.service.name` attribute filter (the metric store's
|
|
41
|
+
* service dimension), and then DELETES the param on its next write-back
|
|
42
|
+
* — the scope lives on as ordinary, user-editable attribute filters.
|
|
43
|
+
*/
|
|
44
|
+
Services = "services",
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface SerializedMetricQueryAlias {
|
|
@@ -60,7 +70,7 @@ export interface SerializedMetricQuery {
|
|
|
60
70
|
* lettering (a, b, ...).
|
|
61
71
|
*/
|
|
62
72
|
variable?: string | undefined;
|
|
63
|
-
attributes?: Dictionary<
|
|
73
|
+
attributes?: Dictionary<SerializedMetricAttributeValue> | undefined;
|
|
64
74
|
aggregationType?: MetricsAggregationType | undefined;
|
|
65
75
|
alias?: SerializedMetricQueryAlias | undefined;
|
|
66
76
|
groupByAttributeKeys?: Array<string> | undefined;
|
|
@@ -90,7 +100,59 @@ export interface SerializedMetricFormula {
|
|
|
90
100
|
criticalThreshold?: number | undefined;
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/*
|
|
104
|
+
* One attribute filter value inside the serialized `metricQueries` param.
|
|
105
|
+
* Scalars are equality filters; Includes is the "any of" membership the
|
|
106
|
+
* UI's operator layer and the query layer both understand. It JSON-
|
|
107
|
+
* serializes to {_type: "Includes", value: [...]} (its toJSON), which the
|
|
108
|
+
* parse side rebuilds into a real instance — that round trip is how a
|
|
109
|
+
* multi-service scope survives Copy Link and saved views.
|
|
110
|
+
*/
|
|
111
|
+
export type SerializedMetricAttributeValue =
|
|
112
|
+
| string
|
|
113
|
+
| number
|
|
114
|
+
| boolean
|
|
115
|
+
| Includes;
|
|
116
|
+
|
|
117
|
+
const MAX_SERVICES_PARAM_ENTRIES: number = 20;
|
|
118
|
+
|
|
93
119
|
export default class MetricExplorerUrl {
|
|
120
|
+
/**
|
|
121
|
+
* Parses the `services` param: a JSON array of non-empty strings,
|
|
122
|
+
* deduped, capped. Garbage yields [] — same defensive posture as the
|
|
123
|
+
* other param parsers.
|
|
124
|
+
*/
|
|
125
|
+
public static parseServicesParam(raw: string): Array<string> {
|
|
126
|
+
let parsedValue: unknown = null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
parsedValue = JSONFunctions.parse(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!Array.isArray(parsedValue)) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const serviceIds: Array<string> = [];
|
|
139
|
+
|
|
140
|
+
for (const entry of parsedValue) {
|
|
141
|
+
if (
|
|
142
|
+
typeof entry === "string" &&
|
|
143
|
+
entry.trim() !== "" &&
|
|
144
|
+
!serviceIds.includes(entry)
|
|
145
|
+
) {
|
|
146
|
+
serviceIds.push(entry);
|
|
147
|
+
}
|
|
148
|
+
if (serviceIds.length >= MAX_SERVICES_PARAM_ENTRIES) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return serviceIds;
|
|
154
|
+
}
|
|
155
|
+
|
|
94
156
|
/*
|
|
95
157
|
* Builds the full URL param dictionary for a metric-view state. Keys
|
|
96
158
|
* are only present when they carry a value (empty/meaningless queries
|
|
@@ -188,7 +250,7 @@ export default class MetricExplorerUrl {
|
|
|
188
250
|
const metricName: string =
|
|
189
251
|
typeof metricNameValue === "string" ? metricNameValue : "";
|
|
190
252
|
|
|
191
|
-
const attributes: Dictionary<
|
|
253
|
+
const attributes: Dictionary<SerializedMetricAttributeValue> =
|
|
192
254
|
MetricExplorerUrl.sanitizeAttributes(filterDataRecord["attributes"]);
|
|
193
255
|
|
|
194
256
|
const aggregationType: MetricsAggregationType | undefined =
|
|
@@ -336,7 +398,7 @@ export default class MetricExplorerUrl {
|
|
|
336
398
|
? (entryRecord["variable"] as string)
|
|
337
399
|
: undefined;
|
|
338
400
|
|
|
339
|
-
const attributes: Dictionary<
|
|
401
|
+
const attributes: Dictionary<SerializedMetricAttributeValue> =
|
|
340
402
|
MetricExplorerUrl.sanitizeAttributes(entryRecord["attributes"]);
|
|
341
403
|
|
|
342
404
|
const aggregationType: MetricsAggregationType | undefined =
|
|
@@ -552,7 +614,7 @@ export default class MetricExplorerUrl {
|
|
|
552
614
|
|
|
553
615
|
public static sanitizeAttributes(
|
|
554
616
|
value: unknown,
|
|
555
|
-
): Dictionary<
|
|
617
|
+
): Dictionary<SerializedMetricAttributeValue> {
|
|
556
618
|
if (value === null || value === undefined) {
|
|
557
619
|
return {};
|
|
558
620
|
}
|
|
@@ -575,7 +637,7 @@ export default class MetricExplorerUrl {
|
|
|
575
637
|
return {};
|
|
576
638
|
}
|
|
577
639
|
|
|
578
|
-
const attributes: Dictionary<
|
|
640
|
+
const attributes: Dictionary<SerializedMetricAttributeValue> = {};
|
|
579
641
|
|
|
580
642
|
for (const key in candidate as Record<string, unknown>) {
|
|
581
643
|
const attributeValue: unknown = (candidate as Record<string, unknown>)[
|
|
@@ -588,12 +650,64 @@ export default class MetricExplorerUrl {
|
|
|
588
650
|
typeof attributeValue === "boolean"
|
|
589
651
|
) {
|
|
590
652
|
attributes[key] = attributeValue;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/*
|
|
657
|
+
* "Any of" membership filters. Live view state holds Includes
|
|
658
|
+
* INSTANCES; parsed URLs hold the plain {_type, value} JSON shape.
|
|
659
|
+
* Both normalize to a fresh instance so multi-value filters (e.g.
|
|
660
|
+
* a multi-service scope) survive Copy Link / saved views instead
|
|
661
|
+
* of being silently dropped — and downstream consumers (the query
|
|
662
|
+
* layer, the operator detector, the monitor evaluator's deep-link
|
|
663
|
+
* builder) all already speak Includes.
|
|
664
|
+
*/
|
|
665
|
+
const membershipValues: Array<string> | null =
|
|
666
|
+
MetricExplorerUrl.getIncludesShapeValues(attributeValue);
|
|
667
|
+
|
|
668
|
+
if (membershipValues !== null && membershipValues.length > 0) {
|
|
669
|
+
attributes[key] = new Includes(membershipValues);
|
|
591
670
|
}
|
|
592
671
|
}
|
|
593
672
|
|
|
594
673
|
return attributes;
|
|
595
674
|
}
|
|
596
675
|
|
|
676
|
+
private static getIncludesShapeValues(value: unknown): Array<string> | null {
|
|
677
|
+
let rawValues: unknown = null;
|
|
678
|
+
|
|
679
|
+
if (value instanceof Includes) {
|
|
680
|
+
rawValues = value.values;
|
|
681
|
+
} else if (
|
|
682
|
+
value &&
|
|
683
|
+
typeof value === "object" &&
|
|
684
|
+
!Array.isArray(value) &&
|
|
685
|
+
(value as Record<string, unknown>)["_type"] === "Includes"
|
|
686
|
+
) {
|
|
687
|
+
rawValues = (value as Record<string, unknown>)["value"];
|
|
688
|
+
} else {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!Array.isArray(rawValues)) {
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const values: Array<string> = [];
|
|
697
|
+
|
|
698
|
+
for (const entry of rawValues) {
|
|
699
|
+
if (
|
|
700
|
+
(typeof entry === "string" && entry.trim() !== "") ||
|
|
701
|
+
typeof entry === "number" ||
|
|
702
|
+
typeof entry === "boolean"
|
|
703
|
+
) {
|
|
704
|
+
values.push(String(entry));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return values;
|
|
709
|
+
}
|
|
710
|
+
|
|
597
711
|
private static buildAliasFromMetricAliasData(
|
|
598
712
|
data: MetricQueryConfigData["metricAliasData"],
|
|
599
713
|
): SerializedMetricQueryAlias | undefined {
|
|
@@ -51,7 +51,9 @@ const WHITESPACE: RegExp = /\s/;
|
|
|
51
51
|
* - Metric explorer (Common/Utils/Metrics/MetricExplorerUrl.ts): the
|
|
52
52
|
* JSON-encoded `metricQueries` param plus absolute `startTime`/`endTime`
|
|
53
53
|
* (a pinned Custom window is represented by the absolute params alone —
|
|
54
|
-
* no `range` token)
|
|
54
|
+
* no `range` token), plus the one-shot `services` param (JSON array of
|
|
55
|
+
* Service ObjectID strings) that the explorer resolves to
|
|
56
|
+
* `resource.service.name` attribute filters on load.
|
|
55
57
|
*/
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -535,11 +537,11 @@ export function toTracesExplorerQueryParams(
|
|
|
535
537
|
* Custom by the absolute params alone — see MetricExplorerUrl). The
|
|
536
538
|
* optional `metricName` argument names the query.
|
|
537
539
|
*
|
|
538
|
-
* Dropped:
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
540
|
+
* Dropped: resourceFacetSelections, traceIds, spanIds, severityTexts,
|
|
541
|
+
* bodyContains — the metric explorer's URL grammar has no resource /
|
|
542
|
+
* trace / span / severity / message dimension. serviceIds DO carry, via
|
|
543
|
+
* the `services` param the explorer folds into `resource.service.name`
|
|
544
|
+
* attribute filters on load.
|
|
543
545
|
*/
|
|
544
546
|
export function toMetricsExplorerQueryParams(
|
|
545
547
|
scope: TelemetryCrossSignalScope,
|
|
@@ -567,6 +569,17 @@ export function toMetricsExplorerQueryParams(
|
|
|
567
569
|
params[MetricExplorerUrlParam.MetricQueries] = JSON.stringify([query]);
|
|
568
570
|
}
|
|
569
571
|
|
|
572
|
+
/*
|
|
573
|
+
* Service scope rides the one-shot `services` param — the explorer
|
|
574
|
+
* resolves ids to names and folds them into `resource.service.name`
|
|
575
|
+
* attribute filters on load, so serviceIds is no longer dropped.
|
|
576
|
+
*/
|
|
577
|
+
const scopedServiceIds: Array<string> = sanitizeValues(scope.serviceIds);
|
|
578
|
+
|
|
579
|
+
if (scopedServiceIds.length > 0) {
|
|
580
|
+
params[MetricExplorerUrlParam.Services] = JSON.stringify(scopedServiceIds);
|
|
581
|
+
}
|
|
582
|
+
|
|
570
583
|
const window: ResolvedTimeWindow = resolveTimeWindow(scope);
|
|
571
584
|
|
|
572
585
|
if (window.startTime && window.endTime) {
|
|
@@ -578,10 +591,6 @@ export function toMetricsExplorerQueryParams(
|
|
|
578
591
|
);
|
|
579
592
|
}
|
|
580
593
|
|
|
581
|
-
if (sanitizeValues(scope.serviceIds).length > 0) {
|
|
582
|
-
dropped.push("serviceIds");
|
|
583
|
-
}
|
|
584
|
-
|
|
585
594
|
for (const [facetKey] of sanitizeResourceFacetSelections(
|
|
586
595
|
scope.resourceFacetSelections,
|
|
587
596
|
)) {
|