@oneuptime/common 14.0.9 → 14.0.10

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 (383) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +2 -0
  9. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  10. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  11. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  12. package/Models/DatabaseModels/RunnerJob.ts +144 -0
  13. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  14. package/Server/API/AutoRemediationAPI.ts +392 -1
  15. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  18. package/Server/Infrastructure/Semaphore.ts +22 -0
  19. package/Server/Middleware/TelemetryIngest.ts +15 -5
  20. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  21. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  22. package/Server/Services/CephClusterService.ts +109 -1
  23. package/Server/Services/DatabaseServerService.ts +116 -1
  24. package/Server/Services/DockerHostService.ts +109 -1
  25. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  26. package/Server/Services/HostService.ts +126 -4
  27. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  28. package/Server/Services/PodmanHostService.ts +109 -1
  29. package/Server/Services/ProxmoxClusterService.ts +110 -1
  30. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  31. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  32. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  33. package/Server/Services/RunnerJobService.ts +611 -4
  34. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  35. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  36. package/Server/Services/VMwareVCenterService.ts +110 -1
  37. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
  38. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
  39. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  40. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  41. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  42. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  43. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  44. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  45. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  46. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
  47. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  48. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  49. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
  50. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  51. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  52. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  53. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  54. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  55. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  56. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  57. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  60. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
  61. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  63. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  64. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  65. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  66. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  67. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
  68. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  69. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  70. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  71. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  72. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  73. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  74. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  75. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  76. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  77. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  78. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  79. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  80. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  81. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  82. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  83. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  84. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  85. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  86. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  87. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
  88. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
  89. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
  90. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  91. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  92. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  93. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  94. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  95. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  96. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  97. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  98. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  99. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  100. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  101. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  102. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  103. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  104. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  105. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
  106. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  107. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  108. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  109. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  110. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  111. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  112. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  113. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  114. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  115. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  116. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  117. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  118. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  119. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  120. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  121. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  122. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  123. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  124. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  125. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  126. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  127. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  128. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
  129. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  130. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  131. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  132. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  133. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  134. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  135. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  137. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  138. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  139. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  140. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  141. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  142. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  143. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  144. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  145. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  146. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  147. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  148. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  149. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  150. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  151. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  152. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  153. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  154. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  155. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  156. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  157. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  158. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  159. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  160. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  161. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  162. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  163. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  164. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  165. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  166. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  167. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  168. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  169. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  170. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  171. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  172. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  173. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  174. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  175. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
  176. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  177. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  178. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  179. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  180. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  181. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  182. package/Types/Runbook/RunbookStepType.ts +29 -0
  183. package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
  184. package/Utils/AI/InvestigationReport.ts +121 -15
  185. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  186. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  187. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  188. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  189. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  190. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  191. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  192. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  193. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  194. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  195. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  196. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  197. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  198. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  199. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  200. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  202. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  204. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  206. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  208. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  210. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  211. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  212. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  214. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  216. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  218. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  219. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  220. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  221. package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
  222. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  223. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  224. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  225. package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
  226. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  227. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  228. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  234. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  235. package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
  236. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  237. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  239. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  240. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  241. package/build/dist/Server/Services/CephClusterService.js +104 -0
  242. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  243. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  244. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  245. package/build/dist/Server/Services/DockerHostService.js +104 -0
  246. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  247. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  248. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  249. package/build/dist/Server/Services/HostService.js +114 -2
  250. package/build/dist/Server/Services/HostService.js.map +1 -1
  251. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  252. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  253. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  254. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  255. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  256. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  257. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  258. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  259. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  260. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  261. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  262. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  263. package/build/dist/Server/Services/RunnerJobService.js +344 -5
  264. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  265. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  266. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  267. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  268. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  269. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  270. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  271. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
  272. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  273. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
  274. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  275. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  276. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  277. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  278. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  279. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  280. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  281. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  282. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  283. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  284. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  285. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  286. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  287. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  288. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  289. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
  290. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  291. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  292. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  293. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  294. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  295. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
  296. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  297. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  298. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  299. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  300. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  301. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  302. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  303. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  304. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  314. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  315. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  316. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  317. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
  318. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  319. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  320. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  321. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  322. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  323. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  324. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  325. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  326. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  327. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  328. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  329. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  330. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  331. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  332. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  333. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  334. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  335. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
  336. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  337. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  338. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  339. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  340. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  341. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  342. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  343. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  344. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  345. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  346. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  347. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  348. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  349. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  350. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  351. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
  352. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  353. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  354. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  355. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  356. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  357. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  358. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  359. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  360. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  361. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  362. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  363. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  364. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  365. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  366. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  367. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  368. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  369. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  370. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  371. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  372. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  373. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  374. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  375. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  376. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  377. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  378. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  379. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  380. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  381. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  382. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  383. package/package.json +1 -1
@@ -0,0 +1,1647 @@
1
+ import { MetricPointType } from "../../../../Models/AnalyticsModels/Metric";
2
+ import GlobalConfig from "../../../../Models/DatabaseModels/GlobalConfig";
3
+ import MetricType from "../../../../Models/DatabaseModels/MetricType";
4
+ import Project from "../../../../Models/DatabaseModels/Project";
5
+ import RumApplication from "../../../../Models/DatabaseModels/RumApplication";
6
+ import GlobalConfigService from "../../../../Server/Services/GlobalConfigService";
7
+ import MetricService from "../../../../Server/Services/MetricService";
8
+ import ProjectService from "../../../../Server/Services/ProjectService";
9
+ import RumApplicationService from "../../../../Server/Services/RumApplicationService";
10
+ import FindBy from "../../../../Server/Types/Database/FindBy";
11
+ import logger from "../../../../Server/Utils/Logger";
12
+ import MonitorMetricUtil from "../../../../Server/Utils/Monitor/MonitorMetricUtil";
13
+ import SessionReplayBudgetMetrics, {
14
+ SESSION_REPLAY_BUDGET_SWEEP_MAX_CONSECUTIVE_FAILED_PAGES,
15
+ SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE,
16
+ SessionReplayBudgetApplication,
17
+ SessionReplayBudgetSweepSummary,
18
+ } from "../../../../Server/Utils/SessionReplay/SessionReplayBudgetMetrics";
19
+ import SessionReplayUsage from "../../../../Server/Utils/SessionReplay/SessionReplayUsage";
20
+ import TelemetryUtil from "../../../../Server/Utils/Telemetry/Telemetry";
21
+ import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
22
+ import OneUptimeDate from "../../../../Types/Date";
23
+ import Dictionary from "../../../../Types/Dictionary";
24
+ import { JSONObject } from "../../../../Types/JSON";
25
+ import ObjectID from "../../../../Types/ObjectID";
26
+ import SessionReplayBudgetMetricType from "../../../../Types/Rum/SessionReplayBudgetMetricType";
27
+ import ServiceType from "../../../../Types/Telemetry/ServiceType";
28
+ import SessionReplayBudgetMetricTypeUtil from "../../../../Utils/SessionReplay/SessionReplayBudgetMetricType";
29
+ import {
30
+ afterEach,
31
+ beforeEach,
32
+ describe,
33
+ expect,
34
+ jest,
35
+ test,
36
+ } from "@jest/globals";
37
+
38
+ /*
39
+ * SessionReplayBudgetMetrics turns the session replay byte-budget counters
40
+ * into the oneuptime.rum.session.replay.budget.* gauges monitors alert on.
41
+ * What is pinned here is everything a monitor depends on and no type system
42
+ * checks:
43
+ *
44
+ * - the percent is rounded DOWN, so ">= 80" and ">= 100" fire exactly
45
+ * when the gate's own comparison does - never a byte early or late;
46
+ * - rows are keyed to the RUM application, in the same shape as monitor
47
+ * metric rows; the project's daily value goes under every recording
48
+ * application of the project with only a projectId attribute;
49
+ * - zero and unknown usage post nothing (never a 0), the monthly pair only
50
+ * exists with a budget, and a project with replay switched off posts
51
+ * nothing;
52
+ * - the sweep pages by id, reads each counter once per page in one batch,
53
+ * inserts once per page, registers each name once per project, and stops
54
+ * cleanly on every failure it can meet without ever throwing.
55
+ */
56
+
57
+ const GIB: number = 1024 * 1024 * 1024;
58
+ const DAILY_LIMIT: number = GIB;
59
+ const NOW: Date = new Date("2026-09-29T12:34:56.000Z");
60
+
61
+ function projectId(n: number): ObjectID {
62
+ return new ObjectID(`10000000-0000-4000-8000-${String(n).padStart(12, "0")}`);
63
+ }
64
+
65
+ function applicationId(n: number): ObjectID {
66
+ return new ObjectID(`20000000-0000-4000-8000-${String(n).padStart(12, "0")}`);
67
+ }
68
+
69
+ const PROJECT_A: ObjectID = projectId(1);
70
+ const PROJECT_B: ObjectID = projectId(2);
71
+
72
+ function makeApplication(data: {
73
+ id: ObjectID;
74
+ projectId: ObjectID;
75
+ name?: string | undefined;
76
+ monthlyBudgetInGB?: number | null | undefined;
77
+ }): RumApplication {
78
+ const application: RumApplication = new RumApplication();
79
+ application.id = data.id;
80
+ application.projectId = data.projectId;
81
+ application.name = data.name === undefined ? "Storefront" : data.name;
82
+
83
+ if (data.monthlyBudgetInGB !== undefined && data.monthlyBudgetInGB !== null) {
84
+ application.sessionReplayMonthlyBudgetInGB = data.monthlyBudgetInGB;
85
+ }
86
+
87
+ return application;
88
+ }
89
+
90
+ /*
91
+ * The ids a QueryHelper.any / in operand asks about - a Raw
92
+ * `(<column> IN (:...<param>))` whose one parameter holds them. Throws for
93
+ * anything else, so a query that lost the operand fails loudly.
94
+ */
95
+ function idsIn(operand: unknown): Array<string> {
96
+ const raw: {
97
+ getSql?: (alias: string) => string;
98
+ objectLiteralParameters?: JSONObject;
99
+ } = (operand || {}) as {
100
+ getSql?: (alias: string) => string;
101
+ objectLiteralParameters?: JSONObject;
102
+ };
103
+
104
+ const inOperandSql: RegExp = /^\(column IN \(:\.\.\.\w+\)\)$/;
105
+
106
+ if (
107
+ typeof raw.getSql !== "function" ||
108
+ !inOperandSql.test(raw.getSql("column")) ||
109
+ !raw.objectLiteralParameters
110
+ ) {
111
+ throw new Error("Expected a QueryHelper.any operand");
112
+ }
113
+
114
+ const values: Array<unknown> = Object.values(raw.objectLiteralParameters);
115
+
116
+ if (values.length !== 1 || !Array.isArray(values[0])) {
117
+ throw new Error("Expected one list of ids in the operand");
118
+ }
119
+
120
+ return (values[0] as Array<unknown>).map((value: unknown) => {
121
+ return String(value);
122
+ });
123
+ }
124
+
125
+ function makeProject(id: ObjectID): Project {
126
+ const project: Project = new Project();
127
+ project.id = id;
128
+ return project;
129
+ }
130
+
131
+ function dailyKey(id: ObjectID): string {
132
+ return SessionReplayUsage.getDailyProjectByteKey(id);
133
+ }
134
+
135
+ function monthlyKey(project: ObjectID, application: ObjectID): string {
136
+ return SessionReplayUsage.getMonthlyApplicationByteKey({
137
+ projectId: project,
138
+ rumApplicationId: application,
139
+ });
140
+ }
141
+
142
+ describe("SessionReplayBudgetMetrics.computePercentUsed", () => {
143
+ test.each([
144
+ { usedBytes: 0, limitBytes: GIB, expected: 0 },
145
+ { usedBytes: GIB, limitBytes: GIB, expected: 100 },
146
+ { usedBytes: GIB - 1, limitBytes: GIB, expected: 99.99 },
147
+ { usedBytes: 2 * GIB, limitBytes: GIB, expected: 200 },
148
+ // The crossing request stays charged: a 2 MiB overshoot reads over 100.
149
+ { usedBytes: GIB + 2 * 1024 * 1024, limitBytes: GIB, expected: 100.19 },
150
+ { usedBytes: Math.ceil(0.8 * GIB), limitBytes: GIB, expected: 80 },
151
+ { usedBytes: Math.ceil(0.8 * GIB) - 1, limitBytes: GIB, expected: 79.99 },
152
+ { usedBytes: 44 * 1024 * 1024, limitBytes: GIB, expected: 4.29 },
153
+ // A handful of bytes is still "some", but rounds down to 0.00.
154
+ { usedBytes: 10, limitBytes: GIB, expected: 0 },
155
+ ])(
156
+ "$usedBytes of $limitBytes bytes reads $expected",
157
+ (row: { usedBytes: number; limitBytes: number; expected: number }) => {
158
+ expect(
159
+ SessionReplayBudgetMetrics.computePercentUsed({
160
+ usedBytes: row.usedBytes,
161
+ limitBytes: row.limitBytes,
162
+ }),
163
+ ).toBe(row.expected);
164
+ },
165
+ );
166
+
167
+ test("a negative count (a refund across 00:00 UTC) reads as 0", () => {
168
+ expect(
169
+ SessionReplayBudgetMetrics.computePercentUsed({
170
+ usedBytes: -4096,
171
+ limitBytes: GIB,
172
+ }),
173
+ ).toBe(0);
174
+ });
175
+
176
+ test.each([0, -1, NaN, Infinity, -Infinity])(
177
+ "a limit of %p has no percent",
178
+ (limitBytes: number) => {
179
+ expect(
180
+ SessionReplayBudgetMetrics.computePercentUsed({
181
+ usedBytes: 100,
182
+ limitBytes: limitBytes,
183
+ }),
184
+ ).toBeNull();
185
+ },
186
+ );
187
+
188
+ test("an unusable count has no percent", () => {
189
+ expect(
190
+ SessionReplayBudgetMetrics.computePercentUsed({
191
+ usedBytes: NaN,
192
+ limitBytes: GIB,
193
+ }),
194
+ ).toBeNull();
195
+ });
196
+
197
+ /*
198
+ * The property the alert thresholds depend on, checked for every whole-GiB
199
+ * budget a customer is likely to set and for the default daily limit: the
200
+ * published percent crosses 80 and 100 on exactly the byte where the true
201
+ * ratio does. The gate refuses once used > limit, so used == limit is
202
+ * "spent" - and must read exactly 100.
203
+ */
204
+ test("crosses 80 and 100 on exactly the right byte for every whole-GiB budget up to 2,000", () => {
205
+ const misfires: Array<string> = [];
206
+
207
+ for (let gigabytes: number = 1; gigabytes <= 2000; gigabytes++) {
208
+ const limitBytes: number = gigabytes * GIB;
209
+
210
+ for (const threshold of [80, 100]) {
211
+ // Smallest whole byte count at or above threshold% of the limit.
212
+ const firstByte: number = Math.ceil((limitBytes * threshold) / 100);
213
+
214
+ const atFirstByte: number | null =
215
+ SessionReplayBudgetMetrics.computePercentUsed({
216
+ usedBytes: firstByte,
217
+ limitBytes: limitBytes,
218
+ });
219
+ const justBelow: number | null =
220
+ SessionReplayBudgetMetrics.computePercentUsed({
221
+ usedBytes: firstByte - 1,
222
+ limitBytes: limitBytes,
223
+ });
224
+
225
+ if (atFirstByte === null || atFirstByte < threshold) {
226
+ misfires.push(
227
+ `${gigabytes} GiB: ${firstByte} bytes read ${atFirstByte}`,
228
+ );
229
+ }
230
+
231
+ if (justBelow === null || justBelow >= threshold) {
232
+ misfires.push(
233
+ `${gigabytes} GiB: ${firstByte - 1} bytes read ${justBelow}`,
234
+ );
235
+ }
236
+ }
237
+ }
238
+
239
+ expect(misfires).toEqual([]);
240
+ });
241
+ });
242
+
243
+ describe("SessionReplayBudgetMetrics.getMonthlyBudgetInBytes", () => {
244
+ test("GB is GiB, rounded down to a whole byte, as the gate computes it", () => {
245
+ expect(SessionReplayBudgetMetrics.getMonthlyBudgetInBytes(10)).toBe(
246
+ 10 * GIB,
247
+ );
248
+ expect(SessionReplayBudgetMetrics.getMonthlyBudgetInBytes(1.5)).toBe(
249
+ Math.floor(1.5 * GIB),
250
+ );
251
+ });
252
+
253
+ test("accepts a numeric string, the way the gate's policy reader does", () => {
254
+ expect(SessionReplayBudgetMetrics.getMonthlyBudgetInBytes("25")).toBe(
255
+ 25 * GIB,
256
+ );
257
+ });
258
+
259
+ test.each([0, -5, null, undefined, NaN, Infinity, "", " ", "abc", {}])(
260
+ "%p means no monthly budget",
261
+ (value: unknown) => {
262
+ expect(
263
+ SessionReplayBudgetMetrics.getMonthlyBudgetInBytes(value),
264
+ ).toBeNull();
265
+ },
266
+ );
267
+ });
268
+
269
+ describe("SessionReplayBudgetMetrics.isDailyLimitMismatchSuspected", () => {
270
+ test("only past twice the limit - a spent counter rests at most one request over it", () => {
271
+ expect(
272
+ SessionReplayBudgetMetrics.isDailyLimitMismatchSuspected({
273
+ usedBytes: 2 * DAILY_LIMIT + 1,
274
+ dailyByteLimit: DAILY_LIMIT,
275
+ }),
276
+ ).toBe(true);
277
+ expect(
278
+ SessionReplayBudgetMetrics.isDailyLimitMismatchSuspected({
279
+ usedBytes: 2 * DAILY_LIMIT,
280
+ dailyByteLimit: DAILY_LIMIT,
281
+ }),
282
+ ).toBe(false);
283
+ expect(
284
+ SessionReplayBudgetMetrics.isDailyLimitMismatchSuspected({
285
+ usedBytes: DAILY_LIMIT + 2 * 1024 * 1024,
286
+ dailyByteLimit: DAILY_LIMIT,
287
+ }),
288
+ ).toBe(false);
289
+ });
290
+
291
+ test("never without a usable limit", () => {
292
+ expect(
293
+ SessionReplayBudgetMetrics.isDailyLimitMismatchSuspected({
294
+ usedBytes: 10 * GIB,
295
+ dailyByteLimit: 0,
296
+ }),
297
+ ).toBe(false);
298
+ });
299
+ });
300
+
301
+ describe("SessionReplayBudgetMetrics.buildMetricRow", () => {
302
+ const ingestionDate: Date = new Date("2026-09-29T10:00:00.000Z");
303
+ const retentionDate: Date = new Date("2026-10-29T10:00:00.000Z");
304
+
305
+ function build(attributes: JSONObject = { projectId: "p" }): JSONObject {
306
+ return SessionReplayBudgetMetrics.buildMetricRow({
307
+ projectId: PROJECT_A,
308
+ rumApplicationId: applicationId(1),
309
+ metricType: SessionReplayBudgetMetricType.ProjectDailyUsedPercent,
310
+ value: 42.5,
311
+ attributes: attributes,
312
+ ingestionDate: ingestionDate,
313
+ retentionDate: retentionDate,
314
+ });
315
+ }
316
+
317
+ test("keys the row to the RUM application, so a monitor scoped to it finds it", () => {
318
+ const row: JSONObject = build();
319
+
320
+ expect(row["projectId"]).toBe(PROJECT_A.toString());
321
+ expect(row["primaryEntityId"]).toBe(applicationId(1).toString());
322
+ expect(row["primaryEntityType"]).toBe(ServiceType.RealUserMonitor);
323
+ expect(row["name"]).toBe(
324
+ SessionReplayBudgetMetricType.ProjectDailyUsedPercent,
325
+ );
326
+ expect(row["metricPointType"]).toBe(MetricPointType.Gauge);
327
+ expect(row["value"]).toBe(42.5);
328
+ });
329
+
330
+ test("stamps the sweep's time and the retention it was given", () => {
331
+ const row: JSONObject = build();
332
+
333
+ expect(row["time"]).toBe(OneUptimeDate.toClickhouseDateTime(ingestionDate));
334
+ expect(row["createdAt"]).toBe(
335
+ OneUptimeDate.toClickhouseDateTime(ingestionDate),
336
+ );
337
+ expect(row["timeUnixNano"]).toBe(
338
+ OneUptimeDate.toUnixNano(ingestionDate).toString(),
339
+ );
340
+ expect(row["retentionDate"]).toBe(
341
+ OneUptimeDate.toClickhouseDateTime(retentionDate),
342
+ );
343
+ expect(typeof row["_id"]).toBe("string");
344
+ });
345
+
346
+ test("publishes the attribute keys (the pickers read keys, not the map) and copies the map", () => {
347
+ const attributes: JSONObject = { projectId: "p", rumApplicationId: "a" };
348
+ const row: JSONObject = build(attributes);
349
+
350
+ expect(row["attributeKeys"]).toEqual(["projectId", "rumApplicationId"]);
351
+ expect(row["attributes"]).toEqual(attributes);
352
+
353
+ (row["attributes"] as JSONObject)["mutated"] = "yes";
354
+ expect(attributes["mutated"]).toBeUndefined();
355
+ });
356
+
357
+ test("writes exactly the columns MonitorMetricUtil.buildMonitorMetricRow writes (the lockstep)", async () => {
358
+ // The monitor builder reads its retention setting; there is no database.
359
+ jest
360
+ .spyOn(GlobalConfigService, "findOneBy")
361
+ .mockResolvedValue(null as never);
362
+
363
+ const monitorRow: JSONObject = await (
364
+ MonitorMetricUtil as unknown as {
365
+ buildMonitorMetricRow: (data: {
366
+ projectId: ObjectID;
367
+ monitorId: ObjectID;
368
+ metricName: string;
369
+ value: number | null | undefined;
370
+ attributes: JSONObject;
371
+ }) => Promise<JSONObject>;
372
+ }
373
+ ).buildMonitorMetricRow({
374
+ projectId: PROJECT_A,
375
+ monitorId: applicationId(1),
376
+ metricName: "oneuptime.monitor.online",
377
+ value: 1,
378
+ attributes: { monitorId: applicationId(1).toString() },
379
+ });
380
+
381
+ const row: JSONObject = build();
382
+
383
+ expect(Object.keys(row).sort()).toEqual(Object.keys(monitorRow).sort());
384
+
385
+ for (const column of [
386
+ "aggregationTemporality",
387
+ "startTime",
388
+ "startTimeUnixNano",
389
+ "isMonotonic",
390
+ "count",
391
+ "sum",
392
+ "min",
393
+ "max",
394
+ "bucketCounts",
395
+ "explicitBounds",
396
+ ]) {
397
+ expect(row[column]).toEqual(monitorRow[column]);
398
+ }
399
+
400
+ jest.restoreAllMocks();
401
+ });
402
+ });
403
+
404
+ describe("SessionReplayBudgetMetrics.buildApplicationRows", () => {
405
+ const ingestionDate: Date = new Date("2026-09-29T10:00:00.000Z");
406
+ const retentionDate: Date = new Date("2026-10-29T10:00:00.000Z");
407
+
408
+ function application(
409
+ overrides: Partial<SessionReplayBudgetApplication> = {},
410
+ ): SessionReplayBudgetApplication {
411
+ return {
412
+ rumApplicationId: applicationId(1),
413
+ projectId: PROJECT_A,
414
+ name: "Storefront",
415
+ monthlyBudgetInBytes: null,
416
+ ...overrides,
417
+ };
418
+ }
419
+
420
+ function rows(data: {
421
+ application?: SessionReplayBudgetApplication;
422
+ daily: number | null;
423
+ monthly?: number | null;
424
+ dailyByteLimit?: number;
425
+ }): Array<JSONObject> {
426
+ return SessionReplayBudgetMetrics.buildApplicationRows({
427
+ application: data.application || application(),
428
+ projectDailyUsedBytes: data.daily,
429
+ applicationMonthlyUsedBytes:
430
+ data.monthly === undefined ? null : data.monthly,
431
+ dailyByteLimit:
432
+ data.dailyByteLimit === undefined ? DAILY_LIMIT : data.dailyByteLimit,
433
+ ingestionDate: ingestionDate,
434
+ retentionDate: retentionDate,
435
+ });
436
+ }
437
+
438
+ function byName(
439
+ list: Array<JSONObject>,
440
+ name: SessionReplayBudgetMetricType,
441
+ ): JSONObject | undefined {
442
+ return list.find((row: JSONObject) => {
443
+ return row["name"] === name;
444
+ });
445
+ }
446
+
447
+ test("an application without a monthly budget gets the project's daily pair only", () => {
448
+ const list: Array<JSONObject> = rows({ daily: 858993460, monthly: 999 });
449
+
450
+ expect(
451
+ list.map((row: JSONObject) => {
452
+ return row["name"];
453
+ }),
454
+ ).toEqual([
455
+ SessionReplayBudgetMetricType.ProjectDailyUsedBytes,
456
+ SessionReplayBudgetMetricType.ProjectDailyUsedPercent,
457
+ ]);
458
+ expect(
459
+ byName(list, SessionReplayBudgetMetricType.ProjectDailyUsedBytes)?.[
460
+ "value"
461
+ ],
462
+ ).toBe(858993460);
463
+ expect(
464
+ byName(list, SessionReplayBudgetMetricType.ProjectDailyUsedPercent)?.[
465
+ "value"
466
+ ],
467
+ ).toBe(80);
468
+ });
469
+
470
+ test("the daily pair carries only the project id - it is the project's budget, not this application's usage", () => {
471
+ for (const row of rows({ daily: 1000 })) {
472
+ expect(row["attributes"]).toEqual({ projectId: PROJECT_A.toString() });
473
+ expect(row["attributeKeys"]).toEqual(["projectId"]);
474
+ // Still keyed to the application, which is what a scoped monitor reads.
475
+ expect(row["primaryEntityId"]).toBe(applicationId(1).toString());
476
+ }
477
+ });
478
+
479
+ test("an application with a monthly budget also gets its own monthly pair, with its identity", () => {
480
+ const list: Array<JSONObject> = rows({
481
+ application: application({ monthlyBudgetInBytes: 10 * GIB }),
482
+ daily: 1000,
483
+ monthly: 9 * GIB,
484
+ });
485
+
486
+ expect(list).toHaveLength(4);
487
+
488
+ const monthlyBytes: JSONObject | undefined = byName(
489
+ list,
490
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedBytes,
491
+ );
492
+ const monthlyPercent: JSONObject | undefined = byName(
493
+ list,
494
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedPercent,
495
+ );
496
+
497
+ expect(monthlyBytes?.["value"]).toBe(9 * GIB);
498
+ expect(monthlyPercent?.["value"]).toBe(90);
499
+
500
+ for (const row of [monthlyBytes, monthlyPercent]) {
501
+ expect(row?.["attributes"]).toEqual({
502
+ projectId: PROJECT_A.toString(),
503
+ rumApplicationId: applicationId(1).toString(),
504
+ rumApplicationName: "Storefront",
505
+ });
506
+ expect(row?.["attributeKeys"]).toEqual([
507
+ "projectId",
508
+ "rumApplicationId",
509
+ "rumApplicationName",
510
+ ]);
511
+ }
512
+ });
513
+
514
+ test("a blank application name is left off rather than posted empty", () => {
515
+ const list: Array<JSONObject> = rows({
516
+ application: application({
517
+ name: " ",
518
+ monthlyBudgetInBytes: 10 * GIB,
519
+ }),
520
+ daily: null,
521
+ monthly: GIB,
522
+ });
523
+
524
+ expect(list).toHaveLength(2);
525
+
526
+ for (const row of list) {
527
+ expect(Object.keys(row["attributes"] as JSONObject).sort()).toEqual([
528
+ "projectId",
529
+ "rumApplicationId",
530
+ ]);
531
+ }
532
+ });
533
+
534
+ test("zero usage posts nothing - a zero cannot cross a threshold", () => {
535
+ expect(
536
+ rows({
537
+ application: application({ monthlyBudgetInBytes: 10 * GIB }),
538
+ daily: 0,
539
+ monthly: 0,
540
+ }),
541
+ ).toEqual([]);
542
+ });
543
+
544
+ test("an unknown count (null) posts nothing, never 0", () => {
545
+ expect(
546
+ rows({
547
+ application: application({ monthlyBudgetInBytes: 10 * GIB }),
548
+ daily: null,
549
+ monthly: null,
550
+ }),
551
+ ).toEqual([]);
552
+ });
553
+
554
+ test("a negative daily count reads as 0, so it posts nothing", () => {
555
+ expect(rows({ daily: -2048 })).toEqual([]);
556
+ });
557
+
558
+ test("the bytes point goes out even when the percent rounds down to 0.00", () => {
559
+ const list: Array<JSONObject> = rows({ daily: 10 });
560
+
561
+ expect(list).toHaveLength(2);
562
+ expect(
563
+ byName(list, SessionReplayBudgetMetricType.ProjectDailyUsedPercent)?.[
564
+ "value"
565
+ ],
566
+ ).toBe(0);
567
+ });
568
+
569
+ test("without a usable daily limit only the bytes point is posted", () => {
570
+ const list: Array<JSONObject> = rows({ daily: 1000, dailyByteLimit: 0 });
571
+
572
+ expect(
573
+ list.map((row: JSONObject) => {
574
+ return row["name"];
575
+ }),
576
+ ).toEqual([SessionReplayBudgetMetricType.ProjectDailyUsedBytes]);
577
+ });
578
+
579
+ test("a spent budget reads 100 or more, not capped", () => {
580
+ const list: Array<JSONObject> = rows({
581
+ application: application({ monthlyBudgetInBytes: GIB }),
582
+ daily: DAILY_LIMIT + 1024,
583
+ monthly: 3 * GIB,
584
+ });
585
+
586
+ expect(
587
+ byName(list, SessionReplayBudgetMetricType.ProjectDailyUsedPercent)?.[
588
+ "value"
589
+ ],
590
+ ).toBe(100);
591
+ expect(
592
+ byName(
593
+ list,
594
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedPercent,
595
+ )?.["value"],
596
+ ).toBe(300);
597
+ });
598
+ });
599
+
600
+ describe("SessionReplayBudgetMetrics.toBudgetApplication", () => {
601
+ test("reads the id, project, name and monthly budget off the row", () => {
602
+ const budgetApplication: SessionReplayBudgetApplication | null =
603
+ SessionReplayBudgetMetrics.toBudgetApplication(
604
+ makeApplication({
605
+ id: applicationId(7),
606
+ projectId: PROJECT_B,
607
+ name: "Checkout",
608
+ monthlyBudgetInGB: 3,
609
+ }),
610
+ );
611
+
612
+ expect(budgetApplication?.rumApplicationId.toString()).toBe(
613
+ applicationId(7).toString(),
614
+ );
615
+ expect(budgetApplication?.projectId.toString()).toBe(PROJECT_B.toString());
616
+ expect(budgetApplication?.name).toBe("Checkout");
617
+ expect(budgetApplication?.monthlyBudgetInBytes).toBe(3 * GIB);
618
+ });
619
+
620
+ test("a row that cannot be keyed is skipped", () => {
621
+ const withoutProject: RumApplication = new RumApplication();
622
+ withoutProject.id = applicationId(1);
623
+
624
+ const withoutId: RumApplication = new RumApplication();
625
+ withoutId.projectId = PROJECT_A;
626
+
627
+ expect(
628
+ SessionReplayBudgetMetrics.toBudgetApplication(withoutProject),
629
+ ).toBeNull();
630
+ expect(
631
+ SessionReplayBudgetMetrics.toBudgetApplication(withoutId),
632
+ ).toBeNull();
633
+ });
634
+
635
+ test("no budget and no name read as null and empty", () => {
636
+ const row: RumApplication = makeApplication({
637
+ id: applicationId(1),
638
+ projectId: PROJECT_A,
639
+ });
640
+ delete (row as unknown as JSONObject)["name"];
641
+
642
+ const budgetApplication: SessionReplayBudgetApplication | null =
643
+ SessionReplayBudgetMetrics.toBudgetApplication(row);
644
+
645
+ expect(budgetApplication?.name).toBe("");
646
+ expect(budgetApplication?.monthlyBudgetInBytes).toBeNull();
647
+ });
648
+ });
649
+
650
+ describe("SessionReplayBudgetMetrics.publishAll", () => {
651
+ let pages: Array<Array<RumApplication>>;
652
+ let findCalls: Array<FindBy<RumApplication>>;
653
+ let projectFindCalls: Array<FindBy<Project>>;
654
+ let allowedProjects: Array<ObjectID>;
655
+ let counters: Dictionary<number>;
656
+ let counterReads: Array<Array<string>>;
657
+ let countersAvailable: boolean;
658
+ let insertedBatches: Array<Array<JSONObject>>;
659
+ let registrations: Array<{
660
+ projectId: string;
661
+ names: Array<string>;
662
+ metricTypes: Dictionary<MetricType>;
663
+ }>;
664
+ let retentionConfig: GlobalConfig | null;
665
+
666
+ beforeEach(() => {
667
+ pages = [];
668
+ findCalls = [];
669
+ projectFindCalls = [];
670
+ allowedProjects = [PROJECT_A, PROJECT_B];
671
+ counters = {};
672
+ counterReads = [];
673
+ countersAvailable = true;
674
+ insertedBatches = [];
675
+ registrations = [];
676
+ retentionConfig = null;
677
+
678
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockImplementation(() => {
679
+ return new Date(NOW.getTime());
680
+ });
681
+
682
+ jest
683
+ .spyOn(RumApplicationService, "findBy")
684
+ .mockImplementation(
685
+ async (
686
+ findBy: FindBy<RumApplication>,
687
+ ): Promise<Array<RumApplication>> => {
688
+ findCalls.push(findBy);
689
+ return pages[findCalls.length - 1] || [];
690
+ },
691
+ );
692
+
693
+ /*
694
+ * Answers like the database would: only the allowed projects among the
695
+ * ids the query asks about. A query that lost its `_id` scope throws here
696
+ * (idsIn), rather than quietly answering with every allowed project.
697
+ */
698
+ jest
699
+ .spyOn(ProjectService, "findBy")
700
+ .mockImplementation(
701
+ async (findBy: FindBy<Project>): Promise<Array<Project>> => {
702
+ projectFindCalls.push(findBy);
703
+
704
+ const asked: Array<string> = idsIn(
705
+ (findBy.query as unknown as JSONObject)["_id"],
706
+ );
707
+
708
+ return allowedProjects
709
+ .filter((id: ObjectID) => {
710
+ return asked.includes(id.toString());
711
+ })
712
+ .map((id: ObjectID) => {
713
+ return makeProject(id);
714
+ });
715
+ },
716
+ );
717
+
718
+ jest
719
+ .spyOn(SessionReplayUsage, "readByteCounters")
720
+ .mockImplementation(
721
+ async (keys: Array<string>): Promise<Array<number> | null> => {
722
+ counterReads.push(keys);
723
+
724
+ if (!countersAvailable) {
725
+ return null;
726
+ }
727
+
728
+ return keys.map((key: string) => {
729
+ return counters[key] || 0;
730
+ });
731
+ },
732
+ );
733
+
734
+ jest
735
+ .spyOn(MetricService, "insertJsonRows")
736
+ .mockImplementation(async (rows: Array<JSONObject>): Promise<void> => {
737
+ insertedBatches.push(rows);
738
+ });
739
+
740
+ jest
741
+ .spyOn(TelemetryUtil, "indexMetricNameServiceNameMap")
742
+ .mockImplementation(
743
+ async (data: {
744
+ projectId: ObjectID;
745
+ metricNameServiceNameMap: Dictionary<MetricType>;
746
+ }): Promise<void> => {
747
+ registrations.push({
748
+ projectId: data.projectId.toString(),
749
+ names: Object.keys(data.metricNameServiceNameMap).sort(),
750
+ metricTypes: data.metricNameServiceNameMap,
751
+ });
752
+ },
753
+ );
754
+
755
+ // Only the one global config row, and only if the setting is selected.
756
+ jest
757
+ .spyOn(GlobalConfigService, "findOneBy")
758
+ .mockImplementation(
759
+ async (findOneBy: {
760
+ query?: unknown;
761
+ select?: unknown;
762
+ }): Promise<GlobalConfig | null> => {
763
+ const query: JSONObject = (findOneBy.query || {}) as JSONObject;
764
+ const select: JSONObject = (findOneBy.select || {}) as JSONObject;
765
+
766
+ if (
767
+ query["_id"] !== ObjectID.getZeroObjectID().toString() ||
768
+ select["monitorMetricRetentionInDays"] !== true
769
+ ) {
770
+ return null;
771
+ }
772
+
773
+ return retentionConfig;
774
+ },
775
+ );
776
+
777
+ jest.spyOn(logger, "debug").mockImplementation((): void => {});
778
+ jest.spyOn(logger, "warn").mockImplementation((): void => {});
779
+ jest.spyOn(logger, "error").mockImplementation((): void => {});
780
+
781
+ /*
782
+ * The counter keys come from the wall clock (SessionReplayUsage), so the
783
+ * clock is frozen too: a test must not build today's key at 23:59:59.999
784
+ * and see the sweep build tomorrow's a millisecond later.
785
+ */
786
+ jest.useFakeTimers({
787
+ now: NOW,
788
+ doNotFake: ["nextTick", "setImmediate"],
789
+ });
790
+ });
791
+
792
+ afterEach(() => {
793
+ jest.restoreAllMocks();
794
+ jest.useRealTimers();
795
+ });
796
+
797
+ async function sweep(
798
+ options: {
799
+ shouldContinue?: () => boolean;
800
+ deadline?: Date;
801
+ dailyByteLimit?: number;
802
+ } = {},
803
+ ): Promise<SessionReplayBudgetSweepSummary> {
804
+ return SessionReplayBudgetMetrics.publishAll({
805
+ dailyByteLimit:
806
+ options.dailyByteLimit === undefined
807
+ ? DAILY_LIMIT
808
+ : options.dailyByteLimit,
809
+ shouldContinue: options.shouldContinue,
810
+ deadline: options.deadline,
811
+ });
812
+ }
813
+
814
+ function insertedRows(): Array<JSONObject> {
815
+ return insertedBatches.flat();
816
+ }
817
+
818
+ // A full page of applications, all in one project, with ids from `from`.
819
+ function fullPage(from: number, project: ObjectID): Array<RumApplication> {
820
+ return Array.from(
821
+ { length: SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE },
822
+ (_: unknown, index: number) => {
823
+ return makeApplication({
824
+ id: applicationId(from + index),
825
+ projectId: project,
826
+ });
827
+ },
828
+ );
829
+ }
830
+
831
+ test("reads only applications that record replay, by id, as root", async () => {
832
+ await sweep();
833
+
834
+ expect(findCalls).toHaveLength(1);
835
+
836
+ const findBy: FindBy<RumApplication> = findCalls[0]!;
837
+ const query: JSONObject = findBy.query as unknown as JSONObject;
838
+
839
+ expect(query["isSessionReplayEnabled"]).toBe(true);
840
+ /*
841
+ * "Has ever recorded": replay is on by default for every RUM application,
842
+ * so the enabled flag alone would include every web-vitals-only app.
843
+ */
844
+ const lastChunk: { getSql?: (alias: string) => string } = query[
845
+ "sessionReplayLastChunkReceivedAt"
846
+ ] as unknown as { getSql?: (alias: string) => string };
847
+ expect(lastChunk.getSql?.("column")).toBe("(column IS NOT NULL)");
848
+ expect(query["_id"]).toBeUndefined();
849
+
850
+ expect(findBy.select).toEqual({
851
+ _id: true,
852
+ projectId: true,
853
+ name: true,
854
+ sessionReplayMonthlyBudgetInGB: true,
855
+ });
856
+ expect(findBy.sort).toEqual({ _id: SortOrder.Ascending });
857
+ expect(findBy.skip).toBe(0);
858
+ expect(findBy.limit).toBe(SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE);
859
+ expect(findBy.props).toEqual({ isRoot: true });
860
+ });
861
+
862
+ test("an empty instance finishes without touching projects, Redis or ClickHouse", async () => {
863
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
864
+
865
+ expect(summary.stopReason).toBe("done");
866
+ expect(summary.pagesScanned).toBe(0);
867
+ expect(projectFindCalls).toHaveLength(0);
868
+ expect(counterReads).toHaveLength(0);
869
+ expect(insertedBatches).toHaveLength(0);
870
+ });
871
+
872
+ test("pages on past the last id of a full page", async () => {
873
+ pages = [
874
+ fullPage(1, PROJECT_A),
875
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_A })],
876
+ ];
877
+
878
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
879
+
880
+ expect(findCalls).toHaveLength(2);
881
+
882
+ const secondQuery: JSONObject = findCalls[1]!
883
+ .query as unknown as JSONObject;
884
+ const cursor: { getSql?: (alias: string) => string } = secondQuery[
885
+ "_id"
886
+ ] as unknown as { getSql?: (alias: string) => string };
887
+
888
+ // QueryHelper.greaterThan: the cursor is the last id of the first page.
889
+ expect(cursor.getSql?.("id")).toMatch(/^\(id > :/);
890
+ expect(
891
+ Object.values(
892
+ (cursor as unknown as { objectLiteralParameters: JSONObject })
893
+ .objectLiteralParameters,
894
+ ).map((value: unknown) => {
895
+ return String(value);
896
+ }),
897
+ ).toContain(
898
+ applicationId(SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE).toString(),
899
+ );
900
+
901
+ expect(summary.pagesScanned).toBe(2);
902
+ expect(summary.applicationsScanned).toBe(
903
+ SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE + 1,
904
+ );
905
+ });
906
+
907
+ test("drops applications whose project has replay switched off", async () => {
908
+ pages = [
909
+ [
910
+ makeApplication({ id: applicationId(1), projectId: PROJECT_A }),
911
+ makeApplication({ id: applicationId(2), projectId: PROJECT_B }),
912
+ ],
913
+ ];
914
+ allowedProjects = [PROJECT_A];
915
+ counters[dailyKey(PROJECT_A)] = 5000;
916
+ counters[dailyKey(PROJECT_B)] = 7000;
917
+
918
+ await sweep();
919
+
920
+ expect(projectFindCalls).toHaveLength(1);
921
+
922
+ const projectQuery: JSONObject = projectFindCalls[0]!
923
+ .query as unknown as JSONObject;
924
+ // Asks about exactly this page's projects: no more, and each once.
925
+ expect(idsIn(projectQuery["_id"]).sort()).toEqual(
926
+ [PROJECT_A.toString(), PROJECT_B.toString()].sort(),
927
+ );
928
+ expect(projectQuery["isSessionReplayAllowed"]).toBe(true);
929
+ expect(projectFindCalls[0]!.select).toEqual({ _id: true });
930
+ // One row per distinct project on the page, however many apps it has.
931
+ expect(projectFindCalls[0]!.limit).toBe(2);
932
+ expect(projectFindCalls[0]!.props).toEqual({ isRoot: true });
933
+
934
+ expect(counterReads).toEqual([[dailyKey(PROJECT_A)]]);
935
+
936
+ const entityIds: Array<unknown> = insertedRows().map((row: JSONObject) => {
937
+ return row["primaryEntityId"];
938
+ });
939
+
940
+ expect(new Set(entityIds)).toEqual(new Set([applicationId(1).toString()]));
941
+ });
942
+
943
+ test("reads one daily key per project and one monthly key per budgeted application, in one batch", async () => {
944
+ pages = [
945
+ [
946
+ makeApplication({
947
+ id: applicationId(1),
948
+ projectId: PROJECT_A,
949
+ monthlyBudgetInGB: 10,
950
+ }),
951
+ makeApplication({ id: applicationId(2), projectId: PROJECT_A }),
952
+ makeApplication({
953
+ id: applicationId(3),
954
+ projectId: PROJECT_B,
955
+ monthlyBudgetInGB: 0,
956
+ }),
957
+ ],
958
+ ];
959
+
960
+ await sweep();
961
+
962
+ expect(counterReads).toEqual([
963
+ [
964
+ dailyKey(PROJECT_A),
965
+ monthlyKey(PROJECT_A, applicationId(1)),
966
+ dailyKey(PROJECT_B),
967
+ ],
968
+ ]);
969
+ });
970
+
971
+ test("posts the project's daily value under every recording application, identically", async () => {
972
+ pages = [
973
+ [
974
+ makeApplication({ id: applicationId(1), projectId: PROJECT_A }),
975
+ makeApplication({ id: applicationId(2), projectId: PROJECT_A }),
976
+ ],
977
+ ];
978
+ counters[dailyKey(PROJECT_A)] = Math.ceil(0.85 * DAILY_LIMIT);
979
+
980
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
981
+
982
+ // One insert for the whole page.
983
+ expect(insertedBatches).toHaveLength(1);
984
+
985
+ const percentRows: Array<JSONObject> = insertedRows().filter(
986
+ (row: JSONObject) => {
987
+ return (
988
+ row["name"] === SessionReplayBudgetMetricType.ProjectDailyUsedPercent
989
+ );
990
+ },
991
+ );
992
+
993
+ expect(
994
+ percentRows.map((row: JSONObject) => {
995
+ return [row["primaryEntityId"], row["value"]];
996
+ }),
997
+ ).toEqual([
998
+ [applicationId(1).toString(), 85],
999
+ [applicationId(2).toString(), 85],
1000
+ ]);
1001
+
1002
+ for (const row of insertedRows()) {
1003
+ expect(row["attributes"]).toEqual({ projectId: PROJECT_A.toString() });
1004
+ }
1005
+
1006
+ expect(summary.rowsWritten).toBe(4);
1007
+ });
1008
+
1009
+ test("posts the monthly pair only for budgeted applications, from their own counters", async () => {
1010
+ pages = [
1011
+ [
1012
+ makeApplication({
1013
+ id: applicationId(1),
1014
+ projectId: PROJECT_A,
1015
+ name: "Checkout",
1016
+ monthlyBudgetInGB: 4,
1017
+ }),
1018
+ makeApplication({ id: applicationId(2), projectId: PROJECT_A }),
1019
+ ],
1020
+ ];
1021
+ counters[monthlyKey(PROJECT_A, applicationId(1))] = 3 * GIB;
1022
+
1023
+ await sweep();
1024
+
1025
+ // No daily usage today, so only the budgeted application's monthly pair.
1026
+ expect(
1027
+ insertedRows().map((row: JSONObject) => {
1028
+ return [row["primaryEntityId"], row["name"], row["value"]];
1029
+ }),
1030
+ ).toEqual([
1031
+ [
1032
+ applicationId(1).toString(),
1033
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedBytes,
1034
+ 3 * GIB,
1035
+ ],
1036
+ [
1037
+ applicationId(1).toString(),
1038
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedPercent,
1039
+ 75,
1040
+ ],
1041
+ ]);
1042
+ expect(insertedRows()[0]!["attributes"]).toEqual({
1043
+ projectId: PROJECT_A.toString(),
1044
+ rumApplicationId: applicationId(1).toString(),
1045
+ rumApplicationName: "Checkout",
1046
+ });
1047
+ });
1048
+
1049
+ test("zero usage everywhere inserts nothing and registers nothing", async () => {
1050
+ pages = [
1051
+ [
1052
+ makeApplication({
1053
+ id: applicationId(1),
1054
+ projectId: PROJECT_A,
1055
+ monthlyBudgetInGB: 4,
1056
+ }),
1057
+ ],
1058
+ ];
1059
+
1060
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1061
+
1062
+ expect(counterReads).toHaveLength(1);
1063
+ expect(insertedBatches).toHaveLength(0);
1064
+ expect(registrations).toHaveLength(0);
1065
+ expect(summary.stopReason).toBe("done");
1066
+ expect(summary.rowsWritten).toBe(0);
1067
+ });
1068
+
1069
+ test("unreadable counters end the sweep without posting anything - an unknown is never a 0", async () => {
1070
+ pages = [
1071
+ fullPage(1, PROJECT_A),
1072
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_A })],
1073
+ ];
1074
+ countersAvailable = false;
1075
+
1076
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1077
+
1078
+ expect(summary.stopReason).toBe("counters-unavailable");
1079
+ expect(insertedBatches).toHaveLength(0);
1080
+ // It does not go on to spend queries on pages it cannot publish.
1081
+ expect(findCalls).toHaveLength(1);
1082
+ expect(logger.warn).toHaveBeenCalledWith(
1083
+ expect.stringContaining("could not be read from Redis"),
1084
+ );
1085
+ });
1086
+
1087
+ /*
1088
+ * A read that fails partway through: what earlier pages wrote stays
1089
+ * written, and the warning says so rather than "nothing was published".
1090
+ */
1091
+ test("counters lost partway keep the earlier pages' rows, and the warning counts them", async () => {
1092
+ pages = [
1093
+ fullPage(1, PROJECT_A),
1094
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_B })],
1095
+ ];
1096
+ counters[dailyKey(PROJECT_A)] = 1000;
1097
+
1098
+ let reads: number = 0;
1099
+
1100
+ jest
1101
+ .spyOn(SessionReplayUsage, "readByteCounters")
1102
+ .mockImplementation(
1103
+ async (keys: Array<string>): Promise<Array<number> | null> => {
1104
+ reads++;
1105
+
1106
+ if (reads > 1) {
1107
+ return null;
1108
+ }
1109
+
1110
+ return keys.map((key: string) => {
1111
+ return counters[key] || 0;
1112
+ });
1113
+ },
1114
+ );
1115
+
1116
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1117
+
1118
+ expect(summary.stopReason).toBe("counters-unavailable");
1119
+ expect(summary.rowsWritten).toBe(2 * SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE);
1120
+ expect(insertedBatches).toHaveLength(1);
1121
+
1122
+ const warning: string = String(
1123
+ (logger.warn as unknown as jest.Mock).mock.calls.find(
1124
+ (call: Array<unknown>) => {
1125
+ return String(call[0]).includes("could not be read from Redis");
1126
+ },
1127
+ )?.[0],
1128
+ );
1129
+
1130
+ expect(warning).toContain(
1131
+ `${2 * SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE} row(s) from earlier pages were`,
1132
+ );
1133
+ // Names the page it stopped at: the first page's last application.
1134
+ expect(warning).toContain(
1135
+ applicationId(SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE).toString(),
1136
+ );
1137
+ expect(warning).not.toContain("nothing is published");
1138
+ });
1139
+
1140
+ test("a page that cannot be read ends the sweep quietly - it never throws", async () => {
1141
+ jest
1142
+ .spyOn(RumApplicationService, "findBy")
1143
+ .mockRejectedValue(new Error("Postgres is down") as never);
1144
+
1145
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1146
+
1147
+ expect(summary.stopReason).toBe("page-fetch-failed");
1148
+ expect(logger.error).toHaveBeenCalled();
1149
+ expect(insertedBatches).toHaveLength(0);
1150
+ });
1151
+
1152
+ test("a page that does not advance the cursor ends the sweep instead of looping", async () => {
1153
+ const page: Array<RumApplication> = fullPage(1, PROJECT_A);
1154
+ pages = [page, page];
1155
+
1156
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1157
+
1158
+ expect(summary.stopReason).toBe("page-fetch-failed");
1159
+ expect(findCalls).toHaveLength(2);
1160
+ expect(summary.pagesScanned).toBe(1);
1161
+ });
1162
+
1163
+ test("a failed insert skips its page, the cursor still moves past it, and the next page is published", async () => {
1164
+ pages = [
1165
+ fullPage(1, PROJECT_A),
1166
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_B })],
1167
+ ];
1168
+ counters[dailyKey(PROJECT_A)] = 1000;
1169
+ counters[dailyKey(PROJECT_B)] = 2000;
1170
+
1171
+ let insertAttempts: number = 0;
1172
+
1173
+ jest
1174
+ .spyOn(MetricService, "insertJsonRows")
1175
+ .mockImplementation(async (rows: Array<JSONObject>): Promise<void> => {
1176
+ insertAttempts++;
1177
+
1178
+ if (insertAttempts === 1) {
1179
+ throw new Error("ClickHouse hiccup");
1180
+ }
1181
+
1182
+ insertedBatches.push(rows);
1183
+ });
1184
+
1185
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1186
+
1187
+ expect(summary.stopReason).toBe("done");
1188
+ expect(summary.pagesFailed).toBe(1);
1189
+ expect(findCalls).toHaveLength(2);
1190
+ expect(
1191
+ new Set(
1192
+ insertedRows().map((row: JSONObject) => {
1193
+ return row["primaryEntityId"];
1194
+ }),
1195
+ ),
1196
+ ).toEqual(new Set([applicationId(9999).toString()]));
1197
+ // The failed page's project is not registered: nothing of it was written.
1198
+ expect(
1199
+ registrations.map((registration: { projectId: string }) => {
1200
+ return registration.projectId;
1201
+ }),
1202
+ ).toEqual([PROJECT_B.toString()]);
1203
+ });
1204
+
1205
+ test(`${SESSION_REPLAY_BUDGET_SWEEP_MAX_CONSECUTIVE_FAILED_PAGES} failed pages in a row end the sweep`, async () => {
1206
+ pages = Array.from({ length: 6 }, (_: unknown, index: number) => {
1207
+ return fullPage(
1208
+ 1 + index * SESSION_REPLAY_BUDGET_SWEEP_PAGE_SIZE,
1209
+ PROJECT_A,
1210
+ );
1211
+ });
1212
+ counters[dailyKey(PROJECT_A)] = 1000;
1213
+
1214
+ jest
1215
+ .spyOn(MetricService, "insertJsonRows")
1216
+ .mockRejectedValue(new Error("ClickHouse is down") as never);
1217
+
1218
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1219
+
1220
+ expect(summary.stopReason).toBe("too-many-failed-pages");
1221
+ expect(summary.pagesFailed).toBe(
1222
+ SESSION_REPLAY_BUDGET_SWEEP_MAX_CONSECUTIVE_FAILED_PAGES,
1223
+ );
1224
+ expect(findCalls).toHaveLength(
1225
+ SESSION_REPLAY_BUDGET_SWEEP_MAX_CONSECUTIVE_FAILED_PAGES,
1226
+ );
1227
+ });
1228
+
1229
+ test("a failed Project lookup is a failed page, not a crash", async () => {
1230
+ pages = [[makeApplication({ id: applicationId(1), projectId: PROJECT_A })]];
1231
+
1232
+ jest
1233
+ .spyOn(ProjectService, "findBy")
1234
+ .mockRejectedValue(new Error("Postgres timeout") as never);
1235
+
1236
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1237
+
1238
+ expect(summary.pagesFailed).toBe(1);
1239
+ expect(summary.stopReason).toBe("done");
1240
+ expect(counterReads).toHaveLength(0);
1241
+ });
1242
+
1243
+ test("stops between pages once the sweep lock is lost", async () => {
1244
+ pages = [
1245
+ fullPage(1, PROJECT_A),
1246
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_A })],
1247
+ ];
1248
+
1249
+ let checks: number = 0;
1250
+
1251
+ const summary: SessionReplayBudgetSweepSummary = await sweep({
1252
+ shouldContinue: (): boolean => {
1253
+ checks++;
1254
+ return checks === 1;
1255
+ },
1256
+ });
1257
+
1258
+ expect(summary.stopReason).toBe("lock-lost");
1259
+ expect(findCalls).toHaveLength(1);
1260
+ });
1261
+
1262
+ test("stops between pages once the deadline has passed", async () => {
1263
+ pages = [
1264
+ fullPage(1, PROJECT_A),
1265
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_A })],
1266
+ ];
1267
+
1268
+ let now: number = NOW.getTime();
1269
+
1270
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockImplementation(() => {
1271
+ const current: Date = new Date(now);
1272
+ // Each read of the clock is a minute later than the last.
1273
+ now += 60 * 1000;
1274
+ return current;
1275
+ });
1276
+
1277
+ const summary: SessionReplayBudgetSweepSummary = await sweep({
1278
+ deadline: new Date(NOW.getTime() + 90 * 1000),
1279
+ });
1280
+
1281
+ expect(summary.stopReason).toBe("deadline");
1282
+ expect(findCalls).toHaveLength(1);
1283
+ });
1284
+
1285
+ test("stamps every row of the sweep with one timestamp", async () => {
1286
+ pages = [
1287
+ fullPage(1, PROJECT_A),
1288
+ [
1289
+ makeApplication({
1290
+ id: applicationId(9999),
1291
+ projectId: PROJECT_B,
1292
+ monthlyBudgetInGB: 1,
1293
+ }),
1294
+ ],
1295
+ ];
1296
+ counters[dailyKey(PROJECT_A)] = 1000;
1297
+ counters[dailyKey(PROJECT_B)] = 1000;
1298
+ counters[monthlyKey(PROJECT_B, applicationId(9999))] = 1000;
1299
+
1300
+ let now: number = NOW.getTime();
1301
+
1302
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockImplementation(() => {
1303
+ now += 1000;
1304
+ return new Date(now);
1305
+ });
1306
+
1307
+ await sweep();
1308
+
1309
+ const times: Set<unknown> = new Set(
1310
+ insertedRows().map((row: JSONObject) => {
1311
+ return row["time"];
1312
+ }),
1313
+ );
1314
+
1315
+ expect(insertedBatches).toHaveLength(2);
1316
+ expect(times.size).toBe(1);
1317
+ });
1318
+
1319
+ test("registers each written name once per project, with its unit and description and no services", async () => {
1320
+ pages = [
1321
+ fullPage(1, PROJECT_A),
1322
+ [
1323
+ makeApplication({
1324
+ id: applicationId(9999),
1325
+ projectId: PROJECT_A,
1326
+ monthlyBudgetInGB: 1,
1327
+ }),
1328
+ ],
1329
+ ];
1330
+ counters[dailyKey(PROJECT_A)] = 1000;
1331
+ counters[monthlyKey(PROJECT_A, applicationId(9999))] = 1000;
1332
+
1333
+ await sweep();
1334
+
1335
+ /*
1336
+ * Page 1 wrote only the daily pair; page 2 wrote the daily pair again
1337
+ * (already registered this sweep) and the monthly pair (new).
1338
+ */
1339
+ expect(
1340
+ registrations.map(
1341
+ (registration: { projectId: string; names: Array<string> }) => {
1342
+ return [registration.projectId, registration.names];
1343
+ },
1344
+ ),
1345
+ ).toEqual([
1346
+ [
1347
+ PROJECT_A.toString(),
1348
+ [
1349
+ SessionReplayBudgetMetricType.ProjectDailyUsedBytes,
1350
+ SessionReplayBudgetMetricType.ProjectDailyUsedPercent,
1351
+ ].sort(),
1352
+ ],
1353
+ [
1354
+ PROJECT_A.toString(),
1355
+ [
1356
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedBytes,
1357
+ SessionReplayBudgetMetricType.ApplicationMonthlyUsedPercent,
1358
+ ].sort(),
1359
+ ],
1360
+ ]);
1361
+
1362
+ for (const registration of registrations) {
1363
+ for (const [name, metricType] of Object.entries(
1364
+ registration.metricTypes,
1365
+ )) {
1366
+ const type: SessionReplayBudgetMetricType =
1367
+ name as SessionReplayBudgetMetricType;
1368
+
1369
+ expect(metricType.name).toBe(name);
1370
+ expect(metricType.unit).toBe(
1371
+ SessionReplayBudgetMetricTypeUtil.getUnit(type),
1372
+ );
1373
+ expect(metricType.description).toBe(
1374
+ SessionReplayBudgetMetricTypeUtil.getDescription(type),
1375
+ );
1376
+ expect(metricType.services).toBeUndefined();
1377
+ }
1378
+ }
1379
+ });
1380
+
1381
+ test("registers project by project, one at a time", async () => {
1382
+ pages = [
1383
+ [
1384
+ makeApplication({ id: applicationId(1), projectId: PROJECT_A }),
1385
+ makeApplication({ id: applicationId(2), projectId: PROJECT_B }),
1386
+ ],
1387
+ ];
1388
+ counters[dailyKey(PROJECT_A)] = 1000;
1389
+ counters[dailyKey(PROJECT_B)] = 1000;
1390
+
1391
+ let inFlight: number = 0;
1392
+ let maxInFlight: number = 0;
1393
+
1394
+ jest
1395
+ .spyOn(TelemetryUtil, "indexMetricNameServiceNameMap")
1396
+ .mockImplementation(async (): Promise<void> => {
1397
+ inFlight++;
1398
+ maxInFlight = Math.max(maxInFlight, inFlight);
1399
+ await Promise.resolve();
1400
+ await Promise.resolve();
1401
+ inFlight--;
1402
+ });
1403
+
1404
+ await sweep();
1405
+
1406
+ expect(maxInFlight).toBe(1);
1407
+ });
1408
+
1409
+ test("a catalog failure is logged; the rows stay written and the sweep goes on", async () => {
1410
+ pages = [[makeApplication({ id: applicationId(1), projectId: PROJECT_A })]];
1411
+ counters[dailyKey(PROJECT_A)] = 1000;
1412
+
1413
+ jest
1414
+ .spyOn(TelemetryUtil, "indexMetricNameServiceNameMap")
1415
+ .mockRejectedValue(new Error("catalog down") as never);
1416
+
1417
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1418
+
1419
+ expect(summary.stopReason).toBe("done");
1420
+ expect(summary.pagesFailed).toBe(0);
1421
+ expect(summary.rowsWritten).toBe(2);
1422
+ expect(logger.error).toHaveBeenCalledWith(
1423
+ expect.stringContaining("could not register"),
1424
+ );
1425
+ });
1426
+
1427
+ describe("retention", () => {
1428
+ async function retentionWritten(): Promise<unknown> {
1429
+ pages = [
1430
+ [makeApplication({ id: applicationId(1), projectId: PROJECT_A })],
1431
+ ];
1432
+ counters[dailyKey(PROJECT_A)] = 1000;
1433
+
1434
+ await sweep();
1435
+
1436
+ return insertedRows()[0]!["retentionDate"];
1437
+ }
1438
+
1439
+ function daysFromNow(days: number): string {
1440
+ return OneUptimeDate.toClickhouseDateTime(
1441
+ OneUptimeDate.addRemoveDays(new Date(NOW.getTime()), days),
1442
+ );
1443
+ }
1444
+
1445
+ test("follows the monitor metric retention setting", async () => {
1446
+ const config: GlobalConfig = new GlobalConfig();
1447
+ config.monitorMetricRetentionInDays = 7;
1448
+ retentionConfig = config;
1449
+
1450
+ expect(await retentionWritten()).toBe(daysFromNow(7));
1451
+ });
1452
+
1453
+ test.each([undefined, 0, -3])(
1454
+ "defaults to 30 days when the setting is %p",
1455
+ async (value: number | undefined) => {
1456
+ const config: GlobalConfig = new GlobalConfig();
1457
+
1458
+ if (value !== undefined) {
1459
+ config.monitorMetricRetentionInDays = value;
1460
+ }
1461
+
1462
+ retentionConfig = config;
1463
+
1464
+ expect(await retentionWritten()).toBe(daysFromNow(30));
1465
+ },
1466
+ );
1467
+
1468
+ test("defaults to 30 days when there is no config row", async () => {
1469
+ retentionConfig = null;
1470
+
1471
+ expect(await retentionWritten()).toBe(daysFromNow(30));
1472
+ });
1473
+
1474
+ test("defaults to 30 days when the setting cannot be read", async () => {
1475
+ jest
1476
+ .spyOn(GlobalConfigService, "findOneBy")
1477
+ .mockRejectedValue(new Error("Postgres is down") as never);
1478
+
1479
+ expect(await retentionWritten()).toBe(daysFromNow(30));
1480
+ });
1481
+
1482
+ test("reads the setting once per sweep, however many pages it has", async () => {
1483
+ pages = [
1484
+ fullPage(1, PROJECT_A),
1485
+ [makeApplication({ id: applicationId(9999), projectId: PROJECT_A })],
1486
+ ];
1487
+
1488
+ await sweep();
1489
+
1490
+ expect(GlobalConfigService.findOneBy).toHaveBeenCalledTimes(1);
1491
+ });
1492
+
1493
+ // The one global config row, as root, with the setting selected.
1494
+ test("reads the monitor metric retention off the global config row", async () => {
1495
+ await retentionWritten();
1496
+
1497
+ expect(GlobalConfigService.findOneBy).toHaveBeenCalledWith({
1498
+ query: {
1499
+ _id: ObjectID.getZeroObjectID().toString(),
1500
+ },
1501
+ select: {
1502
+ monitorMetricRetentionInDays: true,
1503
+ },
1504
+ props: {
1505
+ isRoot: true,
1506
+ },
1507
+ });
1508
+ });
1509
+ });
1510
+
1511
+ test("warns once when a daily counter is more than twice the worker's limit", async () => {
1512
+ pages = [
1513
+ [
1514
+ makeApplication({ id: applicationId(1), projectId: PROJECT_A }),
1515
+ makeApplication({ id: applicationId(2), projectId: PROJECT_B }),
1516
+ ],
1517
+ ];
1518
+ counters[dailyKey(PROJECT_A)] = 5 * DAILY_LIMIT;
1519
+ counters[dailyKey(PROJECT_B)] = 3 * DAILY_LIMIT;
1520
+
1521
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1522
+
1523
+ expect(summary.dailyLimitMismatchSuspected).toBe(true);
1524
+
1525
+ const mismatchWarnings: Array<unknown> = (
1526
+ logger.warn as unknown as jest.Mock
1527
+ ).mock.calls.filter((call: Array<unknown>) => {
1528
+ return String(call[0]).includes(
1529
+ "SESSION_REPLAY_MAX_BYTES_PER_PROJECT_PER_DAY",
1530
+ );
1531
+ });
1532
+
1533
+ expect(mismatchWarnings).toHaveLength(1);
1534
+
1535
+ /*
1536
+ * On Helm a component's own extraEnv replaces the chart-wide list, so
1537
+ * "set it chart-wide" alone does not reach a worker with its own list.
1538
+ */
1539
+ const warning: string = String((mismatchWarnings[0] as Array<unknown>)[0]);
1540
+
1541
+ expect(warning).toContain(`${DAILY_LIMIT} bytes`);
1542
+ expect(warning).toContain("chart-wide extraEnv");
1543
+ expect(warning).toContain("app.extraEnv or worker.extraEnv");
1544
+
1545
+ // It still publishes what it knows.
1546
+ expect(summary.rowsWritten).toBe(4);
1547
+ });
1548
+
1549
+ test("does not warn about the limit for an ordinary spent budget", async () => {
1550
+ pages = [[makeApplication({ id: applicationId(1), projectId: PROJECT_A })]];
1551
+ counters[dailyKey(PROJECT_A)] = DAILY_LIMIT + 2 * 1024 * 1024;
1552
+
1553
+ const summary: SessionReplayBudgetSweepSummary = await sweep();
1554
+
1555
+ expect(summary.dailyLimitMismatchSuspected).toBe(false);
1556
+ });
1557
+
1558
+ test("skips rows it cannot key and keeps the rest", async () => {
1559
+ const orphan: RumApplication = new RumApplication();
1560
+ orphan.id = applicationId(1);
1561
+
1562
+ pages = [
1563
+ [orphan, makeApplication({ id: applicationId(2), projectId: PROJECT_A })],
1564
+ ];
1565
+ counters[dailyKey(PROJECT_A)] = 1000;
1566
+
1567
+ await sweep();
1568
+
1569
+ expect(
1570
+ new Set(
1571
+ insertedRows().map((row: JSONObject) => {
1572
+ return row["primaryEntityId"];
1573
+ }),
1574
+ ),
1575
+ ).toEqual(new Set([applicationId(2).toString()]));
1576
+ });
1577
+
1578
+ /*
1579
+ * The keys are built from the wall clock at read time, in UTC, and the
1580
+ * counters roll over with them: the last sweep of a month still reads that
1581
+ * day and that month, and the first sweep after midnight reads the new
1582
+ * ones (where nothing has been charged yet).
1583
+ */
1584
+ describe("UTC day and month boundaries", () => {
1585
+ function keysReadAt(now: Date): Array<string> {
1586
+ jest.useFakeTimers({ now: now, doNotFake: ["nextTick", "setImmediate"] });
1587
+
1588
+ return [
1589
+ SessionReplayUsage.getDailyProjectByteKey(PROJECT_A),
1590
+ SessionReplayUsage.getMonthlyApplicationByteKey({
1591
+ projectId: PROJECT_A,
1592
+ rumApplicationId: applicationId(1),
1593
+ }),
1594
+ ];
1595
+ }
1596
+
1597
+ test("the last second of September reads September's day and month", async () => {
1598
+ const expectedKeys: Array<string> = keysReadAt(
1599
+ new Date("2026-09-30T23:59:59.000Z"),
1600
+ );
1601
+
1602
+ expect(expectedKeys).toEqual([
1603
+ `replay:rate:bytes:${PROJECT_A.toString()}:2026-09-30`,
1604
+ `replay:rate:bytes-month:${PROJECT_A.toString()}:${applicationId(1).toString()}:2026-09`,
1605
+ ]);
1606
+
1607
+ pages = [
1608
+ [
1609
+ makeApplication({
1610
+ id: applicationId(1),
1611
+ projectId: PROJECT_A,
1612
+ monthlyBudgetInGB: 1,
1613
+ }),
1614
+ ],
1615
+ ];
1616
+
1617
+ await sweep();
1618
+
1619
+ expect(counterReads).toEqual([expectedKeys]);
1620
+ });
1621
+
1622
+ test("the first second of October reads October's", async () => {
1623
+ const expectedKeys: Array<string> = keysReadAt(
1624
+ new Date("2026-10-01T00:00:00.000Z"),
1625
+ );
1626
+
1627
+ expect(expectedKeys).toEqual([
1628
+ `replay:rate:bytes:${PROJECT_A.toString()}:2026-10-01`,
1629
+ `replay:rate:bytes-month:${PROJECT_A.toString()}:${applicationId(1).toString()}:2026-10`,
1630
+ ]);
1631
+
1632
+ pages = [
1633
+ [
1634
+ makeApplication({
1635
+ id: applicationId(1),
1636
+ projectId: PROJECT_A,
1637
+ monthlyBudgetInGB: 1,
1638
+ }),
1639
+ ],
1640
+ ];
1641
+
1642
+ await sweep();
1643
+
1644
+ expect(counterReads).toEqual([expectedKeys]);
1645
+ });
1646
+ });
1647
+ });