@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.
Files changed (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. 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<string | number | boolean> | undefined;
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<string | number | boolean> =
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<string | number | boolean> =
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<string | number | boolean> {
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<string | number | boolean> = {};
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: serviceIds, resourceFacetSelections, traceIds, spanIds,
539
- * severityTexts, bodyContains — the metric explorer's URL grammar has no
540
- * service / resource / trace / span / severity / message dimension (metrics
541
- * carry a primaryEntityId column, but the explorer only parses metricName +
542
- * attributes out of `metricQueries`).
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
  )) {