@oneuptime/common 11.5.9 → 11.5.11

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 (379) hide show
  1. package/Models/AnalyticsModels/Index.ts +13 -0
  2. package/Models/AnalyticsModels/MetricItemAggMV1mByContainer.ts +178 -0
  3. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +179 -0
  4. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +179 -0
  5. package/Models/AnalyticsModels/NetworkFlow.ts +397 -0
  6. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +18 -0
  7. package/Models/DatabaseModels/GlobalConfig.ts +36 -0
  8. package/Models/DatabaseModels/Index.ts +2 -0
  9. package/Models/DatabaseModels/MarketingConversion.ts +198 -0
  10. package/Models/DatabaseModels/MetricSavedView.ts +34 -0
  11. package/Models/DatabaseModels/MetricType.ts +74 -0
  12. package/Models/DatabaseModels/NetworkDevice.ts +332 -0
  13. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +113 -0
  14. package/Models/DatabaseModels/NetworkInterface.ts +58 -0
  15. package/Models/DatabaseModels/Project.ts +37 -0
  16. package/Models/DatabaseModels/User.ts +37 -0
  17. package/Server/API/BaseAnalyticsAPI.ts +23 -0
  18. package/Server/API/EnterpriseLicenseAPI.ts +14 -0
  19. package/Server/EnvironmentConfig.ts +84 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1784191414522-AddCounterSemanticsToMetricType.ts +38 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1784191414600-AddViewTypeToMetricSavedView.ts +28 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1784211212164-AddNetworkDeviceInventoryAndDiscoverySchedule.ts +108 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1784218257664-AddEnterpriseLicenseNotificationColumns.ts +31 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1784293516000-AddAttributionColumnsToUserAndProject.ts +29 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1784298000000-AddMarketingConversionTable.ts +35 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.ts +101 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -0
  28. package/Server/Services/AnalyticsDatabaseService.ts +70 -1
  29. package/Server/Services/IncidentService.ts +13 -0
  30. package/Server/Services/Index.ts +10 -0
  31. package/Server/Services/MarketingConversionService.ts +10 -0
  32. package/Server/Services/MetricItemAggMV1mByContainerService.ts +31 -0
  33. package/Server/Services/MetricItemAggMV1mByK8sClusterService.ts +31 -0
  34. package/Server/Services/MetricItemAggMV1mByServiceService.ts +31 -0
  35. package/Server/Services/MetricService.ts +970 -122
  36. package/Server/Services/MonitorService.ts +14 -0
  37. package/Server/Services/NetworkDeviceOwnerTeamService.ts +42 -0
  38. package/Server/Services/NetworkDeviceOwnerUserService.ts +106 -0
  39. package/Server/Services/NetworkFlowService.ts +11 -0
  40. package/Server/Services/OnCallDutyPolicyService.ts +11 -1
  41. package/Server/Services/ProjectService.ts +136 -0
  42. package/Server/Services/SpanService.ts +13 -9
  43. package/Server/Services/StatusPageService.ts +10 -0
  44. package/Server/Services/TeamMemberService.ts +11 -0
  45. package/Server/Services/UserService.ts +59 -0
  46. package/Server/Types/AnalyticsDatabase/AggregateBy.ts +37 -7
  47. package/Server/Utils/Attribution.ts +99 -0
  48. package/Server/Utils/Marketing/ConversionUploadProvider.ts +103 -0
  49. package/Server/Utils/Marketing/ConversionUploadProviders.ts +22 -0
  50. package/Server/Utils/Marketing/Providers/GoogleAds.ts +270 -0
  51. package/Server/Utils/Marketing/Providers/LinkedIn.ts +147 -0
  52. package/Server/Utils/Marketing/Providers/Meta.ts +148 -0
  53. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +196 -0
  54. package/Server/Utils/Marketing/Providers/Reddit.ts +178 -0
  55. package/Server/Utils/Monitor/MonitorAlert.ts +71 -7
  56. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +31 -19
  57. package/Server/Utils/Monitor/MonitorIncident.ts +71 -7
  58. package/Server/Utils/Monitor/MonitorMetricUtil.ts +57 -0
  59. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +94 -0
  60. package/Server/Utils/Monitor/NetworkDeviceHydrationUtil.ts +15 -0
  61. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +125 -14
  62. package/Server/Utils/ProductAnalytics.ts +133 -0
  63. package/Server/Utils/Telemetry/Telemetry.ts +39 -0
  64. package/Server/Utils/Telemetry/TelemetryEntity.ts +21 -0
  65. package/Tests/Server/API/BaseAnalyticsAPI.test.ts +72 -0
  66. package/Tests/Server/Infrastructure/SemaphorePermit.test.ts +9 -1
  67. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +21 -2
  68. package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +173 -0
  69. package/Tests/Server/Services/MetricServiceAggregate.test.ts +1391 -0
  70. package/Tests/Server/Services/NetworkDeviceOwnerServices.test.ts +260 -0
  71. package/Tests/Server/Utils/Monitor/MonitorTemplateUtilNetworkDevice.test.ts +389 -0
  72. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +764 -0
  73. package/Tests/Types/BaseDatabase/AggregationIntervalUtil.test.ts +111 -2
  74. package/Tests/Types/Database/EndsWith.test.ts +64 -0
  75. package/Tests/Types/Database/GreaterThan.test.ts +62 -0
  76. package/Tests/Types/Database/GreaterThanOrEqual.test.ts +65 -0
  77. package/Tests/Types/Database/GreaterThanOrNull.test.ts +63 -0
  78. package/Tests/Types/Database/Includes.test.ts +65 -0
  79. package/Tests/Types/Database/IncludesAll.test.ts +65 -0
  80. package/Tests/Types/Database/IncludesNone.test.ts +65 -0
  81. package/Tests/Types/Database/IsNull.test.ts +44 -0
  82. package/Tests/Types/Database/LessThan.test.ts +62 -0
  83. package/Tests/Types/Database/LessThanOrEqual.test.ts +62 -0
  84. package/Tests/Types/Database/LessThanOrNull.test.ts +62 -0
  85. package/Tests/Types/Database/MultiSearch.test.ts +81 -0
  86. package/Tests/Types/Database/NotContains.test.ts +64 -0
  87. package/Tests/Types/Database/NotNull.test.ts +44 -0
  88. package/Tests/Types/Database/StartsWith.test.ts +64 -0
  89. package/Tests/Types/Monitor/MonitorStepSqlMonitor.test.ts +83 -1
  90. package/Tests/Types/Monitor/SnmpV3EnumParsing.test.ts +247 -0
  91. package/Tests/Types/Monitor/SnmpVendorTemplate.test.ts +263 -0
  92. package/Tests/UI/Components/XAxis.test.ts +5 -3
  93. package/Tests/Utils/EnterpriseLicenseUsage.test.ts +76 -0
  94. package/Tests/Utils/Metrics/MetricExplorerUrl.test.ts +622 -0
  95. package/Tests/Utils/Metrics/MetricFormulaEvaluator.test.ts +474 -0
  96. package/Tests/Utils/Telemetry/HeartbeatAvailability.test.ts +52 -4
  97. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +11 -0
  98. package/Types/BaseDatabase/AggregateBy.ts +31 -2
  99. package/Types/BaseDatabase/AggregatedResult.ts +22 -0
  100. package/Types/BaseDatabase/AggregationInterval.ts +11 -0
  101. package/Types/BaseDatabase/AggregationIntervalUtil.ts +30 -5
  102. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +16 -0
  103. package/Types/Email/EmailTemplateType.ts +3 -0
  104. package/Types/Marketing/MarketingConversion.ts +11 -0
  105. package/Types/Metrics/MetricQueryConfigData.ts +21 -0
  106. package/Types/Metrics/MetricQueryData.ts +8 -0
  107. package/Types/Metrics/MetricViewData.ts +8 -0
  108. package/Types/Monitor/MonitorMetricType.ts +8 -0
  109. package/Types/Monitor/MonitorStep.ts +9 -0
  110. package/Types/Monitor/MonitorStepSqlMonitor.ts +12 -0
  111. package/Types/Monitor/MonitorType.ts +2 -1
  112. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +17 -12
  113. package/Types/Monitor/SnmpMonitor/CdpNeighbor.ts +13 -0
  114. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +37 -3
  115. package/Types/Monitor/SnmpMonitor/SnmpAuthProtocol.ts +46 -0
  116. package/Types/Monitor/SnmpMonitor/SnmpEntityInfo.ts +15 -0
  117. package/Types/Monitor/SnmpMonitor/SnmpInterface.ts +8 -0
  118. package/Types/Monitor/SnmpMonitor/SnmpMonitorResponse.ts +17 -8
  119. package/Types/Monitor/SnmpMonitor/SnmpPrivProtocol.ts +53 -0
  120. package/Types/Monitor/SnmpMonitor/SnmpSecurityLevel.ts +43 -0
  121. package/Types/Monitor/SnmpMonitor/SnmpSystemInfo.ts +16 -0
  122. package/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.ts +119 -3
  123. package/Types/NetFlow/NetworkFlowRecord.ts +25 -0
  124. package/Types/Syslog/SyslogMessage.ts +18 -0
  125. package/Types/Telemetry/TelemetrySavedViewState.ts +9 -0
  126. package/Types/Telemetry/TelemetrySavedViewType.ts +12 -0
  127. package/UI/Components/Charts/Area/AreaChart.tsx +51 -0
  128. package/UI/Components/Charts/Bar/BarChart.tsx +51 -1
  129. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +97 -26
  130. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +328 -1
  131. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +115 -0
  132. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +328 -1
  133. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +7 -0
  134. package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +15 -0
  135. package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +13 -0
  136. package/UI/Components/Charts/Line/LineChart.tsx +51 -0
  137. package/UI/Components/Charts/Types/ReferenceRegionProps.ts +12 -0
  138. package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +12 -0
  139. package/UI/Components/Charts/Types/XAxis/XAxisPrecision.ts +1 -0
  140. package/UI/Components/Charts/Utils/DataPoint.ts +0 -0
  141. package/UI/Components/Charts/Utils/TimeAnnotation.ts +169 -0
  142. package/UI/Components/Charts/Utils/XAxis.ts +28 -4
  143. package/UI/Components/Icon/Icon.tsx +33 -13
  144. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +9 -0
  145. package/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.ts +67 -0
  146. package/UI/Components/Page/ModelPage.tsx +6 -1
  147. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +33 -4
  148. package/UI/Styles/Theme.css +10 -0
  149. package/UI/Utils/User.ts +41 -2
  150. package/Utils/Analytics.ts +8 -6
  151. package/Utils/EnterpriseLicense/EnterpriseLicenseUsage.ts +74 -0
  152. package/Utils/Metrics/MetricExplorerUrl.ts +771 -0
  153. package/Utils/Metrics/MetricFormulaEvaluator.ts +410 -46
  154. package/Utils/Monitor/NetworkTopologyUtil.ts +261 -49
  155. package/Utils/ValueFormatter.ts +22 -1
  156. package/build/dist/Models/AnalyticsModels/Index.js +13 -0
  157. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  158. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js +159 -0
  159. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js.map +1 -0
  160. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +158 -0
  161. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js.map +1 -0
  162. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +160 -0
  163. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js.map +1 -0
  164. package/build/dist/Models/AnalyticsModels/NetworkFlow.js +351 -0
  165. package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -0
  166. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +19 -0
  167. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  168. package/build/dist/Models/DatabaseModels/GlobalConfig.js +38 -0
  169. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  170. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  171. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  172. package/build/dist/Models/DatabaseModels/MarketingConversion.js +219 -0
  173. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +1 -0
  174. package/build/dist/Models/DatabaseModels/MetricSavedView.js +35 -0
  175. package/build/dist/Models/DatabaseModels/MetricSavedView.js.map +1 -1
  176. package/build/dist/Models/DatabaseModels/MetricType.js +76 -0
  177. package/build/dist/Models/DatabaseModels/MetricType.js.map +1 -1
  178. package/build/dist/Models/DatabaseModels/NetworkDevice.js +341 -0
  179. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  180. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +116 -0
  181. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  182. package/build/dist/Models/DatabaseModels/NetworkInterface.js +60 -0
  183. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  184. package/build/dist/Models/DatabaseModels/Project.js +38 -0
  185. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  186. package/build/dist/Models/DatabaseModels/User.js +38 -0
  187. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  188. package/build/dist/Server/API/BaseAnalyticsAPI.js +19 -0
  189. package/build/dist/Server/API/BaseAnalyticsAPI.js.map +1 -1
  190. package/build/dist/Server/API/EnterpriseLicenseAPI.js +8 -0
  191. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  192. package/build/dist/Server/EnvironmentConfig.js +51 -0
  193. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  194. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784191414522-AddCounterSemanticsToMetricType.js +27 -0
  195. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784191414522-AddCounterSemanticsToMetricType.js.map +1 -0
  196. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784191414600-AddViewTypeToMetricSavedView.js +21 -0
  197. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784191414600-AddViewTypeToMetricSavedView.js.map +1 -0
  198. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784211212164-AddNetworkDeviceInventoryAndDiscoverySchedule.js +47 -0
  199. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784211212164-AddNetworkDeviceInventoryAndDiscoverySchedule.js.map +1 -0
  200. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784218257664-AddEnterpriseLicenseNotificationColumns.js +16 -0
  201. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784218257664-AddEnterpriseLicenseNotificationColumns.js.map +1 -0
  202. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784293516000-AddAttributionColumnsToUserAndProject.js +18 -0
  203. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784293516000-AddAttributionColumnsToUserAndProject.js.map +1 -0
  204. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784298000000-AddMarketingConversionTable.js +20 -0
  205. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784298000000-AddMarketingConversionTable.js.map +1 -0
  206. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js +40 -0
  207. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784401962564-MigrationName.js.map +1 -0
  208. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -0
  209. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  210. package/build/dist/Server/Services/AnalyticsDatabaseService.js +58 -4
  211. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  212. package/build/dist/Server/Services/IncidentService.js +12 -0
  213. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  214. package/build/dist/Server/Services/Index.js +10 -0
  215. package/build/dist/Server/Services/Index.js.map +1 -1
  216. package/build/dist/Server/Services/MarketingConversionService.js +9 -0
  217. package/build/dist/Server/Services/MarketingConversionService.js.map +1 -0
  218. package/build/dist/Server/Services/MetricItemAggMV1mByContainerService.js +29 -0
  219. package/build/dist/Server/Services/MetricItemAggMV1mByContainerService.js.map +1 -0
  220. package/build/dist/Server/Services/MetricItemAggMV1mByK8sClusterService.js +29 -0
  221. package/build/dist/Server/Services/MetricItemAggMV1mByK8sClusterService.js.map +1 -0
  222. package/build/dist/Server/Services/MetricItemAggMV1mByServiceService.js +29 -0
  223. package/build/dist/Server/Services/MetricItemAggMV1mByServiceService.js.map +1 -0
  224. package/build/dist/Server/Services/MetricService.js +761 -125
  225. package/build/dist/Server/Services/MetricService.js.map +1 -1
  226. package/build/dist/Server/Services/MonitorService.js +18 -5
  227. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  228. package/build/dist/Server/Services/NetworkDeviceOwnerTeamService.js +44 -0
  229. package/build/dist/Server/Services/NetworkDeviceOwnerTeamService.js.map +1 -1
  230. package/build/dist/Server/Services/NetworkDeviceOwnerUserService.js +88 -0
  231. package/build/dist/Server/Services/NetworkDeviceOwnerUserService.js.map +1 -1
  232. package/build/dist/Server/Services/NetworkFlowService.js +9 -0
  233. package/build/dist/Server/Services/NetworkFlowService.js.map +1 -0
  234. package/build/dist/Server/Services/OnCallDutyPolicyService.js +15 -6
  235. package/build/dist/Server/Services/OnCallDutyPolicyService.js.map +1 -1
  236. package/build/dist/Server/Services/ProjectService.js +107 -6
  237. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  238. package/build/dist/Server/Services/SpanService.js +8 -6
  239. package/build/dist/Server/Services/SpanService.js.map +1 -1
  240. package/build/dist/Server/Services/StatusPageService.js +10 -0
  241. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  242. package/build/dist/Server/Services/TeamMemberService.js +11 -0
  243. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  244. package/build/dist/Server/Services/UserService.js +52 -0
  245. package/build/dist/Server/Services/UserService.js.map +1 -1
  246. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +33 -7
  247. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
  248. package/build/dist/Server/Utils/Attribution.js +74 -0
  249. package/build/dist/Server/Utils/Attribution.js.map +1 -0
  250. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +40 -0
  251. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +1 -0
  252. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js +20 -0
  253. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js.map +1 -0
  254. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +177 -0
  255. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +1 -0
  256. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +113 -0
  257. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +1 -0
  258. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +109 -0
  259. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +1 -0
  260. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +130 -0
  261. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +1 -0
  262. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +126 -0
  263. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +1 -0
  264. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +53 -9
  265. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  266. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +21 -10
  267. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  268. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +54 -11
  269. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  270. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +43 -3
  271. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  272. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +68 -0
  273. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  274. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js +15 -0
  275. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js.map +1 -1
  276. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +100 -15
  277. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  278. package/build/dist/Server/Utils/ProductAnalytics.js +111 -0
  279. package/build/dist/Server/Utils/ProductAnalytics.js.map +1 -0
  280. package/build/dist/Server/Utils/Telemetry/Telemetry.js +32 -5
  281. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  282. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -0
  283. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  284. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +11 -0
  285. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  286. package/build/dist/Types/BaseDatabase/AggregationInterval.js +11 -0
  287. package/build/dist/Types/BaseDatabase/AggregationInterval.js.map +1 -1
  288. package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js +33 -5
  289. package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js.map +1 -1
  290. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  291. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  292. package/build/dist/Types/Marketing/MarketingConversion.js +13 -0
  293. package/build/dist/Types/Marketing/MarketingConversion.js.map +1 -0
  294. package/build/dist/Types/Monitor/MonitorMetricType.js +7 -0
  295. package/build/dist/Types/Monitor/MonitorMetricType.js.map +1 -1
  296. package/build/dist/Types/Monitor/MonitorStep.js +6 -0
  297. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  298. package/build/dist/Types/Monitor/MonitorStepSqlMonitor.js +3 -0
  299. package/build/dist/Types/Monitor/MonitorStepSqlMonitor.js.map +1 -1
  300. package/build/dist/Types/Monitor/MonitorType.js +2 -1
  301. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  302. package/build/dist/Types/Monitor/SnmpMonitor/CdpNeighbor.js +2 -0
  303. package/build/dist/Types/Monitor/SnmpMonitor/CdpNeighbor.js.map +1 -0
  304. package/build/dist/Types/Monitor/SnmpMonitor/SnmpAuthProtocol.js +41 -0
  305. package/build/dist/Types/Monitor/SnmpMonitor/SnmpAuthProtocol.js.map +1 -1
  306. package/build/dist/Types/Monitor/SnmpMonitor/SnmpEntityInfo.js +2 -0
  307. package/build/dist/Types/Monitor/SnmpMonitor/SnmpEntityInfo.js.map +1 -0
  308. package/build/dist/Types/Monitor/SnmpMonitor/SnmpPrivProtocol.js +48 -0
  309. package/build/dist/Types/Monitor/SnmpMonitor/SnmpPrivProtocol.js.map +1 -1
  310. package/build/dist/Types/Monitor/SnmpMonitor/SnmpSecurityLevel.js +38 -0
  311. package/build/dist/Types/Monitor/SnmpMonitor/SnmpSecurityLevel.js.map +1 -1
  312. package/build/dist/Types/Monitor/SnmpMonitor/SnmpSystemInfo.js +2 -0
  313. package/build/dist/Types/Monitor/SnmpMonitor/SnmpSystemInfo.js.map +1 -0
  314. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js +96 -3
  315. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js.map +1 -1
  316. package/build/dist/Types/NetFlow/NetworkFlowRecord.js +2 -0
  317. package/build/dist/Types/NetFlow/NetworkFlowRecord.js.map +1 -0
  318. package/build/dist/Types/Syslog/SyslogMessage.js +2 -0
  319. package/build/dist/Types/Syslog/SyslogMessage.js.map +1 -0
  320. package/build/dist/Types/Telemetry/TelemetrySavedViewState.js +0 -7
  321. package/build/dist/Types/Telemetry/TelemetrySavedViewState.js.map +1 -1
  322. package/build/dist/Types/Telemetry/TelemetrySavedViewType.js +13 -0
  323. package/build/dist/Types/Telemetry/TelemetrySavedViewType.js.map +1 -0
  324. package/build/dist/UI/Components/Charts/Area/AreaChart.js +25 -1
  325. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  326. package/build/dist/UI/Components/Charts/Bar/BarChart.js +26 -2
  327. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  328. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +36 -12
  329. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  330. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +208 -6
  331. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  332. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +52 -3
  333. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  334. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +208 -6
  335. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  336. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +6 -1
  337. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  338. package/build/dist/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.js +2 -0
  339. package/build/dist/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.js.map +1 -0
  340. package/build/dist/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.js +2 -0
  341. package/build/dist/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.js.map +1 -0
  342. package/build/dist/UI/Components/Charts/Line/LineChart.js +25 -1
  343. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  344. package/build/dist/UI/Components/Charts/Types/ReferenceRegionProps.js +2 -0
  345. package/build/dist/UI/Components/Charts/Types/ReferenceRegionProps.js.map +1 -0
  346. package/build/dist/UI/Components/Charts/Types/TimeReferenceLineProps.js +2 -0
  347. package/build/dist/UI/Components/Charts/Types/TimeReferenceLineProps.js.map +1 -0
  348. package/build/dist/UI/Components/Charts/Types/XAxis/XAxisPrecision.js +1 -0
  349. package/build/dist/UI/Components/Charts/Types/XAxis/XAxisPrecision.js.map +1 -1
  350. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +0 -0
  351. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  352. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +121 -0
  353. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -0
  354. package/build/dist/UI/Components/Charts/Utils/XAxis.js +27 -4
  355. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  356. package/build/dist/UI/Components/Icon/Icon.js +15 -8
  357. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  358. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  359. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js +58 -0
  360. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js.map +1 -1
  361. package/build/dist/UI/Components/Page/ModelPage.js +7 -1
  362. package/build/dist/UI/Components/Page/ModelPage.js.map +1 -1
  363. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +10 -4
  364. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  365. package/build/dist/UI/Utils/User.js +35 -2
  366. package/build/dist/UI/Utils/User.js.map +1 -1
  367. package/build/dist/Utils/Analytics.js +8 -5
  368. package/build/dist/Utils/Analytics.js.map +1 -1
  369. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js +57 -0
  370. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js.map +1 -1
  371. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +418 -0
  372. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -0
  373. package/build/dist/Utils/Metrics/MetricFormulaEvaluator.js +296 -37
  374. package/build/dist/Utils/Metrics/MetricFormulaEvaluator.js.map +1 -1
  375. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +176 -40
  376. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  377. package/build/dist/Utils/ValueFormatter.js +20 -1
  378. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  379. package/package.json +4 -3
@@ -18,10 +18,23 @@ import AggregationInterval from "../../Types/BaseDatabase/AggregationInterval";
18
18
  import AggregatedResult from "../../Types/BaseDatabase/AggregatedResult";
19
19
  import AnalyticsTableName from "../../Types/AnalyticsDatabase/AnalyticsTableName";
20
20
  import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
21
+ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
21
22
  import BadDataException from "../../Types/Exception/BadDataException";
22
23
  import { JSONObject } from "../../Types/JSON";
23
- import { keyForHost } from "../../Utils/Telemetry/EntityKey";
24
+ import {
25
+ canonicalizeEntityValue,
26
+ keyForHost,
27
+ keyForKubernetesCluster,
28
+ keyForService,
29
+ } from "../../Utils/Telemetry/EntityKey";
30
+ import { keyForContainer } from "../Utils/Telemetry/TelemetryEntity";
31
+ import { getEntityBudget } from "../Utils/Telemetry/EntityRegistry";
32
+ import { EntityScopeQueryValue } from "../Utils/AnalyticsDatabase/StatementGenerator";
33
+ import TelemetryEntityService from "./TelemetryEntityService";
34
+ import TelemetryEntity from "../../Models/DatabaseModels/TelemetryEntity";
35
+ import EntityType from "../../Types/Telemetry/EntityType";
24
36
  import ObjectID from "../../Types/ObjectID";
37
+ import PositiveNumber from "../../Types/PositiveNumber";
25
38
  import logger, { LogAttributes } from "../Utils/Logger";
26
39
 
27
40
  /*
@@ -46,6 +59,62 @@ const MAX_GROUP_BY_ATTRIBUTE_KEY_LENGTH: number = 256;
46
59
  const POINT_TYPE_CACHE_TTL_MS: number = 10 * 60 * 1000;
47
60
  const POINT_TYPE_CACHE_MAX_ENTRIES: number = 5000;
48
61
 
62
+ /*
63
+ * Deliberately short: the registry gains a row for a NEW namespaced
64
+ * service variant asynchronously (throttled reconcile), and until this
65
+ * cache expires a routed service query would keep serving the stale
66
+ * (smaller) key set. 60s bounds that staleness to about one dashboard
67
+ * refresh while still collapsing the burst of per-metric aggregates a
68
+ * single refresh fires.
69
+ */
70
+ const SERVICE_ENTITY_KEYS_CACHE_TTL_MS: number = 60 * 1000;
71
+ const SERVICE_ENTITY_KEYS_CACHE_MAX_ENTRIES: number = 5000;
72
+
73
+ type EntityMVRoute = {
74
+ /** The resource attribute the entity detail pages filter by. */
75
+ attributeKey: string;
76
+ /** The per-entity 1-minute rollup that serves the filter. */
77
+ tableName: AnalyticsTableName;
78
+ /** Scalar entity-key column — same name on the raw table and the MV. */
79
+ keyColumn: string;
80
+ /**
81
+ * Read-side key derivation, byte-identical to the ingest stamp. Only
82
+ * single-attribute identities are derivable this way; `null` means the
83
+ * key set must come from the registry instead (service — ingest folds
84
+ * service.namespace into the key when present, so one name can map to
85
+ * several keys and a computed bare key would silently drop the
86
+ * namespaced variants' data).
87
+ */
88
+ keyForValue: ((projectId: string, value: string) => string) | null;
89
+ };
90
+
91
+ const ENTITY_MV_ROUTES: ReadonlyArray<EntityMVRoute> = [
92
+ {
93
+ attributeKey: "resource.host.name",
94
+ tableName: AnalyticsTableName.MetricItemAggMV1mByHostV2,
95
+ keyColumn: "hostEntityKey",
96
+ keyForValue: keyForHost,
97
+ },
98
+ {
99
+ attributeKey: "resource.k8s.cluster.name",
100
+ tableName: AnalyticsTableName.MetricItemAggMV1mByK8sCluster,
101
+ keyColumn: "k8sClusterEntityKey",
102
+ keyForValue: keyForKubernetesCluster,
103
+ },
104
+ {
105
+ attributeKey: "resource.container.id",
106
+ tableName: AnalyticsTableName.MetricItemAggMV1mByContainer,
107
+ keyColumn: "containerEntityKey",
108
+ keyForValue: keyForContainer,
109
+ },
110
+ {
111
+ attributeKey: "resource.service.name",
112
+ tableName: AnalyticsTableName.MetricItemAggMV1mByService,
113
+ keyColumn: "serviceEntityKey",
114
+ keyForValue: null,
115
+ },
116
+ ];
117
+
49
118
  export class MetricService extends AnalyticsDatabaseService<Metric> {
50
119
  /*
51
120
  * (projectId|metricName) -> metricPointType, resolved lazily on the
@@ -72,6 +141,33 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
72
141
  private inFlightPointTypeLookups: Map<string, Promise<string | null>> =
73
142
  new Map();
74
143
 
144
+ /*
145
+ * Registry-resolved serviceEntityKey set per aggregateBy call, for
146
+ * queries filtering by `resource.service.name`. Same hand-over pattern
147
+ * as the point-type hint: toAggregateStatement is synchronous, so the
148
+ * async Postgres lookup happens in aggregateBy and is keyed on the
149
+ * (shared) aggregateBy object. An empty array means "registry has no
150
+ * rows for this name" — the builder then refuses to route (raw path).
151
+ */
152
+ private serviceEntityKeysHintByAggregate: WeakMap<
153
+ AggregateBy<Metric>,
154
+ Array<string>
155
+ > = new WeakMap();
156
+
157
+ /*
158
+ * (projectId|canonical service name) -> serviceEntityKey set. Bounded +
159
+ * short-TTL'd (see SERVICE_ENTITY_KEYS_CACHE_TTL_MS): a dashboard
160
+ * refresh fires one aggregate per metric name, all for the same
161
+ * service, and each would otherwise hit Postgres.
162
+ */
163
+ private serviceEntityKeysCache: Map<
164
+ string,
165
+ { keys: Array<string>; expiresAt: number }
166
+ > = new Map();
167
+
168
+ private inFlightServiceEntityKeyLookups: Map<string, Promise<Array<string>>> =
169
+ new Map();
170
+
75
171
  public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
76
172
  super({ modelType: Metric, database: clickhouseDatabase });
77
173
  }
@@ -96,11 +192,244 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
96
192
  const pointType: string | null =
97
193
  await this.resolveMetricPointType(aggregateBy);
98
194
  this.pointTypeHintByAggregate.set(aggregateBy, pointType);
195
+
196
+ /*
197
+ * Service-scoped queries need the registry-resolved serviceEntityKey
198
+ * set before the synchronous statement builder runs. Skipped when
199
+ * the point type already rules out MV routing (distribution
200
+ * metrics never touch the rollups).
201
+ */
202
+ if (!pointType || !DISTRIBUTION_POINT_TYPES.includes(pointType)) {
203
+ const serviceKeys: Array<string> | null =
204
+ await this.resolveServiceEntityKeysHint(aggregateBy);
205
+ if (serviceKeys) {
206
+ this.serviceEntityKeysHintByAggregate.set(aggregateBy, serviceKeys);
207
+ }
208
+ }
99
209
  }
100
210
 
101
211
  return super.aggregateBy(aggregateBy);
102
212
  }
103
213
 
214
+ /**
215
+ * Resolves the serviceEntityKey set for a query whose only entity
216
+ * filter is a `resource.service.name` equality — the shape (and the
217
+ * only shape) the entity-MV builder routes to the per-service rollup.
218
+ * Returns null for every other query shape so no lookup is wasted.
219
+ */
220
+ private async resolveServiceEntityKeysHint(
221
+ aggregateBy: AggregateBy<Metric>,
222
+ ): Promise<Array<string> | null> {
223
+ const queryRecord: Record<string, unknown> =
224
+ (aggregateBy.query as unknown as Record<string, unknown>) || {};
225
+
226
+ // service + entityScope is never routed (see the entity-MV builder).
227
+ if (
228
+ queryRecord["entityScope"] !== undefined &&
229
+ queryRecord["entityScope"] !== null
230
+ ) {
231
+ return null;
232
+ }
233
+
234
+ const attrs: unknown = queryRecord["attributes"];
235
+ if (!attrs || typeof attrs !== "object") {
236
+ return null;
237
+ }
238
+ const attrEntries: Array<[string, unknown]> = Object.entries(
239
+ attrs as Record<string, unknown>,
240
+ );
241
+ if (attrEntries.length !== 1) {
242
+ return null;
243
+ }
244
+ const [attrKey, attrValue] = attrEntries[0]!;
245
+ if (
246
+ attrKey !== "resource.service.name" ||
247
+ typeof attrValue !== "string" ||
248
+ !attrValue
249
+ ) {
250
+ return null;
251
+ }
252
+
253
+ const projectIdValue: unknown = queryRecord["projectId"];
254
+ let projectId: string = "";
255
+ if (projectIdValue instanceof ObjectID) {
256
+ projectId = projectIdValue.toString();
257
+ } else if (typeof projectIdValue === "string") {
258
+ projectId = projectIdValue;
259
+ }
260
+ if (!projectId) {
261
+ return null;
262
+ }
263
+
264
+ const cacheKey: string = `${projectId}|${canonicalizeEntityValue(
265
+ attrValue,
266
+ )}`;
267
+ const cached: { keys: Array<string>; expiresAt: number } | undefined =
268
+ this.serviceEntityKeysCache.get(cacheKey);
269
+ if (cached && cached.expiresAt > Date.now()) {
270
+ return cached.keys;
271
+ }
272
+
273
+ const inFlight: Promise<Array<string>> | undefined =
274
+ this.inFlightServiceEntityKeyLookups.get(cacheKey);
275
+ if (inFlight) {
276
+ return inFlight;
277
+ }
278
+
279
+ const lookup: Promise<Array<string>> = this.lookupServiceEntityKeys(
280
+ projectId,
281
+ attrValue,
282
+ )
283
+ .then((keys: Array<string>) => {
284
+ this.evictExpiredServiceEntityKeyCacheEntries();
285
+ this.serviceEntityKeysCache.set(cacheKey, {
286
+ keys,
287
+ expiresAt: Date.now() + SERVICE_ENTITY_KEYS_CACHE_TTL_MS,
288
+ });
289
+ return keys;
290
+ })
291
+ .finally(() => {
292
+ this.inFlightServiceEntityKeyLookups.delete(cacheKey);
293
+ });
294
+
295
+ this.inFlightServiceEntityKeyLookups.set(cacheKey, lookup);
296
+ return lookup;
297
+ }
298
+
299
+ /**
300
+ * All serviceEntityKeys the ingest pipeline can have stamped for rows
301
+ * reporting this `service.name`, from the Postgres TelemetryEntity
302
+ * registry. Service identity folds `service.namespace` into the key
303
+ * when the resource carries one (see the service resolver in
304
+ * Common/Server/Utils/Telemetry/TelemetryEntity), so one name maps to
305
+ * one key per namespace variant — only the registry knows them all.
306
+ *
307
+ * `displayName` equals the canonicalized service.name for every
308
+ * Service row the registry mints (deriveDisplayName prefers the
309
+ * `service.name` identifying attribute), so the query is indexed and
310
+ * tiny; the identifyingAttributes re-check below guards against a
311
+ * displayName that came from some other identity shape. Returns []
312
+ * on a registry miss, on any lookup failure, or when the project's
313
+ * Service registry is at/over its entity budget (new variants stop
314
+ * minting rows there, so the key set can be permanently partial) —
315
+ * the caller then refuses to route (an MV query must never return
316
+ * less data than the raw predicate it replaces, and rows of a
317
+ * namespaced service the registry does not know about would be
318
+ * silently dropped).
319
+ */
320
+ private async lookupServiceEntityKeys(
321
+ projectId: string,
322
+ serviceName: string,
323
+ ): Promise<Array<string>> {
324
+ try {
325
+ const canonicalName: string = canonicalizeEntityValue(serviceName);
326
+
327
+ const rows: Array<TelemetryEntity> = await TelemetryEntityService.findBy({
328
+ query: {
329
+ projectId: new ObjectID(projectId),
330
+ entityType: EntityType.Service,
331
+ displayName: canonicalName,
332
+ },
333
+ select: {
334
+ entityKey: true,
335
+ identifyingAttributes: true,
336
+ },
337
+ limit: LIMIT_PER_PROJECT,
338
+ skip: 0,
339
+ props: { isRoot: true },
340
+ });
341
+
342
+ const keys: Set<string> = new Set<string>();
343
+ for (const row of rows) {
344
+ const identifying: Record<string, unknown> =
345
+ (row.identifyingAttributes as Record<string, unknown>) || {};
346
+ if (identifying["service.name"] !== canonicalName) {
347
+ continue;
348
+ }
349
+ if (typeof row.entityKey === "string" && row.entityKey) {
350
+ keys.add(row.entityKey);
351
+ }
352
+ }
353
+
354
+ if (keys.size === 0) {
355
+ return [];
356
+ }
357
+
358
+ /*
359
+ * Budget-capped projects must not route. At/over the per-type
360
+ * entity budget, ingest stops minting NEW Service registry rows
361
+ * forever (TelemetryEntityService.beforeCreate) while namespaced
362
+ * serviceEntityKeys keep flowing on signal rows — so a non-empty
363
+ * registry key set may be PERMANENTLY missing identity variants,
364
+ * and a routed `serviceEntityKey IN (...)` would silently return
365
+ * less data than the raw attributes predicate it replaces (the
366
+ * invariant documented above). Same signal as the ingest-side
367
+ * gate; the [] is cached for 60s alongside key hits, so the extra
368
+ * countBy amortizes to one per (project, service name) per minute.
369
+ */
370
+ const serviceEntityCount: PositiveNumber =
371
+ await TelemetryEntityService.countBy({
372
+ query: {
373
+ projectId: new ObjectID(projectId),
374
+ entityType: EntityType.Service,
375
+ },
376
+ props: { isRoot: true },
377
+ });
378
+ if (
379
+ serviceEntityCount.toNumber() >= getEntityBudget(EntityType.Service)
380
+ ) {
381
+ logger.debug(
382
+ "Service entity registry is budget-capped; refusing MV routing",
383
+ );
384
+ return [];
385
+ }
386
+
387
+ /*
388
+ * Union in the deterministic namespace-less key: a service that
389
+ * ALSO reports without a namespace may not have minted its bare
390
+ * registry row yet (reconcile is throttled and budget-gated), and
391
+ * adding a key can only widen the MV result, never narrow it.
392
+ */
393
+ keys.add(keyForService(projectId, serviceName));
394
+
395
+ return Array.from(keys).sort();
396
+ } catch (err) {
397
+ /*
398
+ * Lookup failures must not fail the aggregation — without the hint
399
+ * the builder falls back to the raw table, which is always correct.
400
+ */
401
+ logger.debug("Service entity key lookup failed");
402
+ logger.debug(err);
403
+ return [];
404
+ }
405
+ }
406
+
407
+ private evictExpiredServiceEntityKeyCacheEntries(): void {
408
+ if (
409
+ this.serviceEntityKeysCache.size < SERVICE_ENTITY_KEYS_CACHE_MAX_ENTRIES
410
+ ) {
411
+ return;
412
+ }
413
+ const now: number = Date.now();
414
+ for (const [key, entry] of this.serviceEntityKeysCache) {
415
+ if (entry.expiresAt <= now) {
416
+ this.serviceEntityKeysCache.delete(key);
417
+ }
418
+ }
419
+ // Mirrors evictExpiredPointTypeCacheEntries: never clear() wholesale.
420
+ while (
421
+ this.serviceEntityKeysCache.size >= SERVICE_ENTITY_KEYS_CACHE_MAX_ENTRIES
422
+ ) {
423
+ const oldestKey: string | undefined = this.serviceEntityKeysCache
424
+ .keys()
425
+ .next().value;
426
+ if (oldestKey === undefined) {
427
+ break;
428
+ }
429
+ this.serviceEntityKeysCache.delete(oldestKey);
430
+ }
431
+ }
432
+
104
433
  private async resolveMetricPointType(
105
434
  aggregateBy: AggregateBy<Metric>,
106
435
  ): Promise<string | null> {
@@ -336,6 +665,175 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
336
665
  statement.append(`) AS attributes`);
337
666
  }
338
667
 
668
+ /**
669
+ * Validated Top-K spec, or null when the caller did not request one.
670
+ * `count` is parameter-bound where it reaches SQL and `rankBy` only
671
+ * ever selects between two hardcoded aggregate names, so this is
672
+ * shape validation (like getSanitizedGroupByAttributeKeys), not
673
+ * injection defense.
674
+ */
675
+ private getSanitizedTopK(
676
+ aggregateBy: AggregateBy<Metric>,
677
+ ): { count: number; rankBy: "max" | "avg" } | null {
678
+ const topK: AggregateBy<Metric>["topK"] = aggregateBy.topK;
679
+ if (!topK) {
680
+ return null;
681
+ }
682
+ const count: number = Math.floor(Number(topK.count));
683
+ if (!Number.isFinite(count) || count < 1 || count > LIMIT_PER_PROJECT) {
684
+ throw new BadDataException(
685
+ `topK.count must be a positive integer <= ${LIMIT_PER_PROJECT}.`,
686
+ );
687
+ }
688
+ if (topK.rankBy !== "max" && topK.rankBy !== "avg") {
689
+ throw new BadDataException(`topK.rankBy must be 'max' or 'avg'.`);
690
+ }
691
+ return { count, rankBy: topK.rankBy };
692
+ }
693
+
694
+ /**
695
+ * Appends the raw-table expressions that identify a group — model
696
+ * group-by columns as identifiers, attribute keys as parameter-bound
697
+ * `attributes[...]` lookups — in a FIXED order, so the Top-K
698
+ * IN-restriction tuple, the ranking subquery's SELECT/GROUP BY, and
699
+ * the totalGroups counter always line up element-for-element.
700
+ */
701
+ private appendRawGroupExpressionList(
702
+ statement: Statement,
703
+ groupByKeys: Array<string>,
704
+ attributeGroupKeys: Array<string>,
705
+ ): void {
706
+ let isFirst: boolean = true;
707
+ for (const key of groupByKeys) {
708
+ if (!isFirst) {
709
+ statement.append(`, `);
710
+ }
711
+ statement.append(key);
712
+ isFirst = false;
713
+ }
714
+ for (const key of attributeGroupKeys) {
715
+ if (!isFirst) {
716
+ statement.append(`, `);
717
+ }
718
+ statement.append(
719
+ SQL`attributes[${{ value: key, type: TableColumnType.Text }}]`,
720
+ );
721
+ isFirst = false;
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Appends the Top-K group restriction predicate:
727
+ *
728
+ * ` AND (<group exprs>) GLOBAL IN (SELECT <group exprs> FROM <table>
729
+ * WHERE ... GROUP BY <group exprs>
730
+ * ORDER BY max|avg(<column>) DESC LIMIT <k>)`
731
+ *
732
+ * The ranking subquery scores each group over the WHOLE query window
733
+ * (no time bucketing) so a series that spiked early ranks the same as
734
+ * one spiking late. Must be appended in raw-table WHERE scope — the
735
+ * innermost WHERE of the surrounding builder — where the group
736
+ * expressions are valid. Ranking uses plain max/avg of the raw
737
+ * column (not the distribution-aware expressions): for distribution
738
+ * metrics that scores by per-export-interval sums, which preserves
739
+ * relative group ordering well enough for series selection.
740
+ *
741
+ * GLOBAL IN is load-bearing: this predicate sits in a query on the
742
+ * Distributed Metric table whose subquery reads the SAME Distributed
743
+ * table, which multi-shard ClickHouse rejects outright as a
744
+ * double-distributed subquery (Code 288, distributed_product_mode =
745
+ * 'deny' by default) — every grouped Top-K chart would 500 on a
746
+ * 2+-shard cluster. A shard-local rewrite would be wrong anyway: the
747
+ * sharding key is cityHash64(projectId, name, primaryEntityId), so
748
+ * one project+metric's groups span shards and the ranking must be
749
+ * computed once globally, not per shard. On a single shard GLOBAL is
750
+ * a semantic no-op. (The mutable-metric twin below deliberately stays
751
+ * plain IN: its predicate lives in an initiator-evaluated outer query
752
+ * over a subquery-FROM, which is not subject to the denial.)
753
+ */
754
+ private appendTopKGroupRestriction(data: {
755
+ statement: Statement;
756
+ databaseName: string;
757
+ aggregateBy: AggregateBy<Metric>;
758
+ topK: { count: number; rankBy: "max" | "avg" };
759
+ groupByKeys: Array<string>;
760
+ attributeGroupKeys: Array<string>;
761
+ }): void {
762
+ const statement: Statement = data.statement;
763
+ const aggregationColumn: string =
764
+ data.aggregateBy.aggregateColumnName.toString();
765
+
766
+ statement.append(` AND (`);
767
+ this.appendRawGroupExpressionList(
768
+ statement,
769
+ data.groupByKeys,
770
+ data.attributeGroupKeys,
771
+ );
772
+ statement.append(`) GLOBAL IN (SELECT `);
773
+ this.appendRawGroupExpressionList(
774
+ statement,
775
+ data.groupByKeys,
776
+ data.attributeGroupKeys,
777
+ );
778
+ statement.append(
779
+ ` FROM ${data.databaseName}.${this.model.tableName} WHERE TRUE `,
780
+ );
781
+ statement.append(
782
+ this.statementGenerator.toWhereStatement(data.aggregateBy.query),
783
+ );
784
+ statement.append(this.getRetentionReadFilter());
785
+ statement.append(` GROUP BY `);
786
+ this.appendRawGroupExpressionList(
787
+ statement,
788
+ data.groupByKeys,
789
+ data.attributeGroupKeys,
790
+ );
791
+ statement.append(
792
+ ` ORDER BY ${data.topK.rankBy}(${aggregationColumn}) DESC`,
793
+ );
794
+ statement.append(
795
+ SQL` LIMIT ${{
796
+ value: data.topK.count,
797
+ type: TableColumnType.Number,
798
+ }}`,
799
+ );
800
+ statement.append(`)`);
801
+ }
802
+
803
+ /**
804
+ * Appends `, (SELECT uniqExact(<group exprs>) FROM <table>
805
+ * WHERE ...) AS __total_groups` — a scalar subquery counting every
806
+ * distinct group in the window BEFORE Top-K trims them. The value is
807
+ * constant across result rows; AnalyticsDatabaseService._aggregateBy
808
+ * reads it off the first row into AggregatedResult.totalGroups and it
809
+ * never reaches the per-row payload. Must be appended in SELECT-list
810
+ * position of the outer query.
811
+ */
812
+ private appendTotalGroupsColumn(data: {
813
+ statement: Statement;
814
+ databaseName: string;
815
+ aggregateBy: AggregateBy<Metric>;
816
+ groupByKeys: Array<string>;
817
+ attributeGroupKeys: Array<string>;
818
+ }): void {
819
+ const statement: Statement = data.statement;
820
+
821
+ statement.append(`, (SELECT uniqExact(`);
822
+ this.appendRawGroupExpressionList(
823
+ statement,
824
+ data.groupByKeys,
825
+ data.attributeGroupKeys,
826
+ );
827
+ statement.append(
828
+ `) FROM ${data.databaseName}.${this.model.tableName} WHERE TRUE `,
829
+ );
830
+ statement.append(
831
+ this.statementGenerator.toWhereStatement(data.aggregateBy.query),
832
+ );
833
+ statement.append(this.getRetentionReadFilter());
834
+ statement.append(`) AS __total_groups`);
835
+ }
836
+
339
837
  /*
340
838
  * Cascade deletes from `MetricItemV3` into the aggregating
341
839
  * materialized-view target tables.
@@ -352,10 +850,11 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
352
850
  * The cascade only runs when the caller scoped the delete by
353
851
  * `primaryEntityId`. Global time-based purges (TTL cleanup) are handled
354
852
  * by each MV table's own `retentionDate TTL DELETE`, so cascading those
355
- * would pointlessly scan the whole MV. The per-host MV
356
- * (`MetricItemAggMV1mByHostV2`) is keyed by `hostEntityKey` rather than
357
- * `primaryEntityId`, so an entity-scoped delete has nothing to remove
358
- * there skip it.
853
+ * would pointlessly scan the whole MV. The per-entity MVs
854
+ * (`MetricItemAggMV1mByHostV2` / `...ByService` / `...ByK8sCluster` /
855
+ * `...ByContainer`) are keyed by their scalar entity-key column rather
856
+ * than `primaryEntityId`, so an entity-scoped delete has nothing to
857
+ * remove there — skip them.
359
858
  */
360
859
  public override async deleteBy(deleteBy: DeleteBy<Metric>): Promise<void> {
361
860
  await super.deleteBy(deleteBy);
@@ -498,11 +997,12 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
498
997
 
499
998
  if (!isPercentileAggregation(aggregateBy.aggregationType)) {
500
999
  /*
501
- * Try the per-host MV first — host detail pages are the
502
- * dominant attribute-filtered path and the per-host MV is
503
- * the only one that can serve them. If it doesn't apply
504
- * (no host filter, or extra attrs/groupBy), fall through
505
- * to the project/primaryEntityId MV, then to the base table.
1000
+ * Try the per-entity MVs first — entity detail pages (host, k8s
1001
+ * cluster, container, service) are the dominant filtered path and
1002
+ * the entity-keyed rollups are the only ones that can serve them.
1003
+ * If none applies (no entity filter, or extra attrs/groupBy), fall
1004
+ * through to the project/primaryEntityId MV, then to the base
1005
+ * table.
506
1006
  *
507
1007
  * Distribution metrics (histograms/summaries) must skip the
508
1008
  * MVs entirely: their states collapse `value`, which for
@@ -514,12 +1014,12 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
514
1014
  attributeGroupKeys.length === 0 &&
515
1015
  !this.isDistributionMetricAggregate(aggregateBy)
516
1016
  ) {
517
- const hostMvStatement: {
1017
+ const entityMvStatement: {
518
1018
  statement: Statement;
519
1019
  columns: Array<string>;
520
- } | null = this.tryBuildHostAggregateMVStatement(aggregateBy);
521
- if (hostMvStatement) {
522
- return hostMvStatement;
1020
+ } | null = this.tryBuildEntityAggregateMVStatement(aggregateBy);
1021
+ if (entityMvStatement) {
1022
+ return entityMvStatement;
523
1023
  }
524
1024
  const mvStatement: {
525
1025
  statement: Statement;
@@ -693,6 +1193,30 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
693
1193
  */
694
1194
  this.appendAttributeGroupMapColumn(statement, attributeGroupKeys);
695
1195
 
1196
+ /*
1197
+ * Server-side Top-K: rank groups over the whole window, restrict
1198
+ * the bucketed quantile to the winners, and surface the pre-trim
1199
+ * group count. Only meaningful when the aggregation is grouped —
1200
+ * p95-by-host must get the same series selection as the scalar
1201
+ * paths.
1202
+ */
1203
+ const percentileTopK: { count: number; rankBy: "max" | "avg" } | null =
1204
+ this.getSanitizedTopK(aggregateBy);
1205
+ const percentileApplyTopK: boolean = Boolean(
1206
+ percentileTopK &&
1207
+ (groupByKeys.length > 0 || attributeGroupKeys.length > 0),
1208
+ );
1209
+
1210
+ if (percentileApplyTopK) {
1211
+ this.appendTotalGroupsColumn({
1212
+ statement,
1213
+ databaseName,
1214
+ aggregateBy,
1215
+ groupByKeys,
1216
+ attributeGroupKeys,
1217
+ });
1218
+ }
1219
+
696
1220
  statement.append(SQL` FROM (`);
697
1221
  statement.append(`SELECT ${innerSelectClause}`);
698
1222
  this.appendAttributeGroupExtractionColumns(statement, attributeGroupKeys);
@@ -701,6 +1225,16 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
701
1225
  );
702
1226
  statement.append(whereStatement);
703
1227
  statement.append(this.getRetentionReadFilter());
1228
+ if (percentileApplyTopK) {
1229
+ this.appendTopKGroupRestriction({
1230
+ statement,
1231
+ databaseName,
1232
+ aggregateBy,
1233
+ topK: percentileTopK!,
1234
+ groupByKeys,
1235
+ attributeGroupKeys,
1236
+ });
1237
+ }
704
1238
  statement.append(SQL`) `);
705
1239
 
706
1240
  /*
@@ -748,16 +1282,28 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
748
1282
 
749
1283
  /*
750
1284
  * Match the read-path settings the base aggregator now appends (see
751
- * AnalyticsDatabaseService.toAggregateStatement). The percentile
752
- * path bypasses the base method, so we mirror them here to keep
753
- * cluster behavior consistent across aggregation kinds.
1285
+ * AnalyticsDatabaseService.toAggregateStatement), including the 45s
1286
+ * execution cap and the caller-selected overflow mode. The
1287
+ * percentile path bypasses the base method, so we mirror them here
1288
+ * to keep cluster behavior consistent across aggregation kinds.
1289
+ *
1290
+ * transform_null_in=1 rides along only when the Top-K restriction
1291
+ * is present: grouping by a Nullable model column (metricPointType,
1292
+ * unit, ...) puts NULL group keys in play, and under the ClickHouse
1293
+ * default (transform_null_in=0) NULL never matches an IN — a
1294
+ * NULL-keyed group could win a ranking slot yet return zero rows.
1295
+ * Query-wide it is safe here: user-filter IN lists are
1296
+ * parameter-bound scalar arrays that never carry NULL.
754
1297
  */
755
1298
  statement.append(
756
1299
  getQuerySettings({
1300
+ maxExecutionTimeInSeconds: 45,
1301
+ timeoutOverflowMode: this.getTimeoutOverflowMode(aggregateBy),
757
1302
  additionalSettings: {
758
1303
  optimize_aggregation_in_order: 1,
759
1304
  optimize_move_to_prewhere: 1,
760
1305
  max_threads: 4,
1306
+ ...(percentileApplyTopK ? { transform_null_in: 1 } : {}),
761
1307
  },
762
1308
  }),
763
1309
  );
@@ -898,6 +1444,17 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
898
1444
 
899
1445
  const statement: Statement = SQL``;
900
1446
 
1447
+ /*
1448
+ * Server-side Top-K: rank groups over the whole window, restrict
1449
+ * the bucketed aggregation to the winners, and surface the pre-trim
1450
+ * group count. Only meaningful when the aggregation is grouped.
1451
+ */
1452
+ const scalarTopK: { count: number; rankBy: "max" | "avg" } | null =
1453
+ this.getSanitizedTopK(aggregateBy);
1454
+ const scalarApplyTopK: boolean = Boolean(
1455
+ scalarTopK && (groupByKeys.length > 0 || attributeGroupKeys.length > 0),
1456
+ );
1457
+
901
1458
  statement.append(
902
1459
  `SELECT ${aggregationExpression} as ${aggregationColumn}, ${AggregateUtil.buildBucketTimestampSelect(resolvedInterval, aggregationTimestampColumn)}`,
903
1460
  );
@@ -905,6 +1462,15 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
905
1462
  statement.append(`, ${key}`);
906
1463
  }
907
1464
  this.appendAttributeGroupMapColumn(statement, attributeGroupKeys);
1465
+ if (scalarApplyTopK) {
1466
+ this.appendTotalGroupsColumn({
1467
+ statement,
1468
+ databaseName,
1469
+ aggregateBy,
1470
+ groupByKeys,
1471
+ attributeGroupKeys,
1472
+ });
1473
+ }
908
1474
 
909
1475
  if (attributeGroupKeys.length > 0) {
910
1476
  /*
@@ -935,6 +1501,16 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
935
1501
  );
936
1502
  statement.append(whereStatement);
937
1503
  statement.append(this.getRetentionReadFilter());
1504
+ if (scalarApplyTopK) {
1505
+ this.appendTopKGroupRestriction({
1506
+ statement,
1507
+ databaseName,
1508
+ aggregateBy,
1509
+ topK: scalarTopK!,
1510
+ groupByKeys,
1511
+ attributeGroupKeys,
1512
+ });
1513
+ }
938
1514
  statement.append(SQL`) `);
939
1515
  } else {
940
1516
  statement.append(
@@ -942,6 +1518,16 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
942
1518
  );
943
1519
  statement.append(whereStatement);
944
1520
  statement.append(this.getRetentionReadFilter());
1521
+ if (scalarApplyTopK) {
1522
+ this.appendTopKGroupRestriction({
1523
+ statement,
1524
+ databaseName,
1525
+ aggregateBy,
1526
+ topK: scalarTopK!,
1527
+ groupByKeys,
1528
+ attributeGroupKeys,
1529
+ });
1530
+ }
945
1531
  }
946
1532
 
947
1533
  /*
@@ -986,12 +1572,19 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
986
1572
  }} `,
987
1573
  );
988
1574
 
1575
+ /*
1576
+ * transform_null_in=1 only when the Top-K IN restriction is present
1577
+ * — see the percentile path's settings comment for the rationale.
1578
+ */
989
1579
  statement.append(
990
1580
  getQuerySettings({
1581
+ maxExecutionTimeInSeconds: 45,
1582
+ timeoutOverflowMode: this.getTimeoutOverflowMode(aggregateBy),
991
1583
  additionalSettings: {
992
1584
  optimize_aggregation_in_order: 1,
993
1585
  optimize_move_to_prewhere: 1,
994
1586
  max_threads: 4,
1587
+ ...(scalarApplyTopK ? { transform_null_in: 1 } : {}),
995
1588
  },
996
1589
  }),
997
1590
  );
@@ -1049,11 +1642,37 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1049
1642
  aggregateBy.sort!,
1050
1643
  );
1051
1644
 
1645
+ /*
1646
+ * Server-side Top-K for the (model-column) grouped mutable path.
1647
+ * The ranking subquery and totalGroups counter each re-run the
1648
+ * dedup subquery (buildMutableMetricDedupSource) — acceptable
1649
+ * because the mutable table is small by design (bounded-cardinality
1650
+ * business metrics), unlike the raw Metric table.
1651
+ */
1652
+ const mutableTopK: { count: number; rankBy: "max" | "avg" } | null =
1653
+ this.getSanitizedTopK(aggregateBy);
1654
+ const mutableGroupByKeys: Array<string> = aggregateBy.groupBy
1655
+ ? Object.keys(aggregateBy.groupBy)
1656
+ : [];
1657
+ const mutableApplyTopK: boolean = Boolean(
1658
+ mutableTopK && mutableGroupByKeys.length > 0,
1659
+ );
1660
+
1052
1661
  const statement: Statement = SQL``;
1053
1662
 
1663
+ statement.append(SQL`SELECT `).append(select.statement);
1664
+
1665
+ if (mutableApplyTopK) {
1666
+ statement.append(`, (SELECT uniqExact(`);
1667
+ this.appendRawGroupExpressionList(statement, mutableGroupByKeys, []);
1668
+ statement.append(`)`);
1669
+ statement.append(
1670
+ this.buildMutableMetricDedupSource(databaseName, aggregateBy),
1671
+ );
1672
+ statement.append(`) AS __total_groups`);
1673
+ }
1674
+
1054
1675
  statement
1055
- .append(SQL`SELECT `)
1056
- .append(select.statement)
1057
1676
  .append(
1058
1677
  SQL`
1059
1678
  FROM (
@@ -1091,6 +1710,35 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1091
1710
  .append(outerWhereStatement)
1092
1711
  .append(SQL` AND retentionDate >= now()`);
1093
1712
 
1713
+ if (mutableApplyTopK) {
1714
+ /*
1715
+ * Plain (non-GLOBAL) IN is deliberate here, unlike
1716
+ * appendTopKGroupRestriction: this predicate sits in the OUTER
1717
+ * query whose FROM is the argMax-dedup subquery — the initiator
1718
+ * evaluates it, so it is not a double-distributed subquery and
1719
+ * multi-shard ClickHouse accepts it as-is.
1720
+ */
1721
+ statement.append(` AND (`);
1722
+ this.appendRawGroupExpressionList(statement, mutableGroupByKeys, []);
1723
+ statement.append(`) IN (SELECT `);
1724
+ this.appendRawGroupExpressionList(statement, mutableGroupByKeys, []);
1725
+ statement.append(
1726
+ this.buildMutableMetricDedupSource(databaseName, aggregateBy),
1727
+ );
1728
+ statement.append(` GROUP BY `);
1729
+ this.appendRawGroupExpressionList(statement, mutableGroupByKeys, []);
1730
+ statement.append(
1731
+ ` ORDER BY ${mutableTopK!.rankBy}(${aggregateBy.aggregateColumnName.toString()}) DESC`,
1732
+ );
1733
+ statement.append(
1734
+ SQL` LIMIT ${{
1735
+ value: mutableTopK!.count,
1736
+ type: TableColumnType.Number,
1737
+ }}`,
1738
+ );
1739
+ statement.append(`)`);
1740
+ }
1741
+
1094
1742
  /*
1095
1743
  * Mirror the base builder's Total handling: the SELECT above (shared
1096
1744
  * toAggregateSelectStatement) already emits `min(time)` for a `Total`
@@ -1155,11 +1803,20 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1155
1803
  }}`,
1156
1804
  );
1157
1805
 
1806
+ /*
1807
+ * transform_null_in=1 only when the Top-K IN restriction is present
1808
+ * — see the percentile path's settings comment for the rationale
1809
+ * (mutable group-by keys are Nullable model columns too, e.g.
1810
+ * primaryEntityType).
1811
+ */
1158
1812
  statement.append(
1159
1813
  getQuerySettings({
1814
+ maxExecutionTimeInSeconds: 45,
1815
+ timeoutOverflowMode: this.getTimeoutOverflowMode(aggregateBy),
1160
1816
  additionalSettings: {
1161
1817
  optimize_move_to_prewhere: 1,
1162
1818
  max_threads: 4,
1819
+ ...(mutableApplyTopK ? { transform_null_in: 1 } : {}),
1163
1820
  },
1164
1821
  }),
1165
1822
  );
@@ -1177,6 +1834,63 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1177
1834
  };
1178
1835
  }
1179
1836
 
1837
+ /**
1838
+ * A fresh `FROM (...) WHERE ...` fragment over the argMax-deduped
1839
+ * mutable-metric rows — the same source the main mutable aggregate
1840
+ * reads from — for the Top-K ranking subquery and totalGroups
1841
+ * counter, which need their own scan of the deduped rows (the
1842
+ * versioned raw rows must never be ranked directly, or a metric
1843
+ * updated N times would rank by its stalest value).
1844
+ */
1845
+ private buildMutableMetricDedupSource(
1846
+ databaseName: string,
1847
+ aggregateBy: AggregateBy<Metric>,
1848
+ ): Statement {
1849
+ const source: Statement = SQL``;
1850
+ source
1851
+ .append(
1852
+ SQL`
1853
+ FROM (
1854
+ SELECT
1855
+ projectId,
1856
+ name,
1857
+ primaryEntityId,
1858
+ primaryEntityType,
1859
+ metricPointId,
1860
+ argMax(metricPointType, version) AS metricPointType,
1861
+ argMax(time, version) AS time,
1862
+ argMax(timeUnixNano, version) AS timeUnixNano,
1863
+ argMax(attributes, version) AS attributes,
1864
+ argMax(attributeKeys, version) AS attributeKeys,
1865
+ argMax(value, version) AS value,
1866
+ argMax(retentionDate, version) AS retentionDate,
1867
+ argMax(isDeleted, version) AS isDeleted
1868
+ FROM ${databaseName}.${AnalyticsTableName.MutableMetric}
1869
+ WHERE TRUE
1870
+ `,
1871
+ )
1872
+ .append(
1873
+ this.statementGenerator.toWhereStatement(
1874
+ this.getMutableMetricInnerQuery(aggregateBy.query),
1875
+ ),
1876
+ )
1877
+ .append(
1878
+ SQL`
1879
+ GROUP BY
1880
+ projectId,
1881
+ name,
1882
+ primaryEntityId,
1883
+ primaryEntityType,
1884
+ metricPointId
1885
+ )
1886
+ WHERE isDeleted = false
1887
+ `,
1888
+ )
1889
+ .append(this.statementGenerator.toWhereStatement(aggregateBy.query))
1890
+ .append(SQL` AND retentionDate >= now()`);
1891
+ return source;
1892
+ }
1893
+
1180
1894
  private getMutableMetricInnerQuery(query: Query<Metric>): Query<Metric> {
1181
1895
  const queryRecord: Record<string, unknown> = query as unknown as Record<
1182
1896
  string,
@@ -1323,10 +2037,11 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1323
2037
  });
1324
2038
  /*
1325
2039
  * The MV is bucketed at 1 minute, so every time-bucketed interval
1326
- * (Minute / Hour / Day / Week / Month / Year) is >= MV resolution and
1327
- * acceptable — the honored interval flows into the date_trunc below.
1328
- * `Total` (whole-window, no bucketing) is the one exception: fall back
1329
- * to the raw-table builder, which knows how to collapse the window.
2040
+ * (Minute / FiveMinutes / ... / Year) is >= MV resolution and
2041
+ * acceptable — the honored interval flows into the shared bucket
2042
+ * expression below. `Total` (whole-window, no bucketing) is the one
2043
+ * exception: fall back to the raw-table builder, which knows how to
2044
+ * collapse the window.
1330
2045
  */
1331
2046
  if (AggregateUtil.isTotalAggregation(interval)) {
1332
2047
  return null;
@@ -1382,8 +2097,6 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1382
2097
  }
1383
2098
  const databaseName: string = this.database.getDatasourceOptions().database!;
1384
2099
 
1385
- const intervalLower: string = interval.toLowerCase();
1386
-
1387
2100
  let mergedExpr: string;
1388
2101
  if (aggType === AggregationType.Sum) {
1389
2102
  mergedExpr = `sumMerge(valueSumState)`;
@@ -1414,7 +2127,7 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1414
2127
  const statement: Statement = SQL``;
1415
2128
 
1416
2129
  statement.append(
1417
- `SELECT ${mergedExpr} as value, date_trunc('${intervalLower}', toStartOfInterval(bucketTime, INTERVAL 1 ${intervalLower})) as time`,
2130
+ `SELECT ${mergedExpr} as value, ${AggregateUtil.buildBucketTimestampExpression(interval, "bucketTime")} as time`,
1418
2131
  );
1419
2132
  statement.append(SQL` FROM ${databaseName}.MetricItemAggMV1m`);
1420
2133
  statement.append(
@@ -1438,6 +2151,8 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1438
2151
  );
1439
2152
  statement.append(
1440
2153
  getQuerySettings({
2154
+ maxExecutionTimeInSeconds: 45,
2155
+ timeoutOverflowMode: this.getTimeoutOverflowMode(aggregateBy),
1441
2156
  additionalSettings: {
1442
2157
  optimize_aggregation_in_order: 1,
1443
2158
  optimize_move_to_prewhere: 1,
@@ -1463,30 +2178,55 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1463
2178
  }
1464
2179
 
1465
2180
  /*
1466
- * Per-host materialized-view fast path.
2181
+ * Entity-scoped materialized-view fast path (per-entity 1-minute
2182
+ * rollups, keyed by the ingest-stamped scalar entity-key columns).
2183
+ * Entity keys canonicalize their value (trim + lowercase), so spelling
2184
+ * drift in the reported identity still lands on one rollup stream.
1467
2185
  *
1468
- * Returns a statement that reads from MetricItemAggMV1mByHostV2
1469
- * (created by RekeyMetricHostRollupToEntityKey), which is keyed by
1470
- * the stable `hostEntityKey` the incoming `resource.host.name`
1471
- * filter value is folded into that key server-side via
1472
- * EntityKey.keyForHost, so spelling drift (case/whitespace) in the
1473
- * reported hostname still lands on one rollup stream. Applies when:
2186
+ * Routing decision table ALL of these outer gates must hold:
2187
+ * Sum/Avg/Min/Max/Count over `value` bucketed by `time`; no group-by
2188
+ * of any kind (an entity-key-keyed MV cannot label legend series);
2189
+ * not a distribution metric and not a percentile (enforced by the
2190
+ * caller); a bucketed (non-Total) interval; a projectId (the entity
2191
+ * key is tenant-scoped by construction); and no query columns beyond
2192
+ * projectId/name/time/attributes/entityScope.
2193
+ * Then exactly ONE of these entity-filter shapes routes:
1474
2194
  *
1475
- * - The aggregation is Sum/Avg/Min/Max/Count over `value`.
1476
- * - The only attribute filter is `resource.host.name` as a
1477
- * bare-string equality (the dashboard's host detail page
1478
- * pattern), and the query carries a `projectId` (the entity
1479
- * key is tenant-scoped by construction).
1480
- * - The query carries no group-by other than the time
1481
- * bucket the MV is keyed by hostEntityKey and does not
1482
- * preserve other attribute breakdowns.
2195
+ * attributes == {resource.host.name: v}
2196
+ * -> MetricItemAggMV1mByHostV2, hostEntityKey = keyForHost(projectId, v)
2197
+ * attributes == {resource.k8s.cluster.name: v}
2198
+ * -> MetricItemAggMV1mByK8sCluster, k8sClusterEntityKey = keyForKubernetesCluster(projectId, v)
2199
+ * attributes == {resource.container.id: v}
2200
+ * -> MetricItemAggMV1mByContainer, containerEntityKey = keyForContainer(projectId, v)
2201
+ * attributes == {resource.service.name: v}
2202
+ * -> MetricItemAggMV1mByService, serviceEntityKey IN (<registry key set>)
2203
+ * The key set is resolved asynchronously in aggregateBy() from
2204
+ * the Postgres TelemetryEntity registry — service identity folds
2205
+ * service.namespace into the key at ingest, so one name can map
2206
+ * to several keys. No registry rows -> NO routing (raw path).
2207
+ * entityScope only, attributeKey in {host/k8s-cluster/container} and
2208
+ * entityKeys verified byte-equal to [keyFor<type>(projectId, attributeValue)]
2209
+ * -> same MV as the matching attribute shape. The scope's attribute
2210
+ * OR-fallback only adds rows ingested before the entity-key
2211
+ * columns existed — the same retention-bounded gap the original
2212
+ * per-host path accepted.
2213
+ * attributes(single recognized key) + entityScope agreeing on
2214
+ * attributeKey/attributeValue (the host/k8s Metrics-tab sparkline
2215
+ * shape; the scope is then redundant — A AND (B OR A) === A)
2216
+ * -> same MV as the attribute shape (service excluded).
2217
+ *
2218
+ * Everything else falls through (minute MV, then raw): service +
2219
+ * entityScope (a bare-key translation would drop namespaced variants),
2220
+ * unverifiable or multi-key scopes, extra attribute filters, non-string
2221
+ * values, k8s pod/node identities (composite cluster(+namespace)+uid —
2222
+ * not derivable from a single attribute), and any other query column.
2223
+ * When in doubt this method returns null; the raw path is always
2224
+ * correct.
1483
2225
  *
1484
- * Returns `null` if any condition fails so the caller falls
1485
- * through to the next fast path / base table. The result row
1486
- * shape (columns: aggregateColumn, timestampColumn) matches
1487
- * the base statement so downstream code needs no changes.
2226
+ * The result row shape (columns: aggregateColumn, timestampColumn)
2227
+ * matches the base statement so downstream code needs no changes.
1488
2228
  */
1489
- private tryBuildHostAggregateMVStatement(
2229
+ private tryBuildEntityAggregateMVStatement(
1490
2230
  aggregateBy: AggregateBy<Metric>,
1491
2231
  ): { statement: Statement; columns: Array<string> } | null {
1492
2232
  const aggType: AggregationType = aggregateBy.aggregationType;
@@ -1519,80 +2259,51 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1519
2259
  return null;
1520
2260
  }
1521
2261
 
1522
- /*
1523
- * Inspect the attribute filter. This MV is only safe when
1524
- * the user is filtering by exactly one attribute,
1525
- * `resource.host.name`, with a bare-string value (the
1526
- * canonical Overview/Metrics-page pattern). Anything else —
1527
- * extra attribute filters, NotEqual, Search, etc. — has to
1528
- * fall back so the result stays correct.
1529
- */
1530
2262
  const queryRecord: Record<string, unknown> =
1531
2263
  (aggregateBy.query as unknown as Record<string, unknown>) || {};
1532
- const attrs: unknown = queryRecord["attributes"];
1533
- if (!attrs || typeof attrs !== "object") {
1534
- return null;
1535
- }
1536
- const attrEntries: Array<[string, unknown]> = Object.entries(
1537
- attrs as Record<string, unknown>,
1538
- );
1539
- if (attrEntries.length !== 1) {
1540
- return null;
2264
+
2265
+ /*
2266
+ * The entity keys fold the tenant in (sha256(projectId|type|...)), so
2267
+ * MV rows can only be located when the query is project-scoped.
2268
+ * Dashboard reads always are; anything else falls back safely.
2269
+ */
2270
+ const projectIdValue: unknown = queryRecord["projectId"];
2271
+ let projectId: string = "";
2272
+ if (projectIdValue instanceof ObjectID) {
2273
+ projectId = projectIdValue.toString();
2274
+ } else if (typeof projectIdValue === "string") {
2275
+ projectId = projectIdValue;
1541
2276
  }
1542
- const [attrKey, attrValue] = attrEntries[0]!;
1543
- if (attrKey !== "resource.host.name") {
2277
+ if (!projectId) {
1544
2278
  return null;
1545
2279
  }
1546
- if (attrValue === undefined || attrValue === null) {
2280
+
2281
+ const resolved: { route: EntityMVRoute; keys: Array<string> } | null =
2282
+ this.resolveEntityMVRouteAndKeys(aggregateBy, queryRecord, projectId);
2283
+ if (!resolved) {
1547
2284
  return null;
1548
2285
  }
1549
2286
 
1550
2287
  /*
1551
- * The MV only carries projectId/name/hostEntityKey/bucketTime. Any
1552
- * other query key (primaryEntityId, entityScope, entityKeys, ...)
1553
- * would compile to a WHERE over a column the MV does not have, so
1554
- * fall back to the raw table for those. Mirrors
1555
- * tryBuildMinuteAggregateMVStatement.
2288
+ * Each MV only carries projectId/name/<entity key>/bucketTime. Any
2289
+ * other query key (primaryEntityId, entityKeys, ...) would compile to
2290
+ * a WHERE over a column the MV does not have, so fall back to the raw
2291
+ * table for those. Mirrors tryBuildMinuteAggregateMVStatement.
2292
+ * `attributes` and `entityScope` are validated and rewritten into the
2293
+ * entity-key predicate by resolveEntityMVRouteAndKeys above.
1556
2294
  */
1557
2295
  const mvQueryableColumns: ReadonlyArray<string> = [
1558
2296
  "projectId",
1559
2297
  "name",
1560
2298
  "time", // stripped below; bucketTime range is added explicitly
1561
- "attributes", // rewritten below into the hostEntityKey predicate
2299
+ "attributes",
2300
+ "entityScope",
1562
2301
  ];
1563
2302
  for (const queryKey of Object.keys(queryRecord)) {
1564
2303
  if (!mvQueryableColumns.includes(queryKey)) {
1565
2304
  return null;
1566
2305
  }
1567
2306
  }
1568
- const hostIdentifier: string =
1569
- typeof attrValue === "string" ? attrValue : "";
1570
- if (!hostIdentifier) {
1571
- return null;
1572
- }
1573
-
1574
- /*
1575
- * The entity key folds the tenant in (sha256(projectId|host|...)), so
1576
- * the MV row can only be located when the query is project-scoped.
1577
- * Dashboard reads always are; anything else falls back safely.
1578
- */
1579
- const projectIdValue: unknown = queryRecord["projectId"];
1580
- let projectId: string = "";
1581
- if (projectIdValue instanceof ObjectID) {
1582
- projectId = projectIdValue.toString();
1583
- } else if (typeof projectIdValue === "string") {
1584
- projectId = projectIdValue;
1585
- }
1586
- if (!projectId) {
1587
- return null;
1588
- }
1589
-
1590
- /*
1591
- * Same canonicalized key the ingest pipeline stamps into
1592
- * MetricItemV3.hostEntityKey (and the V2 MV groups by) — byte-equality
1593
- * is what makes this lookup correct, see Common/Utils/Telemetry/EntityKey.
1594
- */
1595
- const hostEntityKey: string = keyForHost(projectId, hostIdentifier);
1596
2307
 
1597
2308
  const interval: AggregationInterval = AggregateUtil.getAggregationInterval({
1598
2309
  startDate: aggregateBy.startTimestamp!,
@@ -1600,10 +2311,10 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1600
2311
  aggregationInterval: aggregateBy.aggregationInterval,
1601
2312
  });
1602
2313
  /*
1603
- * `Total` (whole-window, no bucketing) is not served by this
1604
- * time-bucketed per-host MV — fall back to the raw-table builder.
1605
- * Every other interval is >= the MV's 1-minute resolution and flows
1606
- * into the date_trunc below.
2314
+ * `Total` (whole-window, no bucketing) is not served by these
2315
+ * time-bucketed per-entity MVs — fall back to the raw-table builder.
2316
+ * Every other interval is >= the MVs' 1-minute resolution and flows
2317
+ * into the shared bucket expression below.
1607
2318
  */
1608
2319
  if (AggregateUtil.isTotalAggregation(interval)) {
1609
2320
  return null;
@@ -1614,8 +2325,6 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1614
2325
  }
1615
2326
  const databaseName: string = this.database.getDatasourceOptions().database!;
1616
2327
 
1617
- const intervalLower: string = interval.toLowerCase();
1618
-
1619
2328
  let mergedExpr: string;
1620
2329
  if (aggType === AggregationType.Sum) {
1621
2330
  mergedExpr = `sumMerge(valueSumState)`;
@@ -1630,13 +2339,13 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1630
2339
  }
1631
2340
 
1632
2341
  /*
1633
- * Strip both `time` (column doesn't exist on the MV; we
1634
- * inject an explicit bucketTime range below) and
1635
- * `attributes` (the attribute filter is now an explicit
1636
- * `hostEntityKey =` predicate against an MV column).
2342
+ * Strip `time` (column doesn't exist on the MV; we inject an
2343
+ * explicit bucketTime range below) plus `attributes`/`entityScope`
2344
+ * (the entity filter is now an explicit predicate against the MV's
2345
+ * entity-key column).
1637
2346
  */
1638
2347
  const filteredQuery: typeof aggregateBy.query =
1639
- this.stripAttributesAndTimeFromQuery(
2348
+ this.stripEntityFilterAndTimeFromQuery(
1640
2349
  aggregateBy.query,
1641
2350
  ) as typeof aggregateBy.query;
1642
2351
  const nonTimeWhere: Statement =
@@ -1648,18 +2357,29 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1648
2357
  const statement: Statement = SQL``;
1649
2358
 
1650
2359
  statement.append(
1651
- `SELECT ${mergedExpr} as value, date_trunc('${intervalLower}', toStartOfInterval(bucketTime, INTERVAL 1 ${intervalLower})) as time`,
2360
+ `SELECT ${mergedExpr} as value, ${AggregateUtil.buildBucketTimestampExpression(interval, "bucketTime")} as time`,
1652
2361
  );
1653
- statement.append(SQL` FROM ${databaseName}.MetricItemAggMV1mByHostV2`);
2362
+ statement.append(SQL` FROM ${databaseName}.`);
2363
+ statement.append(resolved.route.tableName);
1654
2364
  statement.append(
1655
2365
  ` WHERE bucketTime >= toDateTime('${this.formatDateTime(aggregateBy.startTimestamp!)}') AND bucketTime <= toDateTime('${this.formatDateTime(aggregateBy.endTimestamp!)}')${this.getRetentionReadFilter()}`,
1656
2366
  );
1657
- statement.append(
1658
- SQL` AND hostEntityKey = ${{
1659
- value: hostEntityKey,
1660
- type: TableColumnType.Text,
1661
- }}`,
1662
- );
2367
+ statement.append(` AND ${resolved.route.keyColumn}`);
2368
+ if (resolved.keys.length === 1) {
2369
+ statement.append(
2370
+ SQL` = ${{
2371
+ value: resolved.keys[0]!,
2372
+ type: TableColumnType.Text,
2373
+ }}`,
2374
+ );
2375
+ } else {
2376
+ statement.append(
2377
+ SQL` IN ${{
2378
+ value: resolved.keys,
2379
+ type: TableColumnType.ArrayText,
2380
+ }}`,
2381
+ );
2382
+ }
1663
2383
  statement.append(SQL` `).append(nonTimeWhere);
1664
2384
 
1665
2385
  statement.append(SQL` GROUP BY `).append(`time`);
@@ -1678,6 +2398,8 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1678
2398
  );
1679
2399
  statement.append(
1680
2400
  getQuerySettings({
2401
+ maxExecutionTimeInSeconds: 45,
2402
+ timeoutOverflowMode: this.getTimeoutOverflowMode(aggregateBy),
1681
2403
  additionalSettings: {
1682
2404
  optimize_aggregation_in_order: 1,
1683
2405
  optimize_move_to_prewhere: 1,
@@ -1686,7 +2408,7 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1686
2408
  }),
1687
2409
  );
1688
2410
 
1689
- logger.debug(`${this.model.tableName} Host MV Aggregate Statement`, {
2411
+ logger.debug(`${this.model.tableName} Entity MV Aggregate Statement`, {
1690
2412
  tableName: this.model.tableName,
1691
2413
  } as LogAttributes);
1692
2414
  logger.debug(statement, {
@@ -1702,13 +2424,139 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1702
2424
  };
1703
2425
  }
1704
2426
 
1705
- private stripAttributesAndTimeFromQuery(query: unknown): typeof query {
2427
+ /**
2428
+ * The single entity filter of a query, resolved to the MV route that
2429
+ * serves it and the exact entity-key set to filter by — or null when
2430
+ * no branch of the routing decision table (see
2431
+ * tryBuildEntityAggregateMVStatement) matches. Never guesses: every
2432
+ * shape it accepts is one whose MV predicate provably covers the raw
2433
+ * predicate it replaces (modulo the accepted pre-entity-key-column
2434
+ * rows).
2435
+ */
2436
+ private resolveEntityMVRouteAndKeys(
2437
+ aggregateBy: AggregateBy<Metric>,
2438
+ queryRecord: Record<string, unknown>,
2439
+ projectId: string,
2440
+ ): { route: EntityMVRoute; keys: Array<string> } | null {
2441
+ const attrs: unknown = queryRecord["attributes"];
2442
+ if (attrs !== undefined && attrs !== null && typeof attrs !== "object") {
2443
+ return null;
2444
+ }
2445
+ const attrEntries: Array<[string, unknown]> =
2446
+ attrs && typeof attrs === "object"
2447
+ ? Object.entries(attrs as Record<string, unknown>)
2448
+ : [];
2449
+ if (attrEntries.length > 1) {
2450
+ return null;
2451
+ }
2452
+
2453
+ const scopeValue: unknown = queryRecord["entityScope"];
2454
+ const scope: EntityScopeQueryValue | null =
2455
+ scopeValue !== undefined &&
2456
+ scopeValue !== null &&
2457
+ typeof scopeValue === "object"
2458
+ ? (scopeValue as EntityScopeQueryValue)
2459
+ : null;
2460
+ // A present-but-malformed entityScope is a filter we cannot honor.
2461
+ if (scopeValue !== undefined && scopeValue !== null && !scope) {
2462
+ return null;
2463
+ }
2464
+
2465
+ if (attrEntries.length === 1) {
2466
+ const [attrKey, attrValue] = attrEntries[0]!;
2467
+ const route: EntityMVRoute | undefined = ENTITY_MV_ROUTES.find(
2468
+ (candidate: EntityMVRoute) => {
2469
+ return candidate.attributeKey === attrKey;
2470
+ },
2471
+ );
2472
+ if (!route || typeof attrValue !== "string" || !attrValue) {
2473
+ return null;
2474
+ }
2475
+
2476
+ if (scope) {
2477
+ /*
2478
+ * The host/k8s Metrics tabs send the attribute filter AND a
2479
+ * matching entityScope in the same query. When the scope is
2480
+ * provably the SAME entity filter (same attributeKey/value), the
2481
+ * conjunction reduces to the attribute equality — the scope's
2482
+ * OR-fallback is subsumed — so route on the attribute alone. Any
2483
+ * disagreement means a genuine second filter: fall back. Service
2484
+ * is excluded outright: its scope keys cannot be verified against
2485
+ * a computed key (namespace variants).
2486
+ */
2487
+ if (route.keyForValue === null) {
2488
+ return null;
2489
+ }
2490
+ if (
2491
+ scope.attributeKey !== attrKey ||
2492
+ String(scope.attributeValue ?? "") !== attrValue
2493
+ ) {
2494
+ return null;
2495
+ }
2496
+ }
2497
+
2498
+ if (route.keyForValue === null) {
2499
+ /*
2500
+ * Service: the registry-resolved key set was handed over by
2501
+ * aggregateBy(). Absent or empty (sync caller, registry miss,
2502
+ * lookup failure) -> raw path.
2503
+ */
2504
+ const hintKeys: Array<string> | undefined =
2505
+ this.serviceEntityKeysHintByAggregate.get(aggregateBy);
2506
+ if (!hintKeys || hintKeys.length === 0) {
2507
+ return null;
2508
+ }
2509
+ return { route, keys: hintKeys };
2510
+ }
2511
+
2512
+ return { route, keys: [route.keyForValue(projectId, attrValue)] };
2513
+ }
2514
+
2515
+ // No attribute filter: entityScope-only shape.
2516
+ if (!scope) {
2517
+ return null;
2518
+ }
2519
+ const route: EntityMVRoute | undefined = ENTITY_MV_ROUTES.find(
2520
+ (candidate: EntityMVRoute) => {
2521
+ return candidate.attributeKey === scope.attributeKey;
2522
+ },
2523
+ );
2524
+ if (!route || route.keyForValue === null) {
2525
+ return null;
2526
+ }
2527
+ const scopeAttributeValue: unknown = scope.attributeValue;
2528
+ if (typeof scopeAttributeValue !== "string" || !scopeAttributeValue) {
2529
+ return null;
2530
+ }
2531
+
2532
+ /*
2533
+ * `hasAny(entityKeys, [...])` is only translatable to the scalar
2534
+ * entity-key column when every listed key IS the key this scope's
2535
+ * attribute value derives to — recompute it server-side and require
2536
+ * byte equality (a foreign or extra key would make the raw predicate
2537
+ * match rows the MV predicate cannot).
2538
+ */
2539
+ const expectedKey: string = route.keyForValue(
2540
+ projectId,
2541
+ scopeAttributeValue,
2542
+ );
2543
+ const scopeKeys: Array<string> = Array.isArray(scope.entityKeys)
2544
+ ? scope.entityKeys
2545
+ : [];
2546
+ if (scopeKeys.length !== 1 || scopeKeys[0] !== expectedKey) {
2547
+ return null;
2548
+ }
2549
+
2550
+ return { route, keys: [expectedKey] };
2551
+ }
2552
+
2553
+ private stripEntityFilterAndTimeFromQuery(query: unknown): typeof query {
1706
2554
  if (!query || typeof query !== "object") {
1707
2555
  return query;
1708
2556
  }
1709
2557
  const out: Record<string, unknown> = {};
1710
2558
  for (const [k, v] of Object.entries(query as Record<string, unknown>)) {
1711
- if (k === "time" || k === "attributes") {
2559
+ if (k === "time" || k === "attributes" || k === "entityScope") {
1712
2560
  continue;
1713
2561
  }
1714
2562
  out[k] = v;