@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
@@ -1,6 +1,7 @@
1
1
  import "@testing-library/jest-dom";
2
2
  import {
3
3
  act,
4
+ cleanup,
4
5
  fireEvent,
5
6
  render,
6
7
  screen,
@@ -26,16 +27,19 @@ import Log from "../../../Models/AnalyticsModels/Log";
26
27
  import Span from "../../../Models/AnalyticsModels/Span";
27
28
  import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
28
29
  import ListResult from "../../../Types/BaseDatabase/ListResult";
30
+ import Includes from "../../../Types/BaseDatabase/Includes";
29
31
  import LogSeverity from "../../../Types/Log/LogSeverity";
30
32
  import ReplayRail, {
31
33
  ReplayRailHandle,
32
34
  ReplayRailProps,
33
35
  } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Rail/ReplayRail";
34
36
  import {
37
+ REPLAY_BACKEND_SIGNALS_LIVE_REFRESH_MS,
35
38
  ReplayBackendListRequest,
36
39
  ReplayBackendSignalsStore,
37
40
  } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Rail/ReplayBackendSignals";
38
41
  import {
42
+ ReplayBackendSignalsSlot,
39
43
  ReplayRailTabId,
40
44
  ReplaySignal,
41
45
  ReplaySignalKind,
@@ -43,6 +47,7 @@ import {
43
47
  } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Rail/ReplaySignalTypes";
44
48
  import {
45
49
  REPLAY_CLICK_EVENTS_CAPABILITY,
50
+ REPLAY_RAIL_SAME_ORIGIN_SWITCH_CAVEAT,
46
51
  getRailEmptyCopy,
47
52
  } from "../../../../App/FeatureSet/Dashboard/src/Components/SessionReplay/Rail/ReplayRailEmptyCopy";
48
53
  import { SESSION_REPLAY_RECORDER_CAPABILITIES } from "../../../Types/Rum/SessionReplay";
@@ -313,6 +318,86 @@ function makeStore(options: {
313
318
  };
314
319
  }
315
320
 
321
+ /*
322
+ * A store whose log reads answer by key: read A (by session id), read B
323
+ * (by trace id, when the grouped span read or the header named ids) and
324
+ * the grouped span read each have their own answer, so a test can fail B
325
+ * alone or cap the id set.
326
+ */
327
+ function makeTraceJoinStore(options: {
328
+ sessionLogs?: Array<Log>;
329
+ traceLogs?: Array<Log>;
330
+ rejectTraceRead?: unknown;
331
+ sessionTraceIds?: Array<string>;
332
+ sessionTraceIdsHasMore?: boolean;
333
+ }): { store: ReplayBackendSignalsStore; calls: Array<string> } {
334
+ const calls: Array<string> = [];
335
+
336
+ const fetchList: <T extends AnalyticsBaseModel>(
337
+ request: ReplayBackendListRequest<T>,
338
+ ) => Promise<ListResult<T>> = async <T extends AnalyticsBaseModel>(
339
+ request: ReplayBackendListRequest<T>,
340
+ ): Promise<ListResult<T>> => {
341
+ const query: Record<string, unknown> = request.query as Record<
342
+ string,
343
+ unknown
344
+ >;
345
+ const isLogRead: boolean = (request.modelType as unknown) === Log;
346
+
347
+ if (request.groupBy !== undefined) {
348
+ calls.push("span-ids");
349
+
350
+ const rows: Array<Span> = (options.sessionTraceIds || []).map(
351
+ (traceId: string): Span => {
352
+ const row: Span = new Span();
353
+
354
+ row.traceId = traceId;
355
+
356
+ return row;
357
+ },
358
+ );
359
+
360
+ return {
361
+ ...listResult(rows as Array<AnalyticsBaseModel> as Array<T>),
362
+ hasMore: options.sessionTraceIdsHasMore === true,
363
+ };
364
+ }
365
+
366
+ if (query["traceId"] instanceof Includes) {
367
+ calls.push(`${isLogRead ? "log" : "other"}-by-trace`);
368
+
369
+ if (options.rejectTraceRead !== undefined) {
370
+ throw options.rejectTraceRead;
371
+ }
372
+
373
+ return listResult(
374
+ (isLogRead
375
+ ? options.traceLogs || []
376
+ : []) as Array<AnalyticsBaseModel> as Array<T>,
377
+ );
378
+ }
379
+
380
+ calls.push(`${isLogRead ? "log" : "other"}-by-session`);
381
+
382
+ return listResult(
383
+ (isLogRead
384
+ ? options.sessionLogs || []
385
+ : []) as Array<AnalyticsBaseModel> as Array<T>,
386
+ );
387
+ };
388
+
389
+ return {
390
+ store: new ReplayBackendSignalsStore({
391
+ sessionId: SESSION_ID,
392
+ startTimeUnixMs: START_UNIX_MS,
393
+ endTimeUnixMs: START_UNIX_MS + 60_000,
394
+ isFinalized: true,
395
+ fetchList: fetchList,
396
+ }),
397
+ calls: calls,
398
+ };
399
+ }
400
+
316
401
  function makeLog(id: string, atMs: number, body: string): Log {
317
402
  const log: Log = new Log();
318
403
 
@@ -1119,7 +1204,11 @@ describe("ReplayRail telemetry tabs", () => {
1119
1204
  expect(screen.getByTestId("rail-tab-logs")).toHaveTextContent("Logs1");
1120
1205
  });
1121
1206
 
1122
- expect(calls).toEqual(["log"]);
1207
+ /*
1208
+ * The log read, then the grouped span read that supplies the
1209
+ * session's trace ids; it found none, so no trace-id log read followed.
1210
+ */
1211
+ expect(calls).toEqual(["log", "span"]);
1123
1212
  expect(rows()).toHaveLength(1);
1124
1213
  expect(rows()[0]).toHaveTextContent("[ERROR] charge failed");
1125
1214
  /* Server-stamped rows carry the alignment note in the header. */
@@ -1129,7 +1218,7 @@ describe("ReplayRail telemetry tabs", () => {
1129
1218
 
1130
1219
  /* Switching back does not refetch a finalized session. */
1131
1220
  fireEvent.click(screen.getByTestId("rail-tab-all"));
1132
- expect(calls).toEqual(["log"]);
1221
+ expect(calls).toEqual(["log", "span"]);
1133
1222
  expect(result.tabs).toEqual(["logs", "all"]);
1134
1223
  });
1135
1224
 
@@ -1163,8 +1252,9 @@ describe("ReplayRail telemetry tabs", () => {
1163
1252
 
1164
1253
  renderRail({ backendStore: store, isExpiredFootage: true, signals: [] });
1165
1254
 
1255
+ /* Three row reads plus one grouped span read shared by logs and exceptions. */
1166
1256
  await waitFor((): void => {
1167
- expect(calls.length).toBe(3);
1257
+ expect(calls.length).toBe(4);
1168
1258
  });
1169
1259
 
1170
1260
  expect(new Set(calls)).toEqual(new Set(["log", "span", "exception"]));
@@ -1189,9 +1279,266 @@ describe("ReplayRail telemetry tabs", () => {
1189
1279
 
1190
1280
  fireEvent.click(screen.getByText("Retry"));
1191
1281
 
1282
+ /* The retry reuses the finalized session's grouped span read. */
1283
+ await waitFor((): void => {
1284
+ expect(calls).toEqual(["log", "span", "log"]);
1285
+ });
1286
+ });
1287
+
1288
+ /*
1289
+ * Review round 2: the trace-id read is the only read that finds OTLP
1290
+ * logs, and its failure used to publish a ready slot - "No backend logs
1291
+ * matched this session", no Retry, until a page reload.
1292
+ */
1293
+ it("keeps the session read's rows beside a Retry when only the trace-id read fails", async () => {
1294
+ const { store, calls } = makeTraceJoinStore({
1295
+ sessionLogs: [makeLog("aaaaaaaaaaaaaaaaaaaaaaaa", 5000, "stamped log")],
1296
+ sessionTraceIds: [TRACE_ID],
1297
+ rejectTraceRead: new HTTPErrorResponse(502, { message: "bad" }, {}),
1298
+ });
1299
+
1300
+ renderRail({ backendStore: store, activeTab: "logs" });
1301
+
1302
+ await waitFor((): void => {
1303
+ expect(store.getSnapshot().slots.log.status).toBe("error");
1304
+ });
1305
+
1306
+ expect(rows()).toHaveLength(1);
1307
+ expect(rows()[0]).toHaveTextContent("stamped log");
1308
+ expect(
1309
+ screen.getByText(
1310
+ "Some linked backend logs did not load (HTTP 502). Retry.",
1311
+ ),
1312
+ ).toBeInTheDocument();
1313
+ expect(screen.queryByTestId("rail-empty")).not.toBeInTheDocument();
1314
+
1315
+ fireEvent.click(screen.getByText("Retry"));
1316
+
1317
+ await waitFor((): void => {
1318
+ expect(
1319
+ calls.filter((call: string): boolean => {
1320
+ return call === "log-by-trace";
1321
+ }),
1322
+ ).toHaveLength(2);
1323
+ });
1324
+ });
1325
+
1326
+ it("says the logs did not load, with a Retry, when the session read is empty and the trace-id read fails", async () => {
1327
+ const { store } = makeTraceJoinStore({
1328
+ sessionTraceIds: [TRACE_ID],
1329
+ rejectTraceRead: new HTTPErrorResponse(504, { message: "timeout" }, {}),
1330
+ });
1331
+
1332
+ renderRail({ backendStore: store, activeTab: "logs" });
1333
+
1334
+ await waitFor((): void => {
1335
+ expect(store.getSnapshot().slots.log.status).toBe("error");
1336
+ });
1337
+
1338
+ const empty: HTMLElement = screen.getByTestId("rail-empty");
1339
+
1340
+ expect(empty).toHaveTextContent("Backend logs did not load");
1341
+ expect(empty).toHaveTextContent(
1342
+ "Some linked backend logs did not load (HTTP 504). Retry.",
1343
+ );
1344
+ expect(empty).not.toHaveTextContent("No backend logs matched");
1345
+ expect(within(empty).getByText("Retry")).toBeInTheDocument();
1346
+ });
1347
+
1348
+ /*
1349
+ * Review round 2: a session with more traces than one trace-id read
1350
+ * names used to show an exact count and no notice.
1351
+ */
1352
+ it("marks the count and names the cause when the session's trace ids were capped", async () => {
1353
+ const { store } = makeTraceJoinStore({
1354
+ traceLogs: [makeLog("bbbbbbbbbbbbbbbbbbbbbbbb", 5000, "joined log")],
1355
+ sessionTraceIds: [TRACE_ID],
1356
+ sessionTraceIdsHasMore: true,
1357
+ });
1358
+
1359
+ renderRail({ backendStore: store, activeTab: "logs", scope: "session" });
1360
+
1361
+ await waitFor((): void => {
1362
+ expect(screen.getByTestId("rail-tab-logs")).toHaveTextContent("Logs1+");
1363
+ });
1364
+
1365
+ const notice: HTMLElement = screen.getByTestId("rail-slot-truncated");
1366
+
1367
+ expect(notice).toHaveTextContent(
1368
+ "This session has more traces than one fetch names, so the rows of some of its traces were not fetched; the scope defaults to ±30s around the playhead",
1369
+ );
1370
+ expect(notice).not.toHaveTextContent("Only the first");
1371
+ });
1372
+
1373
+ it("keeps the row-cap wording for a slot that hit the row cap", async () => {
1374
+ const logs: Array<Log> = [];
1375
+
1376
+ for (let i: number = 0; i < 500; i++) {
1377
+ logs.push(
1378
+ makeLog(i.toString(16).padStart(24, "0"), 1000 + i, `row ${i}`),
1379
+ );
1380
+ }
1381
+
1382
+ const { store } = makeStore({ logs: logs });
1383
+
1384
+ renderRail({ backendStore: store, activeTab: "logs", scope: "session" });
1385
+
1192
1386
  await waitFor((): void => {
1193
- expect(calls).toEqual(["log", "log"]);
1387
+ expect(screen.getByTestId("rail-slot-truncated")).toHaveTextContent(
1388
+ "Only the first 500 rows were fetched; the scope defaults to ±30s",
1389
+ );
1194
1390
  });
1391
+
1392
+ expect(screen.getByTestId("rail-tab-badge-logs")).toHaveTextContent("500+");
1393
+ expect(screen.getByTestId("rail-tab-badge-logs")).toHaveAttribute(
1394
+ "title",
1395
+ "The first 500 rows; the fetch was capped",
1396
+ );
1397
+ });
1398
+
1399
+ /*
1400
+ * Review round 3: the notice named the id cap, but the badge's tooltip
1401
+ * still described the row cap ("The first N rows").
1402
+ */
1403
+ it("gives an id-capped tab's badge the notice's cause, not the row cap's", async () => {
1404
+ const { store } = makeTraceJoinStore({
1405
+ traceLogs: [makeLog("bbbbbbbbbbbbbbbbbbbbbbbb", 5000, "joined log")],
1406
+ sessionTraceIds: [TRACE_ID],
1407
+ sessionTraceIdsHasMore: true,
1408
+ });
1409
+
1410
+ renderRail({ backendStore: store, activeTab: "logs", scope: "session" });
1411
+
1412
+ await waitFor((): void => {
1413
+ expect(screen.getByTestId("rail-tab-logs")).toHaveTextContent("Logs1+");
1414
+ });
1415
+
1416
+ const title: string =
1417
+ screen.getByTestId("rail-tab-badge-logs").getAttribute("title") || "";
1418
+
1419
+ expect(title).toContain(
1420
+ "this session has more traces than one fetch names, so the rows of some of its traces were not fetched",
1421
+ );
1422
+ expect(title).not.toContain("The first");
1423
+ });
1424
+
1425
+ /*
1426
+ * Review round 3: a failed trace-id read left an error slot whose count
1427
+ * the badge still showed as the whole answer - "Logs 0" when nothing
1428
+ * loaded (the backend "has no logs"), "Logs 1" when only read A did.
1429
+ */
1430
+ it("badges a partly failed fetch's rows as a lower bound", async () => {
1431
+ const { store } = makeTraceJoinStore({
1432
+ sessionLogs: [makeLog("aaaaaaaaaaaaaaaaaaaaaaaa", 5000, "stamped log")],
1433
+ sessionTraceIds: [TRACE_ID],
1434
+ rejectTraceRead: new HTTPErrorResponse(502, { message: "bad" }, {}),
1435
+ });
1436
+
1437
+ renderRail({ backendStore: store, activeTab: "all" });
1438
+
1439
+ await act(async (): Promise<void> => {
1440
+ await store.load("log");
1441
+ });
1442
+
1443
+ expect(store.getSnapshot().slots.log.status).toBe("error");
1444
+ expect(screen.getByTestId("rail-tab-logs")).toHaveTextContent("Logs1+");
1445
+ expect(screen.getByTestId("rail-tab-badge-logs")).toHaveAttribute(
1446
+ "title",
1447
+ "1 loaded; some rows did not load",
1448
+ );
1449
+ });
1450
+
1451
+ it("claims no count when a partly failed fetch loaded nothing", async () => {
1452
+ const { store } = makeTraceJoinStore({
1453
+ sessionTraceIds: [TRACE_ID],
1454
+ rejectTraceRead: new HTTPErrorResponse(504, { message: "timeout" }, {}),
1455
+ });
1456
+
1457
+ renderRail({ backendStore: store, activeTab: "all" });
1458
+
1459
+ await act(async (): Promise<void> => {
1460
+ await store.load("log");
1461
+ });
1462
+
1463
+ expect(store.getSnapshot().slots.log.status).toBe("error");
1464
+ expect(store.getSnapshot().slots.log.rowCount).toBe(0);
1465
+ expect(screen.getByTestId("rail-tab-logs")).toHaveTextContent(/^Logs$/);
1466
+ expect(screen.queryByTestId("rail-tab-badge-logs")).not.toBeInTheDocument();
1467
+ });
1468
+
1469
+ /*
1470
+ * Review round 3: the rail ticked refreshIfDue every 60 s against a 60 s
1471
+ * age, so a tab opened between ticks - or any read latency - made the
1472
+ * next tick miss and the refresh ran every two minutes.
1473
+ */
1474
+ it("re-reads a live session's open tab about a minute after the first read, even when it was opened between ticks and reads take time", async () => {
1475
+ jest.useFakeTimers({
1476
+ doNotFake: ["performance", "hrtime", "nextTick", "queueMicrotask"],
1477
+ });
1478
+
1479
+ try {
1480
+ const t0: number = Date.now();
1481
+ const reads: Array<number> = [];
1482
+ const store: ReplayBackendSignalsStore = new ReplayBackendSignalsStore({
1483
+ sessionId: SESSION_ID,
1484
+ startTimeUnixMs: START_UNIX_MS,
1485
+ endTimeUnixMs: null,
1486
+ isFinalized: false,
1487
+ isRecordingLive: true,
1488
+ fetchList: <T extends AnalyticsBaseModel>(
1489
+ _request: ReplayBackendListRequest<T>,
1490
+ ): Promise<ListResult<T>> => {
1491
+ reads.push(Date.now() - t0);
1492
+
1493
+ return new Promise<ListResult<T>>(
1494
+ (resolve: (value: ListResult<T>) => void): void => {
1495
+ setTimeout((): void => {
1496
+ resolve(listResult<T>([]));
1497
+ }, 300);
1498
+ },
1499
+ );
1500
+ },
1501
+ });
1502
+
1503
+ renderRail({ backendStore: store, isFinalized: false });
1504
+
1505
+ const advance: (ms: number) => Promise<void> = async (
1506
+ ms: number,
1507
+ ): Promise<void> => {
1508
+ for (let elapsed: number = 0; elapsed < ms; elapsed += 100) {
1509
+ await act(async (): Promise<void> => {
1510
+ jest.advanceTimersByTime(100);
1511
+ });
1512
+ }
1513
+ };
1514
+
1515
+ /* Opened 7 s in: between two ticks of any interval started at mount. */
1516
+ await advance(7_000);
1517
+ fireEvent.click(screen.getByTestId("rail-tab-traces"));
1518
+ await advance(1_000);
1519
+
1520
+ expect(reads).toHaveLength(1);
1521
+
1522
+ /*
1523
+ * One interval plus a 5 s tick plus the latency, spelled out so a
1524
+ * longer tick cannot loosen it. A rail ticking every 60 s read again
1525
+ * only at 120 s here.
1526
+ */
1527
+ const oneRefreshLateMs: number =
1528
+ REPLAY_BACKEND_SIGNALS_LIVE_REFRESH_MS + 5_000 + 300;
1529
+
1530
+ await advance(oneRefreshLateMs);
1531
+
1532
+ expect(reads).toHaveLength(2);
1533
+ expect((reads[1] as number) - (reads[0] as number)).toBeLessThanOrEqual(
1534
+ oneRefreshLateMs,
1535
+ );
1536
+
1537
+ cleanup();
1538
+ store.dispose();
1539
+ } finally {
1540
+ jest.useRealTimers();
1541
+ }
1195
1542
  });
1196
1543
  });
1197
1544
 
@@ -1200,8 +1547,8 @@ describe("ReplayRail empty copy", () => {
1200
1547
  const cases: Array<[ReplayRailTabId, string]> = [
1201
1548
  ["console", "No console output was recorded in the loaded footage"],
1202
1549
  ["network", "No requests were recorded in the loaded footage"],
1203
- ["logs", "No backend logs carried this session's id"],
1204
- ["traces", "No backend spans carried this session's id"],
1550
+ ["logs", "No backend logs matched this session"],
1551
+ ["traces", "No backend traces matched this session"],
1205
1552
  ];
1206
1553
 
1207
1554
  for (const [tabId, expected] of cases) {
@@ -1232,15 +1579,273 @@ describe("ReplayRail empty copy", () => {
1232
1579
  }
1233
1580
  });
1234
1581
 
1235
- it("ships the session.id snippet with the Logs and Traces copy", () => {
1236
- renderRail({ signals: [], activeTab: "logs", backendStore: null });
1582
+ /*
1583
+ * Issue #3979: backend rows reach a recording with no customer code. The
1584
+ * recorder carries the session id on same-origin requests (tracestate),
1585
+ * ingest stamps it and the rail joins logs by trace id. The empty tabs
1586
+ * used to prescribe an onSessionChange snippet plus server-side work;
1587
+ * they now explain the automatic path and name the one step that is
1588
+ * still the customer's: listing an API on another origin.
1589
+ */
1590
+ for (const tabId of ["logs", "traces"] as Array<ReplayRailTabId>) {
1591
+ for (const isMobileReplay of [undefined, false]) {
1592
+ it(`explains the automatic link and the cross-origin step on ${tabId} of a web recording (isMobileReplay ${String(isMobileReplay)}), with no manual snippet`, () => {
1593
+ renderRail({
1594
+ signals: [],
1595
+ activeTab: tabId,
1596
+ backendStore: null,
1597
+ isMobileReplay: isMobileReplay,
1598
+ });
1599
+
1600
+ const empty: HTMLElement = screen.getByTestId("rail-empty");
1601
+
1602
+ expect(empty).toHaveAttribute("data-tab", tabId);
1603
+ expect(empty).toHaveTextContent("own origin");
1604
+ expect(empty).toHaveTextContent("automatically");
1605
+ expect(empty).toHaveTextContent("Trace propagation origins");
1606
+ expect(empty).toHaveTextContent("Access-Control-Allow-Headers");
1607
+ expect(empty).toHaveTextContent("by trace id");
1608
+
1609
+ /* Nothing to paste: no code block, no manual hook, no server step. */
1610
+ expect(empty.querySelector("pre")).toBeNull();
1611
+ expect(empty).not.toHaveTextContent("onSessionChange");
1612
+ expect(empty).not.toHaveTextContent("resource.attributes");
1613
+ expect(empty).not.toHaveTextContent("baggage");
1614
+ expect(empty).not.toHaveTextContent(/server side/i);
1615
+ expect(empty).not.toHaveTextContent("React Native");
1616
+ });
1617
+ }
1618
+
1619
+ /*
1620
+ * Review round 2: the application can turn the same-origin link off
1621
+ * (the documented fix for a redirect that drops the headers); the copy
1622
+ * must not promise a link the switch removed.
1623
+ */
1624
+ it(`qualifies the automatic link on ${tabId} with the Same-origin trace propagation switch`, () => {
1625
+ renderRail({ signals: [], activeTab: tabId, backendStore: null });
1237
1626
 
1238
- expect(screen.getByTestId("rail-empty")).toHaveTextContent(
1239
- "OneUptimeReplay.onSessionChange",
1627
+ const empty: HTMLElement = screen.getByTestId("rail-empty");
1628
+
1629
+ expect(REPLAY_RAIL_SAME_ORIGIN_SWITCH_CAVEAT).toBe(
1630
+ "unless Same-origin trace propagation is turned off in the Replay Policy",
1631
+ );
1632
+ expect(empty).toHaveTextContent(
1633
+ "automatically, unless Same-origin trace propagation is turned off in the Replay Policy, so",
1634
+ );
1635
+ });
1636
+ }
1637
+
1638
+ /*
1639
+ * Review round 2: the React Native SDK patches no fetch or XHR and
1640
+ * ignores Trace propagation origins, so the web copy told a mobile
1641
+ * customer that linking was automatic and gave a step that does
1642
+ * nothing. onSessionChange is the only link there is.
1643
+ */
1644
+ it("gives a React Native recording the onSessionChange step and none of the web copy", () => {
1645
+ const readySlot: Parameters<typeof getRailEmptyCopy>[0]["slot"] = {
1646
+ status: "ready",
1647
+ rowCount: 0,
1648
+ isTruncated: false,
1649
+ fetchedAtUnixMs: START_UNIX_MS,
1650
+ };
1651
+
1652
+ for (const tabId of ["logs", "traces"] as Array<ReplayRailTabId>) {
1653
+ for (const isExpiredFootage of [false, true]) {
1654
+ const copy: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1655
+ tabId: tabId,
1656
+ isFiltering: false,
1657
+ hadRowsBeforeFilter: false,
1658
+ slot: readySlot,
1659
+ isExpiredFootage: isExpiredFootage,
1660
+ recorderCapabilities: null,
1661
+ hasLoadedFootage: true,
1662
+ isMobileReplay: true,
1663
+ });
1664
+ const text: string = `${copy.title} ${copy.detail}`;
1665
+
1666
+ expect(copy.title).toBe(
1667
+ tabId === "logs"
1668
+ ? "No backend logs matched this session"
1669
+ : "No backend traces matched this session",
1670
+ );
1671
+ expect(copy.detail).toContain(
1672
+ "The React Native SDK adds nothing to your app's requests, so nothing links on its own.",
1673
+ );
1674
+ expect(copy.detail).toContain("OneUptimeReplay.onSessionChange()");
1675
+ expect(copy.detail).toContain("session.id");
1676
+ expect(copy.detail).toContain("update it whenever the session rotates");
1677
+ expect(copy.detail).toContain("on the session clock");
1678
+ expect(text).not.toContain("own origin");
1679
+ expect(text).not.toContain("Trace propagation origins");
1680
+ expect(text).not.toContain("Same-origin trace propagation");
1681
+ expect(text).not.toContain("automatically");
1682
+ expect(text).not.toContain("tracestate");
1683
+ expect(copy.snippet).toBeUndefined();
1684
+ }
1685
+ }
1686
+
1687
+ const logs: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1688
+ tabId: "logs",
1689
+ isFiltering: false,
1690
+ hadRowsBeforeFilter: false,
1691
+ slot: readySlot,
1692
+ isExpiredFootage: false,
1693
+ recorderCapabilities: null,
1694
+ hasLoadedFootage: true,
1695
+ isMobileReplay: true,
1696
+ });
1697
+
1698
+ expect(logs.detail).toBe(
1699
+ "The React Native SDK adds nothing to your app's requests, so nothing links on its own. Stamp session.id on your OpenTelemetry logs and spans with OneUptimeReplay.onSessionChange() and update it whenever the session rotates; those rows then land here on the session clock.",
1240
1700
  );
1701
+
1702
+ /* Expired errors: no claim about "this session's requests" either. */
1703
+ const errors: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1704
+ tabId: "errors",
1705
+ isFiltering: false,
1706
+ hadRowsBeforeFilter: false,
1707
+ slot: readySlot,
1708
+ isExpiredFootage: true,
1709
+ recorderCapabilities: null,
1710
+ hasLoadedFootage: true,
1711
+ isMobileReplay: true,
1712
+ });
1713
+
1714
+ expect(errors.detail).not.toContain("requests");
1715
+ expect(errors.detail).toContain("stamped with this session's id");
1716
+ });
1717
+
1718
+ it("renders the React Native copy on the rail when the player says the recording is mobile", () => {
1719
+ for (const tabId of ["logs", "traces"] as Array<ReplayRailTabId>) {
1720
+ const view: RenderResult = renderRail({
1721
+ signals: [],
1722
+ activeTab: tabId,
1723
+ backendStore: null,
1724
+ isMobileReplay: true,
1725
+ });
1726
+
1727
+ const empty: HTMLElement = screen.getByTestId("rail-empty");
1728
+
1729
+ expect(empty).toHaveAttribute("data-tab", tabId);
1730
+ expect(empty).toHaveTextContent("React Native SDK adds nothing");
1731
+ expect(empty).toHaveTextContent("OneUptimeReplay.onSessionChange()");
1732
+ expect(empty).not.toHaveTextContent("own origin");
1733
+ expect(empty).not.toHaveTextContent("Trace propagation origins");
1734
+
1735
+ /* The same rail flips back to the web copy for a web recording. */
1736
+ view.rerender({ isMobileReplay: false });
1737
+ expect(screen.getByTestId("rail-empty")).toHaveTextContent("own origin");
1738
+ expect(screen.getByTestId("rail-empty")).not.toHaveTextContent(
1739
+ "onSessionChange",
1740
+ );
1741
+
1742
+ cleanup();
1743
+ }
1744
+ });
1745
+
1746
+ it("says tracestate carries the session id on Traces, and a trace-id join on Logs", () => {
1747
+ const readySlot: Parameters<typeof getRailEmptyCopy>[0]["slot"] = {
1748
+ status: "ready",
1749
+ rowCount: 0,
1750
+ isTruncated: false,
1751
+ fetchedAtUnixMs: START_UNIX_MS,
1752
+ };
1753
+
1754
+ const traces: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1755
+ tabId: "traces",
1756
+ isFiltering: false,
1757
+ hadRowsBeforeFilter: false,
1758
+ slot: readySlot,
1759
+ isExpiredFootage: false,
1760
+ recorderCapabilities: null,
1761
+ hasLoadedFootage: true,
1762
+ });
1763
+ const logs: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1764
+ tabId: "logs",
1765
+ isFiltering: false,
1766
+ hadRowsBeforeFilter: false,
1767
+ slot: readySlot,
1768
+ isExpiredFootage: true,
1769
+ recorderCapabilities: null,
1770
+ hasLoadedFootage: false,
1771
+ });
1772
+
1773
+ expect(traces.title).toBe("No backend traces matched this session");
1774
+ expect(traces.detail).toContain("traceparent");
1775
+ expect(traces.detail).toContain("tracestate");
1776
+ expect(traces.detail).toContain("W3C trace context");
1777
+ expect(traces.snippet).toBeUndefined();
1778
+
1779
+ /* Expired footage changes nothing: the telemetry is still there. */
1780
+ expect(logs.title).toBe("No backend logs matched this session");
1781
+ expect(logs.detail).toContain("by trace id");
1782
+ expect(logs.snippet).toBeUndefined();
1783
+ });
1784
+
1785
+ it("never prescribes the manual session.id wiring on any telemetry tab of a web recording, in any state", () => {
1786
+ const telemetryTabs: Array<ReplayRailTabId> = ["logs", "traces", "errors"];
1787
+ const slots: Array<Parameters<typeof getRailEmptyCopy>[0]["slot"]> = [
1788
+ {
1789
+ status: "idle",
1790
+ rowCount: null,
1791
+ isTruncated: false,
1792
+ fetchedAtUnixMs: null,
1793
+ },
1794
+ {
1795
+ status: "loading",
1796
+ rowCount: null,
1797
+ isTruncated: false,
1798
+ fetchedAtUnixMs: null,
1799
+ },
1800
+ {
1801
+ status: "ready",
1802
+ rowCount: 0,
1803
+ isTruncated: false,
1804
+ fetchedAtUnixMs: START_UNIX_MS,
1805
+ },
1806
+ ];
1807
+ const manualStep: RegExp =
1808
+ /onSessionChange|resource\.attributes|baggage|carried this session's id|carry this session's id/;
1809
+ const seen: Array<string> = [];
1810
+
1811
+ for (const tabId of telemetryTabs) {
1812
+ for (const slot of slots) {
1813
+ for (const isExpiredFootage of [false, true]) {
1814
+ /* Web only: unset and an explicit false are both a web recording. */
1815
+ for (const isMobileReplay of [undefined, false]) {
1816
+ const copy: ReturnType<typeof getRailEmptyCopy> = getRailEmptyCopy({
1817
+ tabId: tabId,
1818
+ isFiltering: false,
1819
+ hadRowsBeforeFilter: false,
1820
+ slot: slot,
1821
+ isExpiredFootage: isExpiredFootage,
1822
+ recorderCapabilities: null,
1823
+ hasLoadedFootage: true,
1824
+ isMobileReplay: isMobileReplay,
1825
+ });
1826
+ const text: string = [copy.title, copy.detail, copy.snippet || ""]
1827
+ .join(" ")
1828
+ .trim();
1829
+
1830
+ seen.push(text);
1831
+
1832
+ if (manualStep.test(text)) {
1833
+ throw new Error(
1834
+ `${tabId} (${slot?.status}, expired=${isExpiredFootage}) still prescribes a manual step: ${text}`,
1835
+ );
1836
+ }
1837
+ }
1838
+ }
1839
+ }
1840
+ }
1841
+
1842
+ /* 3 tabs x 3 slot states x 2 footage states x 2 web flags, none blank. */
1843
+ expect(seen).toHaveLength(36);
1241
1844
  expect(
1242
- screen.getByTestId("rail-empty").querySelector("pre"),
1243
- ).not.toBeNull();
1845
+ seen.every((text: string): boolean => {
1846
+ return text.length > 20;
1847
+ }),
1848
+ ).toBe(true);
1244
1849
  });
1245
1850
 
1246
1851
  /*
@@ -1415,6 +2020,112 @@ describe("ReplayRail pure helpers", () => {
1415
2020
  expect(byId["traces"]).toBeNull();
1416
2021
  expect(byId["errors"]).toBe(1);
1417
2022
  });
2023
+
2024
+ /* Review round 3: an error slot is a partial answer, never a claimed 0. */
2025
+ it("buildRailTabModels treats an error slot as partial: rows are a lower bound, none is no count", () => {
2026
+ const slot: (
2027
+ overrides: Partial<ReplayBackendSignalsSlot>,
2028
+ ) => ReplayBackendSignalsSlot = (
2029
+ overrides: Partial<ReplayBackendSignalsSlot>,
2030
+ ): ReplayBackendSignalsSlot => {
2031
+ return {
2032
+ status: "error",
2033
+ rowCount: 0,
2034
+ isTruncated: false,
2035
+ errorMessage: "Some linked rows did not load (HTTP 504). Retry.",
2036
+ fetchedAtUnixMs: START_UNIX_MS,
2037
+ ...overrides,
2038
+ };
2039
+ };
2040
+ const byId: (
2041
+ models: ReturnType<typeof buildRailTabModels>,
2042
+ id: ReplayRailTabId,
2043
+ ) => ReturnType<typeof buildRailTabModels>[number] = (
2044
+ models: ReturnType<typeof buildRailTabModels>,
2045
+ id: ReplayRailTabId,
2046
+ ): ReturnType<typeof buildRailTabModels>[number] => {
2047
+ return models.find((model: { id: ReplayRailTabId }): boolean => {
2048
+ return model.id === id;
2049
+ }) as ReturnType<typeof buildRailTabModels>[number];
2050
+ };
2051
+
2052
+ /* Nothing loaded at all: no number, on Logs, Traces and Errors alike. */
2053
+ const empty: ReturnType<typeof buildRailTabModels> = buildRailTabModels({
2054
+ signals: [],
2055
+ matchingSignals: null,
2056
+ slots: { log: slot({}), span: slot({}), exception: slot({}) },
2057
+ });
2058
+
2059
+ for (const id of ["logs", "traces", "errors"] as Array<ReplayRailTabId>) {
2060
+ expect(byId(empty, id).count).toBeNull();
2061
+ expect(byId(empty, id).isPartial).toBe(false);
2062
+ }
2063
+
2064
+ /* Rows that did load: counted, and flagged as a lower bound. */
2065
+ const partial: ReturnType<typeof buildRailTabModels> = buildRailTabModels({
2066
+ signals: [
2067
+ makeSignal("log", 1000, { id: "log:a", source: "telemetry" }),
2068
+ makeSignal("log", 2000, { id: "log:b", source: "telemetry" }),
2069
+ makeSignal("client-error", 3000, { id: "rec:0:9" }),
2070
+ ],
2071
+ matchingSignals: null,
2072
+ slots: {
2073
+ log: slot({ rowCount: 2 }),
2074
+ span: slot({ status: "ready" }),
2075
+ exception: slot({}),
2076
+ },
2077
+ });
2078
+
2079
+ expect(byId(partial, "logs").count).toBe(2);
2080
+ expect(byId(partial, "logs").isPartial).toBe(true);
2081
+ /* The client half is real; the failed server half makes it a lower bound. */
2082
+ expect(byId(partial, "errors").count).toBe(1);
2083
+ expect(byId(partial, "errors").isPartial).toBe(true);
2084
+ /* A read that succeeded may say 0. */
2085
+ expect(byId(partial, "traces").count).toBe(0);
2086
+ expect(byId(partial, "traces").isPartial).toBe(false);
2087
+ });
2088
+
2089
+ it("buildRailTabModels carries the id-cap flag so the badge can name its cause", () => {
2090
+ const models: ReturnType<typeof buildRailTabModels> = buildRailTabModels({
2091
+ signals: [makeSignal("log", 1000, { id: "log:a", source: "telemetry" })],
2092
+ matchingSignals: null,
2093
+ slots: {
2094
+ log: {
2095
+ status: "ready",
2096
+ rowCount: 1,
2097
+ isTruncated: true,
2098
+ isTraceIdSetCapped: true,
2099
+ fetchedAtUnixMs: START_UNIX_MS,
2100
+ },
2101
+ span: {
2102
+ status: "ready",
2103
+ rowCount: 0,
2104
+ isTruncated: true,
2105
+ fetchedAtUnixMs: START_UNIX_MS,
2106
+ },
2107
+ exception: {
2108
+ status: "idle",
2109
+ rowCount: null,
2110
+ isTruncated: false,
2111
+ fetchedAtUnixMs: null,
2112
+ },
2113
+ },
2114
+ });
2115
+ const logs: ReturnType<typeof buildRailTabModels>[number] | undefined =
2116
+ models.find((model: { id: ReplayRailTabId }): boolean => {
2117
+ return model.id === "logs";
2118
+ });
2119
+ const traces: ReturnType<typeof buildRailTabModels>[number] | undefined =
2120
+ models.find((model: { id: ReplayRailTabId }): boolean => {
2121
+ return model.id === "traces";
2122
+ });
2123
+
2124
+ expect(logs?.isTraceIdSetCapped).toBe(true);
2125
+ expect(logs?.isTruncated).toBe(true);
2126
+ expect(traces?.isTraceIdSetCapped).toBe(false);
2127
+ expect(traces?.isTruncated).toBe(true);
2128
+ });
1418
2129
  });
1419
2130
 
1420
2131
  /*