@oneuptime/common 14.0.2 → 14.0.3

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 (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,1207 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import getJestMockFunction, { MockFunction } from "../../MockType";
10
+
11
+ /*
12
+ * The data behind the telemetry overview tiles (Components/TelemetryResource
13
+ * /telemetryMetrics.ts): the log and exception summaries, the whole-range
14
+ * statistics for one span name (the RUM overview's page-load tiles), the
15
+ * span RED metrics, and the web vitals.
16
+ *
17
+ * The network is replaced at the API client, so what is asserted is the
18
+ * request each function really sends and how it folds the answer - in
19
+ * particular that a failed lookup comes back marked as failed (the tile says
20
+ * "could not load") rather than as a confident zero.
21
+ */
22
+
23
+ const postMock: MockFunction = getJestMockFunction();
24
+ const aggregateMock: MockFunction = getJestMockFunction();
25
+ const projectIdMock: MockFunction = getJestMockFunction();
26
+
27
+ jest.mock("../../../UI/Utils/API/API", () => {
28
+ return {
29
+ __esModule: true,
30
+ default: {
31
+ post: (...args: Array<unknown>): unknown => {
32
+ return postMock(...args);
33
+ },
34
+ },
35
+ };
36
+ });
37
+
38
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
39
+ return {
40
+ __esModule: true,
41
+ default: {
42
+ getCommonHeaders: (): Record<string, string> => {
43
+ return { "x-test-header": "1" };
44
+ },
45
+ },
46
+ };
47
+ });
48
+
49
+ jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
50
+ return {
51
+ __esModule: true,
52
+ default: {
53
+ aggregate: (...args: Array<unknown>): unknown => {
54
+ return aggregateMock(...args);
55
+ },
56
+ },
57
+ };
58
+ });
59
+
60
+ jest.mock("../../../UI/Utils/Project", () => {
61
+ return {
62
+ __esModule: true,
63
+ default: {
64
+ getCurrentProjectId: (): unknown => {
65
+ return projectIdMock();
66
+ },
67
+ },
68
+ };
69
+ });
70
+
71
+ import {
72
+ fetchLogAndExceptionSignals,
73
+ fetchSpanMetrics,
74
+ fetchSpanNameStats,
75
+ fetchWebVitals,
76
+ formatBytes as reexportedFormatBytes,
77
+ formatCompact as reexportedFormatCompact,
78
+ formatDurationMs as reexportedFormatDurationMs,
79
+ formatPercent as reexportedFormatPercent,
80
+ LogAndExceptionSignals,
81
+ RawHistogramBucket,
82
+ SpanMetrics,
83
+ SpanNameStats,
84
+ spanNameStatsFromTableRows,
85
+ summarizeLogAndExceptionBuckets,
86
+ TimePoint,
87
+ WebVital,
88
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryMetrics";
89
+ import {
90
+ formatBytes,
91
+ formatCompact,
92
+ formatDurationMs,
93
+ formatPercent,
94
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryFormat";
95
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
96
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
97
+ import URL from "../../../Types/API/URL";
98
+ import { JSONObject } from "../../../Types/JSON";
99
+ import ObjectID from "../../../Types/ObjectID";
100
+ import AggregationType from "../../../Types/BaseDatabase/AggregationType";
101
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
102
+ import { SpanStatus } from "../../../Models/AnalyticsModels/Span";
103
+ import {
104
+ WebVitalDefinition,
105
+ WebVitalDefinitions,
106
+ } from "../../../Types/Rum/WebVitals";
107
+ import { APP_API_URL } from "../../../UI/Config";
108
+
109
+ const ENTITY_ID: string = "84858d6c-1111-4aaa-8bbb-000000000001";
110
+ const PROJECT_ID: string = "10000000-0000-4000-8000-000000000001";
111
+ const START: Date = new Date("2026-09-24T10:00:00.000Z");
112
+ const END: Date = new Date("2026-09-24T11:00:00.000Z");
113
+
114
+ const T0: string = "2026-09-24T10:00:00.000Z";
115
+ const T1: string = "2026-09-24T10:05:00.000Z";
116
+ const T2: string = "2026-09-24T10:10:00.000Z";
117
+
118
+ interface PostRequest {
119
+ url: URL;
120
+ data: JSONObject;
121
+ headers: Record<string, string>;
122
+ }
123
+
124
+ interface AggregateRequest {
125
+ modelType: unknown;
126
+ aggregateBy: {
127
+ query: Record<string, unknown>;
128
+ aggregationType: AggregationType;
129
+ aggregateColumnName: string;
130
+ aggregationTimestampColumnName: string;
131
+ startTimestamp: Date;
132
+ endTimestamp: Date;
133
+ };
134
+ }
135
+
136
+ function ok(data: JSONObject): HTTPResponse<JSONObject> {
137
+ return new HTTPResponse<JSONObject>(200, data, {});
138
+ }
139
+
140
+ function httpError(status: number): HTTPErrorResponse {
141
+ return new HTTPErrorResponse(status, { message: "denied" }, {});
142
+ }
143
+
144
+ function series(points: Array<TimePoint>): Array<[string, number]> {
145
+ return points.map((p: TimePoint): [string, number] => {
146
+ return [p.x.toISOString(), p.y];
147
+ });
148
+ }
149
+
150
+ function postedTo(request: PostRequest): string {
151
+ return request.url.toString();
152
+ }
153
+
154
+ function postRequests(): Array<PostRequest> {
155
+ return postMock.mock.calls.map((call: Array<unknown>): PostRequest => {
156
+ return call[0] as PostRequest;
157
+ });
158
+ }
159
+
160
+ function aggregateRequests(): Array<AggregateRequest> {
161
+ return aggregateMock.mock.calls.map(
162
+ (call: Array<unknown>): AggregateRequest => {
163
+ return call[0] as AggregateRequest;
164
+ },
165
+ );
166
+ }
167
+
168
+ beforeEach(() => {
169
+ postMock.mockReset();
170
+ aggregateMock.mockReset();
171
+ projectIdMock.mockReset();
172
+ projectIdMock.mockReturnValue(new ObjectID(PROJECT_ID));
173
+ });
174
+
175
+ afterEach(() => {
176
+ jest.restoreAllMocks();
177
+ });
178
+
179
+ describe("summarizeLogAndExceptionBuckets", () => {
180
+ test("a pillar whose request failed comes back zeroed and marked failed", () => {
181
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
182
+ null,
183
+ null,
184
+ );
185
+
186
+ expect(result).toEqual({
187
+ logs: {
188
+ total: 0,
189
+ errorCount: 0,
190
+ countSeries: [],
191
+ errorSeries: [],
192
+ failed: true,
193
+ },
194
+ exceptions: {
195
+ total: 0,
196
+ unhandledCount: 0,
197
+ unhandledSeries: [],
198
+ handledSeries: [],
199
+ failed: true,
200
+ },
201
+ });
202
+ });
203
+
204
+ test("empty answers are real zeros, not failures", () => {
205
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
206
+ [],
207
+ [],
208
+ );
209
+
210
+ expect(result.logs.failed).toBe(false);
211
+ expect(result.exceptions.failed).toBe(false);
212
+ expect(result.logs.total).toBe(0);
213
+ expect(result.exceptions.total).toBe(0);
214
+ });
215
+
216
+ test("one pillar failing leaves the other intact", () => {
217
+ const logsOnly: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
218
+ [{ time: T0, severity: "Info", count: 4 }],
219
+ null,
220
+ );
221
+
222
+ expect(logsOnly.logs.failed).toBe(false);
223
+ expect(logsOnly.logs.total).toBe(4);
224
+ expect(logsOnly.exceptions.failed).toBe(true);
225
+
226
+ const exceptionsOnly: LogAndExceptionSignals =
227
+ summarizeLogAndExceptionBuckets(null, [
228
+ { time: T0, series: "unhandled", count: 2 },
229
+ ]);
230
+
231
+ expect(exceptionsOnly.logs.failed).toBe(true);
232
+ expect(exceptionsOnly.exceptions.failed).toBe(false);
233
+ expect(exceptionsOnly.exceptions.total).toBe(2);
234
+ });
235
+
236
+ test("log totals add every bucket, whatever the severity", () => {
237
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
238
+ [
239
+ { time: T0, severity: "Info", count: 10 },
240
+ { time: T0, severity: "Warning", count: 5 },
241
+ { time: T1, severity: "Error", count: 3 },
242
+ { time: T1, severity: "Fatal", count: 1 },
243
+ { time: T2, severity: "Debug", count: 2 },
244
+ ],
245
+ [],
246
+ );
247
+
248
+ expect(result.logs.total).toBe(21);
249
+ });
250
+
251
+ test("only Error and Fatal count as errors - exact, case-sensitive severities", () => {
252
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
253
+ [
254
+ { time: T0, severity: "Error", count: 3 },
255
+ { time: T0, severity: "Fatal", count: 1 },
256
+ { time: T0, severity: "error", count: 100 },
257
+ { time: T0, severity: "Warning", count: 100 },
258
+ { time: T0, severity: "Critical", count: 100 },
259
+ { time: T0, count: 100 },
260
+ ],
261
+ [],
262
+ );
263
+
264
+ expect(result.logs.errorCount).toBe(4);
265
+ expect(series(result.logs.errorSeries)).toEqual([[T0, 4]]);
266
+ });
267
+
268
+ test("the log count series is merged per timestamp across severities and sorted", () => {
269
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
270
+ [
271
+ { time: T2, severity: "Info", count: 1 },
272
+ { time: T0, severity: "Info", count: 10 },
273
+ { time: T0, severity: "Error", count: 2 },
274
+ { time: T1, severity: "Warning", count: 5 },
275
+ ],
276
+ [],
277
+ );
278
+
279
+ expect(series(result.logs.countSeries)).toEqual([
280
+ [T0, 12],
281
+ [T1, 5],
282
+ [T2, 1],
283
+ ]);
284
+ expect(series(result.logs.errorSeries)).toEqual([[T0, 2]]);
285
+ });
286
+
287
+ test("series points are real Dates", () => {
288
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
289
+ [{ time: T0, severity: "Info", count: 1 }],
290
+ [{ time: T0, series: "handled", count: 1 }],
291
+ );
292
+
293
+ expect(result.logs.countSeries[0]!.x).toBeInstanceOf(Date);
294
+ expect(result.exceptions.handledSeries[0]!.x).toBeInstanceOf(Date);
295
+ });
296
+
297
+ test("exceptions split unhandled from everything else, and the total counts both", () => {
298
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
299
+ [],
300
+ [
301
+ { time: T0, series: "unhandled", count: 2 },
302
+ { time: T0, series: "handled", count: 4 },
303
+ { time: T1, series: "handled", count: 8 },
304
+ { time: T2, series: "unhandled", count: 3 },
305
+ { time: T2, count: 1 },
306
+ { time: T2, series: "something-else", count: 1 },
307
+ ],
308
+ );
309
+
310
+ expect(result.exceptions.total).toBe(19);
311
+ expect(result.exceptions.unhandledCount).toBe(5);
312
+ expect(series(result.exceptions.unhandledSeries)).toEqual([
313
+ [T0, 2],
314
+ [T2, 3],
315
+ ]);
316
+ expect(series(result.exceptions.handledSeries)).toEqual([
317
+ [T0, 4],
318
+ [T1, 8],
319
+ [T2, 2],
320
+ ]);
321
+ });
322
+
323
+ test("a bucket with a missing or unparseable time is kept out of the series", () => {
324
+ const buckets: Array<RawHistogramBucket> = [
325
+ { severity: "Error", count: 3 },
326
+ { time: "not a time", severity: "Error", count: 4 },
327
+ { time: T0, severity: "Error", count: 1 },
328
+ ];
329
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
330
+ buckets,
331
+ [
332
+ { series: "unhandled", count: 5 },
333
+ { time: "garbage", series: "handled", count: 6 },
334
+ { time: T1, series: "handled", count: 1 },
335
+ ],
336
+ );
337
+
338
+ expect(series(result.logs.countSeries)).toEqual([[T0, 1]]);
339
+ expect(series(result.logs.errorSeries)).toEqual([[T0, 1]]);
340
+ expect(result.exceptions.unhandledSeries).toEqual([]);
341
+ expect(series(result.exceptions.handledSeries)).toEqual([[T1, 1]]);
342
+ });
343
+
344
+ test("a bucket whose count is missing or not a number adds nothing", () => {
345
+ const result: LogAndExceptionSignals = summarizeLogAndExceptionBuckets(
346
+ [
347
+ { time: T0, severity: "Error" },
348
+ {
349
+ time: T0,
350
+ severity: "Error",
351
+ count: "7" as unknown as number,
352
+ },
353
+ { time: T0, severity: "Error", count: 2 },
354
+ ],
355
+ [
356
+ { time: T0, series: "unhandled" },
357
+ { time: T0, series: "unhandled", count: 1 },
358
+ ],
359
+ );
360
+
361
+ expect(result.logs.total).toBe(2);
362
+ expect(result.logs.errorCount).toBe(2);
363
+ expect(series(result.logs.countSeries)).toEqual([[T0, 2]]);
364
+ expect(result.exceptions.total).toBe(1);
365
+ expect(result.exceptions.unhandledCount).toBe(1);
366
+ });
367
+ });
368
+
369
+ describe("spanNameStatsFromTableRows", () => {
370
+ const ZERO: SpanNameStats = {
371
+ count: 0,
372
+ errorCount: 0,
373
+ avgDurationMs: 0,
374
+ p50DurationMs: 0,
375
+ p95DurationMs: 0,
376
+ p99DurationMs: 0,
377
+ };
378
+
379
+ test.each([
380
+ ["an empty array", []],
381
+ ["undefined", undefined],
382
+ ["null", null],
383
+ ["a string", "rows"],
384
+ ["a number", 42],
385
+ ["an object that is not an array", { count: 5 }],
386
+ ["an array whose first row is null", [null]],
387
+ ["an array whose first row is a string", ["row"]],
388
+ ])("%s means no matching spans: zeros", (_: string, rows: unknown) => {
389
+ expect(spanNameStatsFromTableRows(rows)).toEqual(ZERO);
390
+ });
391
+
392
+ test("reads every statistic from the first row", () => {
393
+ expect(
394
+ spanNameStatsFromTableRows([
395
+ {
396
+ groupValues: { name: "documentLoad" },
397
+ count: 1234,
398
+ errorCount: 3,
399
+ avgDurationMs: 1000,
400
+ p50DurationMs: 850,
401
+ p90DurationMs: 2000,
402
+ p95DurationMs: 2340,
403
+ p99DurationMs: 4100,
404
+ },
405
+ ]),
406
+ ).toEqual({
407
+ count: 1234,
408
+ errorCount: 3,
409
+ avgDurationMs: 1000,
410
+ p50DurationMs: 850,
411
+ p95DurationMs: 2340,
412
+ p99DurationMs: 4100,
413
+ });
414
+ });
415
+
416
+ test("picks the first row and ignores the rest", () => {
417
+ expect(
418
+ spanNameStatsFromTableRows([
419
+ { count: 5, p95DurationMs: 10 },
420
+ { count: 999, p95DurationMs: 999 },
421
+ ]),
422
+ ).toEqual({ ...ZERO, count: 5, p95DurationMs: 10 });
423
+ });
424
+
425
+ test("numeric strings are read as numbers", () => {
426
+ expect(
427
+ spanNameStatsFromTableRows([{ count: "12", p50DurationMs: "7.5" }]),
428
+ ).toEqual({ ...ZERO, count: 12, p50DurationMs: 7.5 });
429
+ });
430
+
431
+ test.each([
432
+ ["missing", undefined],
433
+ ["NaN", Number.NaN],
434
+ ["Infinity", Number.POSITIVE_INFINITY],
435
+ ["-Infinity", Number.NEGATIVE_INFINITY],
436
+ ["a word", "slow"],
437
+ ["an object", { value: 3 }],
438
+ ])("a %s statistic reads as 0", (_: string, value: unknown) => {
439
+ expect(
440
+ spanNameStatsFromTableRows([{ count: 4, p95DurationMs: value }]),
441
+ ).toEqual({ ...ZERO, count: 4 });
442
+ });
443
+ });
444
+
445
+ describe("fetchSpanNameStats", () => {
446
+ function request(): PostRequest {
447
+ expect(postMock).toHaveBeenCalledTimes(1);
448
+ return postRequests()[0]!;
449
+ }
450
+
451
+ test("asks the trace analytics endpoint for one table row of this span name under this entity", async () => {
452
+ postMock.mockResolvedValue(ok({ data: [] }));
453
+
454
+ await fetchSpanNameStats({
455
+ primaryEntityId: new ObjectID(ENTITY_ID),
456
+ spanName: "documentLoad",
457
+ start: START,
458
+ end: END,
459
+ });
460
+
461
+ const sent: PostRequest = request();
462
+
463
+ expect(postedTo(sent)).toBe(
464
+ `${APP_API_URL.toString()}/telemetry/traces/analytics`,
465
+ );
466
+ expect(sent.data).toEqual({
467
+ startTime: START.toISOString(),
468
+ endTime: END.toISOString(),
469
+ chartType: "table",
470
+ metric: "count",
471
+ groupBy: ["name"],
472
+ spanNames: ["documentLoad"],
473
+ serviceIds: [ENTITY_ID],
474
+ limit: 1,
475
+ });
476
+ expect(sent.headers).toEqual({ "x-test-header": "1" });
477
+ });
478
+
479
+ test("the span name is sent exactly as given", async () => {
480
+ postMock.mockResolvedValue(ok({ data: [] }));
481
+
482
+ await fetchSpanNameStats({
483
+ primaryEntityId: new ObjectID(ENTITY_ID),
484
+ spanName: "GET /api/checkout",
485
+ start: START,
486
+ end: END,
487
+ });
488
+
489
+ expect(request().data["spanNames"]).toEqual(["GET /api/checkout"]);
490
+ });
491
+
492
+ test("returns the first row's statistics", async () => {
493
+ postMock.mockResolvedValue(
494
+ ok({
495
+ data: [
496
+ {
497
+ groupValues: { name: "documentLoad" },
498
+ count: 40,
499
+ errorCount: 2,
500
+ avgDurationMs: 900,
501
+ p50DurationMs: 800,
502
+ p95DurationMs: 2100,
503
+ p99DurationMs: 3000,
504
+ },
505
+ ],
506
+ }),
507
+ );
508
+
509
+ await expect(
510
+ fetchSpanNameStats({
511
+ primaryEntityId: new ObjectID(ENTITY_ID),
512
+ spanName: "documentLoad",
513
+ start: START,
514
+ end: END,
515
+ }),
516
+ ).resolves.toEqual({
517
+ count: 40,
518
+ errorCount: 2,
519
+ avgDurationMs: 900,
520
+ p50DurationMs: 800,
521
+ p95DurationMs: 2100,
522
+ p99DurationMs: 3000,
523
+ });
524
+ });
525
+
526
+ test("no rows is a real zero", async () => {
527
+ postMock.mockResolvedValue(ok({ data: [] }));
528
+
529
+ const stats: SpanNameStats = await fetchSpanNameStats({
530
+ primaryEntityId: new ObjectID(ENTITY_ID),
531
+ spanName: "documentLoad",
532
+ start: START,
533
+ end: END,
534
+ });
535
+
536
+ expect(stats.count).toBe(0);
537
+ });
538
+
539
+ test.each([403, 500])(
540
+ "an HTTP %d rejects, so the tile can say 'could not load'",
541
+ async (status: number) => {
542
+ const error: HTTPErrorResponse = httpError(status);
543
+ postMock.mockResolvedValue(error);
544
+
545
+ await expect(
546
+ fetchSpanNameStats({
547
+ primaryEntityId: new ObjectID(ENTITY_ID),
548
+ spanName: "documentLoad",
549
+ start: START,
550
+ end: END,
551
+ }),
552
+ ).rejects.toBe(error);
553
+ },
554
+ );
555
+
556
+ test("a network failure rejects too", async () => {
557
+ postMock.mockRejectedValue(new Error("offline"));
558
+
559
+ await expect(
560
+ fetchSpanNameStats({
561
+ primaryEntityId: new ObjectID(ENTITY_ID),
562
+ spanName: "documentLoad",
563
+ start: START,
564
+ end: END,
565
+ }),
566
+ ).rejects.toThrow("offline");
567
+ });
568
+ });
569
+
570
+ describe("fetchLogAndExceptionSignals", () => {
571
+ const LOG_BUCKETS: Array<RawHistogramBucket> = [
572
+ { time: T0, severity: "Info", count: 10 },
573
+ { time: T1, severity: "Error", count: 2 },
574
+ ];
575
+ const EXCEPTION_BUCKETS: Array<RawHistogramBucket> = [
576
+ { time: T0, series: "unhandled", count: 1 },
577
+ { time: T1, series: "handled", count: 3 },
578
+ ];
579
+
580
+ function respond(handlers: {
581
+ logs: () => Promise<unknown>;
582
+ exceptions: () => Promise<unknown>;
583
+ }): void {
584
+ postMock.mockImplementation((...args: Array<unknown>) => {
585
+ const url: string = postedTo(args[0] as PostRequest);
586
+
587
+ if (url.endsWith("/telemetry/logs/histogram")) {
588
+ return handlers.logs();
589
+ }
590
+
591
+ if (url.endsWith("/telemetry/exceptions/histogram")) {
592
+ return handlers.exceptions();
593
+ }
594
+
595
+ return Promise.reject(new Error(`unexpected ${url}`));
596
+ });
597
+ }
598
+
599
+ async function run(): Promise<LogAndExceptionSignals> {
600
+ return fetchLogAndExceptionSignals({
601
+ primaryEntityId: new ObjectID(ENTITY_ID),
602
+ start: START,
603
+ end: END,
604
+ });
605
+ }
606
+
607
+ test("asks both histograms for the same entity and window", async () => {
608
+ respond({
609
+ logs: async () => {
610
+ return ok({ buckets: LOG_BUCKETS as unknown as JSONObject });
611
+ },
612
+ exceptions: async () => {
613
+ return ok({ buckets: EXCEPTION_BUCKETS as unknown as JSONObject });
614
+ },
615
+ });
616
+
617
+ await run();
618
+
619
+ const sent: Array<PostRequest> = postRequests();
620
+
621
+ expect(sent.map(postedTo).sort()).toEqual(
622
+ [
623
+ `${APP_API_URL.toString()}/telemetry/exceptions/histogram`,
624
+ `${APP_API_URL.toString()}/telemetry/logs/histogram`,
625
+ ].sort(),
626
+ );
627
+
628
+ for (const request of sent) {
629
+ expect(request.data).toEqual({
630
+ startTime: START.toISOString(),
631
+ endTime: END.toISOString(),
632
+ serviceIds: [ENTITY_ID],
633
+ });
634
+ expect(request.headers).toEqual({ "x-test-header": "1" });
635
+ }
636
+ });
637
+
638
+ test("both answering: totals and series from each pillar, nothing failed", async () => {
639
+ respond({
640
+ logs: async () => {
641
+ return ok({ buckets: LOG_BUCKETS as unknown as JSONObject });
642
+ },
643
+ exceptions: async () => {
644
+ return ok({ buckets: EXCEPTION_BUCKETS as unknown as JSONObject });
645
+ },
646
+ });
647
+
648
+ const result: LogAndExceptionSignals = await run();
649
+
650
+ expect(result.logs.failed).toBe(false);
651
+ expect(result.logs.total).toBe(12);
652
+ expect(result.logs.errorCount).toBe(2);
653
+ expect(result.exceptions.failed).toBe(false);
654
+ expect(result.exceptions.total).toBe(4);
655
+ expect(result.exceptions.unhandledCount).toBe(1);
656
+ });
657
+
658
+ test("the logs endpoint refusing marks only logs failed", async () => {
659
+ respond({
660
+ logs: async () => {
661
+ return httpError(403);
662
+ },
663
+ exceptions: async () => {
664
+ return ok({ buckets: EXCEPTION_BUCKETS as unknown as JSONObject });
665
+ },
666
+ });
667
+
668
+ const result: LogAndExceptionSignals = await run();
669
+
670
+ expect(result.logs.failed).toBe(true);
671
+ expect(result.logs.total).toBe(0);
672
+ expect(result.exceptions.failed).toBe(false);
673
+ expect(result.exceptions.total).toBe(4);
674
+ });
675
+
676
+ test("the exceptions endpoint throwing marks only exceptions failed", async () => {
677
+ respond({
678
+ logs: async () => {
679
+ return ok({ buckets: LOG_BUCKETS as unknown as JSONObject });
680
+ },
681
+ exceptions: async () => {
682
+ throw new Error("offline");
683
+ },
684
+ });
685
+
686
+ const result: LogAndExceptionSignals = await run();
687
+
688
+ expect(result.logs.failed).toBe(false);
689
+ expect(result.logs.total).toBe(12);
690
+ expect(result.exceptions.failed).toBe(true);
691
+ expect(result.exceptions.total).toBe(0);
692
+ });
693
+
694
+ test("both failing marks both failed, and still resolves - the page never breaks", async () => {
695
+ respond({
696
+ logs: async () => {
697
+ return httpError(500);
698
+ },
699
+ exceptions: async () => {
700
+ throw new Error("offline");
701
+ },
702
+ });
703
+
704
+ const result: LogAndExceptionSignals = await run();
705
+
706
+ expect(result.logs.failed).toBe(true);
707
+ expect(result.exceptions.failed).toBe(true);
708
+ });
709
+
710
+ test("an answer without a bucket array is an empty answer, not a failure", async () => {
711
+ respond({
712
+ logs: async () => {
713
+ return ok({ buckets: "nope" });
714
+ },
715
+ exceptions: async () => {
716
+ return ok({});
717
+ },
718
+ });
719
+
720
+ const result: LogAndExceptionSignals = await run();
721
+
722
+ expect(result.logs.failed).toBe(false);
723
+ expect(result.logs.total).toBe(0);
724
+ expect(result.exceptions.failed).toBe(false);
725
+ expect(result.exceptions.total).toBe(0);
726
+ });
727
+ });
728
+
729
+ describe("fetchSpanMetrics", () => {
730
+ const NS_PER_MS: number = 1_000_000;
731
+
732
+ function answer(byCall: Array<Array<JSONObject>>): void {
733
+ let call: number = 0;
734
+ aggregateMock.mockImplementation(async () => {
735
+ const data: Array<JSONObject> = byCall[call] || [];
736
+ call += 1;
737
+ return { data };
738
+ });
739
+ }
740
+
741
+ function queries(): Array<Record<string, unknown>> {
742
+ return aggregateRequests().map(
743
+ (r: AggregateRequest): Record<string, unknown> => {
744
+ return r.aggregateBy.query;
745
+ },
746
+ );
747
+ }
748
+
749
+ test("with a span name, all three aggregates are narrowed to that name", async () => {
750
+ answer([]);
751
+
752
+ await fetchSpanMetrics({
753
+ primaryEntityId: new ObjectID(ENTITY_ID),
754
+ spanName: "documentLoad",
755
+ start: START,
756
+ end: END,
757
+ });
758
+
759
+ expect(aggregateMock).toHaveBeenCalledTimes(3);
760
+ for (const query of queries()) {
761
+ expect(query["name"]).toBe("documentLoad");
762
+ }
763
+ });
764
+
765
+ test("without a span name, no aggregate filters on name", async () => {
766
+ answer([]);
767
+
768
+ await fetchSpanMetrics({
769
+ primaryEntityId: new ObjectID(ENTITY_ID),
770
+ start: START,
771
+ end: END,
772
+ });
773
+
774
+ expect(aggregateMock).toHaveBeenCalledTimes(3);
775
+ for (const query of queries()) {
776
+ expect(Object.keys(query)).not.toContain("name");
777
+ }
778
+ });
779
+
780
+ test("an empty span name is treated as no span name", async () => {
781
+ answer([]);
782
+
783
+ await fetchSpanMetrics({
784
+ primaryEntityId: new ObjectID(ENTITY_ID),
785
+ spanName: "",
786
+ start: START,
787
+ end: END,
788
+ });
789
+
790
+ for (const query of queries()) {
791
+ expect(Object.keys(query)).not.toContain("name");
792
+ }
793
+ });
794
+
795
+ test("count, error count and p95, each scoped to the project, entity and window", async () => {
796
+ answer([]);
797
+
798
+ await fetchSpanMetrics({
799
+ primaryEntityId: new ObjectID(ENTITY_ID),
800
+ spanName: "documentLoad",
801
+ start: START,
802
+ end: END,
803
+ });
804
+
805
+ const requests: Array<AggregateRequest> = aggregateRequests();
806
+
807
+ expect(
808
+ requests.map((r: AggregateRequest) => {
809
+ return r.aggregateBy.aggregationType;
810
+ }),
811
+ ).toEqual([
812
+ AggregationType.Count,
813
+ AggregationType.Count,
814
+ AggregationType.P95,
815
+ ]);
816
+
817
+ for (const r of requests) {
818
+ const query: Record<string, unknown> = r.aggregateBy.query;
819
+
820
+ expect(String(query["projectId"])).toBe(PROJECT_ID);
821
+ expect(String(query["primaryEntityId"])).toBe(ENTITY_ID);
822
+ expect(query["startTime"]).toBeInstanceOf(InBetween);
823
+ expect((query["startTime"] as InBetween<Date>).startValue).toBe(START);
824
+ expect((query["startTime"] as InBetween<Date>).endValue).toBe(END);
825
+ expect(r.aggregateBy.aggregateColumnName).toBe("durationUnixNano");
826
+ expect(r.aggregateBy.aggregationTimestampColumnName).toBe("startTime");
827
+ expect(r.aggregateBy.startTimestamp).toBe(START);
828
+ expect(r.aggregateBy.endTimestamp).toBe(END);
829
+ }
830
+
831
+ // Only the second count is narrowed to errors.
832
+ expect(requests[0]!.aggregateBy.query["statusCode"]).toBeUndefined();
833
+ expect(requests[1]!.aggregateBy.query["statusCode"]).toBe(SpanStatus.Error);
834
+ expect(requests[1]!.aggregateBy.query["name"]).toBe("documentLoad");
835
+ expect(requests[2]!.aggregateBy.query["statusCode"]).toBeUndefined();
836
+ });
837
+
838
+ test("attributes are passed through when given and left out when empty", async () => {
839
+ answer([]);
840
+ await fetchSpanMetrics({
841
+ attributes: { "cloud.region": "us-east-1" },
842
+ start: START,
843
+ end: END,
844
+ });
845
+
846
+ for (const query of queries()) {
847
+ expect(query["attributes"]).toEqual({ "cloud.region": "us-east-1" });
848
+ expect(Object.keys(query)).not.toContain("primaryEntityId");
849
+ }
850
+
851
+ aggregateMock.mockReset();
852
+ answer([]);
853
+ await fetchSpanMetrics({ attributes: {}, start: START, end: END });
854
+
855
+ for (const query of queries()) {
856
+ expect(Object.keys(query)).not.toContain("attributes");
857
+ }
858
+ });
859
+
860
+ test("totals are sums over the intervals, and the error rate is errors over all", async () => {
861
+ answer([
862
+ [
863
+ { timestamp: T1, value: 300 },
864
+ { timestamp: T0, value: 100 },
865
+ ],
866
+ [{ timestamp: T1, value: 8 }],
867
+ [
868
+ { timestamp: T0, value: 120 * NS_PER_MS },
869
+ { timestamp: T1, value: 480 * NS_PER_MS },
870
+ ],
871
+ ]);
872
+
873
+ const m: SpanMetrics = await fetchSpanMetrics({
874
+ primaryEntityId: new ObjectID(ENTITY_ID),
875
+ start: START,
876
+ end: END,
877
+ });
878
+
879
+ expect(m.total).toBe(400);
880
+ expect(m.errors).toBe(8);
881
+ expect(m.errorRatePercent).toBe(2);
882
+ expect(series(m.countSeries)).toEqual([
883
+ [T0, 100],
884
+ [T1, 300],
885
+ ]);
886
+ expect(series(m.errorSeries)).toEqual([[T1, 8]]);
887
+ });
888
+
889
+ test("durations are converted from nanoseconds to milliseconds", async () => {
890
+ answer([[], [], [{ timestamp: T0, value: 2_500_000 }]]);
891
+
892
+ const m: SpanMetrics = await fetchSpanMetrics({
893
+ primaryEntityId: new ObjectID(ENTITY_ID),
894
+ start: START,
895
+ end: END,
896
+ });
897
+
898
+ expect(series(m.p95Series)).toEqual([[T0, 2.5]]);
899
+ });
900
+
901
+ /*
902
+ * The RUM "Event duration (p95)" tooltip says the tile is "worked out for
903
+ * each interval, then averaged over the selected range". This is that.
904
+ */
905
+ test("the headline p95 is the mean of the per-interval p95s, not a p95 over the range", async () => {
906
+ answer([
907
+ [
908
+ { timestamp: T0, value: 1 },
909
+ { timestamp: T1, value: 1000 },
910
+ ],
911
+ [],
912
+ [
913
+ { timestamp: T0, value: 100 * NS_PER_MS },
914
+ { timestamp: T1, value: 300 * NS_PER_MS },
915
+ ],
916
+ ]);
917
+
918
+ const m: SpanMetrics = await fetchSpanMetrics({
919
+ primaryEntityId: new ObjectID(ENTITY_ID),
920
+ start: START,
921
+ end: END,
922
+ });
923
+
924
+ // Unweighted: the one-span interval counts as much as the busy one.
925
+ expect(m.p95DurationMs).toBe(200);
926
+ });
927
+
928
+ test("buckets keyed 'time' are read too, and bad ones are dropped", async () => {
929
+ answer([
930
+ [
931
+ { time: T0, value: 5 },
932
+ { timestamp: "not a time", value: 50 },
933
+ { timestamp: T1, value: "many" },
934
+ { value: 7 },
935
+ ],
936
+ [],
937
+ [],
938
+ ]);
939
+
940
+ const m: SpanMetrics = await fetchSpanMetrics({
941
+ primaryEntityId: new ObjectID(ENTITY_ID),
942
+ start: START,
943
+ end: END,
944
+ });
945
+
946
+ expect(series(m.countSeries)).toEqual([[T0, 5]]);
947
+ expect(m.total).toBe(5);
948
+ });
949
+
950
+ test("no spans: zero total, and no error rate or p95 to show", async () => {
951
+ answer([[], [], []]);
952
+
953
+ await expect(
954
+ fetchSpanMetrics({
955
+ primaryEntityId: new ObjectID(ENTITY_ID),
956
+ start: START,
957
+ end: END,
958
+ }),
959
+ ).resolves.toEqual({
960
+ total: 0,
961
+ errors: 0,
962
+ errorRatePercent: null,
963
+ p95DurationMs: null,
964
+ countSeries: [],
965
+ errorSeries: [],
966
+ p95Series: [],
967
+ });
968
+ });
969
+
970
+ test("no current project: returns empty without asking", async () => {
971
+ projectIdMock.mockReturnValue(null);
972
+
973
+ const m: SpanMetrics = await fetchSpanMetrics({
974
+ primaryEntityId: new ObjectID(ENTITY_ID),
975
+ start: START,
976
+ end: END,
977
+ });
978
+
979
+ expect(aggregateMock).not.toHaveBeenCalled();
980
+ expect(m.total).toBe(0);
981
+ expect(m.p95DurationMs).toBeNull();
982
+ });
983
+
984
+ test("a failed aggregate resolves empty, marked failed, rather than rejecting", async () => {
985
+ aggregateMock.mockRejectedValue(new Error("500"));
986
+
987
+ const m: SpanMetrics = await fetchSpanMetrics({
988
+ primaryEntityId: new ObjectID(ENTITY_ID),
989
+ spanName: "documentLoad",
990
+ start: START,
991
+ end: END,
992
+ });
993
+
994
+ expect(m.total).toBe(0);
995
+ expect(m.errorRatePercent).toBeNull();
996
+ /*
997
+ * The zeros are unknown, not none: a tile reads `failed` and says
998
+ * "could not load".
999
+ */
1000
+ expect(m.failed).toBe(true);
1001
+ });
1002
+
1003
+ test("one of the three aggregates failing fails the whole result", async () => {
1004
+ aggregateMock
1005
+ .mockResolvedValueOnce({ data: [] })
1006
+ .mockRejectedValueOnce(new Error("403"))
1007
+ .mockResolvedValueOnce({ data: [] });
1008
+
1009
+ const m: SpanMetrics = await fetchSpanMetrics({
1010
+ primaryEntityId: new ObjectID(ENTITY_ID),
1011
+ start: START,
1012
+ end: END,
1013
+ });
1014
+
1015
+ expect(m.failed).toBe(true);
1016
+ expect(m.total).toBe(0);
1017
+ });
1018
+
1019
+ test("a successful lookup and a missing project are not failures", async () => {
1020
+ answer([[], [], []]);
1021
+
1022
+ const ok: SpanMetrics = await fetchSpanMetrics({
1023
+ primaryEntityId: new ObjectID(ENTITY_ID),
1024
+ start: START,
1025
+ end: END,
1026
+ });
1027
+
1028
+ expect(ok.failed).toBeUndefined();
1029
+
1030
+ projectIdMock.mockReturnValue(null);
1031
+
1032
+ const noProject: SpanMetrics = await fetchSpanMetrics({
1033
+ primaryEntityId: new ObjectID(ENTITY_ID),
1034
+ start: START,
1035
+ end: END,
1036
+ });
1037
+
1038
+ expect(noProject.failed).toBeUndefined();
1039
+ });
1040
+ });
1041
+
1042
+ describe("fetchWebVitals", () => {
1043
+ const LCP: WebVitalDefinition = WebVitalDefinitions.find(
1044
+ (d: WebVitalDefinition): boolean => {
1045
+ return d.key === "lcp";
1046
+ },
1047
+ )!;
1048
+
1049
+ function metricNames(): Array<string> {
1050
+ return aggregateRequests().map((r: AggregateRequest): string => {
1051
+ return String(r.aggregateBy.query["name"]);
1052
+ });
1053
+ }
1054
+
1055
+ test("every vital carries the description, label, unit and thresholds of its definition", async () => {
1056
+ aggregateMock.mockResolvedValue({ data: [] });
1057
+
1058
+ const vitals: Array<WebVital> = await fetchWebVitals({
1059
+ primaryEntityId: new ObjectID(ENTITY_ID),
1060
+ start: START,
1061
+ end: END,
1062
+ });
1063
+
1064
+ expect(vitals).toHaveLength(WebVitalDefinitions.length);
1065
+ vitals.forEach((v: WebVital, i: number) => {
1066
+ const d: WebVitalDefinition = WebVitalDefinitions[i]!;
1067
+
1068
+ expect(v).toEqual({
1069
+ key: d.key,
1070
+ label: d.label,
1071
+ description: d.description,
1072
+ value: null,
1073
+ unit: d.unit,
1074
+ thresholds: d.thresholds,
1075
+ });
1076
+ expect(v.description.length).toBeGreaterThan(20);
1077
+ });
1078
+ });
1079
+
1080
+ test("probes each vital's names in order and stops at the first that reports", async () => {
1081
+ const second: string = LCP.names[1]!;
1082
+
1083
+ aggregateMock.mockImplementation(async (...args: Array<unknown>) => {
1084
+ const name: unknown = (args[0] as AggregateRequest).aggregateBy.query[
1085
+ "name"
1086
+ ];
1087
+
1088
+ if (name === second) {
1089
+ return {
1090
+ data: [
1091
+ { time: T0, value: 1000 },
1092
+ { time: T1, value: 3000 },
1093
+ ],
1094
+ };
1095
+ }
1096
+
1097
+ return { data: [] };
1098
+ });
1099
+
1100
+ const vitals: Array<WebVital> = await fetchWebVitals({
1101
+ primaryEntityId: new ObjectID(ENTITY_ID),
1102
+ start: START,
1103
+ end: END,
1104
+ });
1105
+
1106
+ const lcp: WebVital = vitals.find((v: WebVital): boolean => {
1107
+ return v.key === "lcp";
1108
+ })!;
1109
+
1110
+ // The average of the per-interval averages.
1111
+ expect(lcp.value).toBe(2000);
1112
+ expect(lcp.description).toBe(LCP.description);
1113
+
1114
+ const asked: Array<string> = metricNames();
1115
+ expect(asked).toContain(LCP.names[0]);
1116
+ expect(asked).toContain(second);
1117
+ for (const later of LCP.names.slice(2)) {
1118
+ expect(asked).not.toContain(later);
1119
+ }
1120
+ });
1121
+
1122
+ test("averages, scoped to the entity and window", async () => {
1123
+ aggregateMock.mockResolvedValue({ data: [] });
1124
+
1125
+ await fetchWebVitals({
1126
+ primaryEntityId: new ObjectID(ENTITY_ID),
1127
+ start: START,
1128
+ end: END,
1129
+ });
1130
+
1131
+ for (const r of aggregateRequests()) {
1132
+ expect(r.aggregateBy.aggregationType).toBe(AggregationType.Avg);
1133
+ expect(String(r.aggregateBy.query["primaryEntityId"])).toBe(ENTITY_ID);
1134
+ expect(r.aggregateBy.startTimestamp).toBe(START);
1135
+ expect(r.aggregateBy.endTimestamp).toBe(END);
1136
+ }
1137
+ });
1138
+ });
1139
+
1140
+ describe("telemetryFormat", () => {
1141
+ test.each([
1142
+ [null, "—"],
1143
+ [Number.NaN, "—"],
1144
+ [Number.POSITIVE_INFINITY, "—"],
1145
+ [0, "0"],
1146
+ [7.4, "7"],
1147
+ [999, "999"],
1148
+ [1000, "1k"],
1149
+ [1234, "1.2k"],
1150
+ [45_600, "45.6k"],
1151
+ [1_000_000, "1M"],
1152
+ [2_450_000, "2.5M"],
1153
+ ])("formatCompact(%p) is %p", (n: number | null, text: string) => {
1154
+ expect(formatCompact(n)).toBe(text);
1155
+ });
1156
+
1157
+ test.each([
1158
+ [null, "—"],
1159
+ [Number.NaN, "—"],
1160
+ [0, "0.0%"],
1161
+ [2, "2.0%"],
1162
+ [12.345, "12.3%"],
1163
+ [100, "100.0%"],
1164
+ [250, "250.0%"],
1165
+ ])("formatPercent(%p) is %p", (n: number | null, text: string) => {
1166
+ expect(formatPercent(n)).toBe(text);
1167
+ });
1168
+
1169
+ test.each([
1170
+ [null, "—"],
1171
+ [Number.NaN, "—"],
1172
+ [0.25, "250 µs"],
1173
+ [0, "0 µs"],
1174
+ [5, "5.0 ms"],
1175
+ [9.94, "9.9 ms"],
1176
+ [50, "50 ms"],
1177
+ [999, "999 ms"],
1178
+ [1000, "1.00 s"],
1179
+ [2340, "2.34 s"],
1180
+ [65_000, "65.00 s"],
1181
+ ])("formatDurationMs(%p) is %p", (ms: number | null, text: string) => {
1182
+ expect(formatDurationMs(ms)).toBe(text);
1183
+ });
1184
+
1185
+ test.each([
1186
+ [null, "—"],
1187
+ [Number.NaN, "—"],
1188
+ [0, "0.0 B"],
1189
+ [512, "512 B"],
1190
+ [1024, "1.0 KiB"],
1191
+ [1536, "1.5 KiB"],
1192
+ [10 * 1024, "10 KiB"],
1193
+ [1024 * 1024, "1.0 MiB"],
1194
+ [5 * 1024 * 1024 * 1024, "5.0 GiB"],
1195
+ [3 * 1024 ** 4, "3.0 TiB"],
1196
+ [2048 * 1024 ** 4, "2048 TiB"],
1197
+ ])("formatBytes(%p) is %p", (bytes: number | null, text: string) => {
1198
+ expect(formatBytes(bytes)).toBe(text);
1199
+ });
1200
+
1201
+ test("telemetryMetrics re-exports the very same formatter functions", () => {
1202
+ expect(reexportedFormatCompact).toBe(formatCompact);
1203
+ expect(reexportedFormatPercent).toBe(formatPercent);
1204
+ expect(reexportedFormatDurationMs).toBe(formatDurationMs);
1205
+ expect(reexportedFormatBytes).toBe(formatBytes);
1206
+ });
1207
+ });