@oneuptime/common 11.4.0 → 11.4.1

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 (178) hide show
  1. package/Server/API/AIAgentTaskAPI.ts +10 -24
  2. package/Server/API/TelemetryAPI.ts +4 -3
  3. package/Server/Services/AIAgentTaskService.ts +62 -0
  4. package/Server/Services/AnalyticsDatabaseService.ts +27 -0
  5. package/Server/Services/LogAggregationService.ts +29 -11
  6. package/Server/Services/MetricService.ts +603 -13
  7. package/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.ts +1 -1
  8. package/Server/Utils/AI/Sentinel/README.md +10 -0
  9. package/Server/Utils/Monitor/Criteria/APIRequestCriteria.ts +44 -0
  10. package/Server/Utils/Monitor/MonitorMetricUtil.ts +108 -2
  11. package/Tests/Server/Services/AIAgentTaskServiceClaim.test.ts +146 -0
  12. package/Tests/Server/Services/LogAggregationService.test.ts +22 -0
  13. package/Tests/Server/Services/MetricServiceAggregate.test.ts +194 -0
  14. package/Tests/Types/AI/AIAgentTaskStatus.test.ts +86 -0
  15. package/Tests/Types/BaseDatabase/AggregationIntervalUtil.test.ts +127 -0
  16. package/Tests/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.test.ts +152 -0
  17. package/Tests/Types/Trace/TraceRecordingRuleDefinition.test.ts +308 -0
  18. package/Tests/UI/Components/Dropdown.test.tsx +17 -0
  19. package/Tests/UI/Components/EntityDropdown.test.tsx +130 -0
  20. package/Tests/UI/Components/Modal.test.tsx +144 -2
  21. package/Tests/UI/Components/TelemetrySearchBar.test.tsx +90 -0
  22. package/Tests/UI/Utils/Theme.test.ts +161 -0
  23. package/Types/BaseDatabase/AggregateBy.ts +12 -0
  24. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  25. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  26. package/Types/Dashboard/DashboardComponents/DashboardLogChartComponent.ts +19 -0
  27. package/Types/Dashboard/DashboardTemplates.ts +9 -1
  28. package/Types/Monitor/CriteriaFilter.ts +4 -0
  29. package/Types/Monitor/HttpPhaseTimings.ts +16 -0
  30. package/Types/Monitor/MonitorMetricType.ts +17 -0
  31. package/Types/Monitor/PingMonitor/PingMonitorResponse.ts +20 -0
  32. package/Types/Probe/ProbeMonitorResponse.ts +14 -0
  33. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +8 -5
  34. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +4 -1
  35. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +6 -3
  36. package/UI/Components/CodeBlock/CodeBlock.tsx +2 -1
  37. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  38. package/UI/Components/Divider/Divider.tsx +1 -1
  39. package/UI/Components/Dropdown/Dropdown.tsx +13 -4
  40. package/UI/Components/EntityDropdown/EntityDropdown.tsx +110 -2
  41. package/UI/Components/ErrorBoundary.tsx +2 -1
  42. package/UI/Components/EventItem/EventItem.tsx +2 -2
  43. package/UI/Components/GanttChart/Bar/Index.tsx +1 -1
  44. package/UI/Components/Graphs/UptimeBarTooltip.tsx +30 -23
  45. package/UI/Components/Header/Header.tsx +7 -0
  46. package/UI/Components/Header/HeaderIconDropdownButton.tsx +2 -0
  47. package/UI/Components/Label/Label.tsx +2 -2
  48. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +22 -10
  49. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +9 -3
  50. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +8 -8
  51. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +44 -20
  52. package/UI/Components/Modal/ConfirmModal.tsx +1 -1
  53. package/UI/Components/Modal/Modal.tsx +213 -121
  54. package/UI/Components/Modal/ModalFooter.tsx +33 -22
  55. package/UI/Components/ModelTable/TableView.tsx +1 -1
  56. package/UI/Components/MonitorGraphs/UptimeBarDayModal.tsx +20 -12
  57. package/UI/Components/Pill/Pill.tsx +21 -13
  58. package/UI/Components/StatusBubble/StatusBubble.tsx +3 -3
  59. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +7 -1
  60. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +9 -3
  61. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +62 -4
  62. package/UI/Components/Workflow/Component.tsx +53 -33
  63. package/UI/Components/Workflow/ComponentPortViewer.tsx +6 -6
  64. package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +8 -8
  65. package/UI/Components/Workflow/ComponentsModal.tsx +16 -9
  66. package/UI/Components/Workflow/Workflow.tsx +13 -11
  67. package/UI/Utils/Theme.ts +140 -0
  68. package/Utils/Dashboard/Components/DashboardLogChartComponent.ts +104 -0
  69. package/Utils/Dashboard/Components/Index.ts +7 -0
  70. package/Utils/Monitor/MonitorMetricType.ts +70 -2
  71. package/build/dist/Server/API/AIAgentTaskAPI.js +8 -23
  72. package/build/dist/Server/API/AIAgentTaskAPI.js.map +1 -1
  73. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  74. package/build/dist/Server/Services/AIAgentTaskService.js +59 -0
  75. package/build/dist/Server/Services/AIAgentTaskService.js.map +1 -1
  76. package/build/dist/Server/Services/AnalyticsDatabaseService.js +19 -0
  77. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  78. package/build/dist/Server/Services/LogAggregationService.js +21 -7
  79. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  80. package/build/dist/Server/Services/MetricService.js +449 -7
  81. package/build/dist/Server/Services/MetricService.js.map +1 -1
  82. package/build/dist/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.js +1 -1
  83. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js +29 -0
  84. package/build/dist/Server/Utils/Monitor/Criteria/APIRequestCriteria.js.map +1 -1
  85. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +86 -2
  86. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  87. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  88. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  89. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  90. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  91. package/build/dist/Types/Dashboard/DashboardComponents/DashboardLogChartComponent.js +2 -0
  92. package/build/dist/Types/Dashboard/DashboardComponents/DashboardLogChartComponent.js.map +1 -0
  93. package/build/dist/Types/Dashboard/DashboardTemplates.js +9 -1
  94. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  95. package/build/dist/Types/Monitor/CriteriaFilter.js +4 -0
  96. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  97. package/build/dist/Types/Monitor/HttpPhaseTimings.js +2 -0
  98. package/build/dist/Types/Monitor/HttpPhaseTimings.js.map +1 -0
  99. package/build/dist/Types/Monitor/MonitorMetricType.js +15 -0
  100. package/build/dist/Types/Monitor/MonitorMetricType.js.map +1 -1
  101. package/build/dist/Types/Monitor/PingMonitor/PingMonitorResponse.js +2 -0
  102. package/build/dist/Types/Monitor/PingMonitor/PingMonitorResponse.js.map +1 -0
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +7 -4
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +4 -1
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +6 -3
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/CodeBlock/CodeBlock.js +1 -1
  110. package/build/dist/UI/Components/CodeBlock/CodeBlock.js.map +1 -1
  111. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +3 -1
  112. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  113. package/build/dist/UI/Components/Divider/Divider.js +1 -1
  114. package/build/dist/UI/Components/Divider/Divider.js.map +1 -1
  115. package/build/dist/UI/Components/Dropdown/Dropdown.js +10 -4
  116. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  117. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +76 -3
  118. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  119. package/build/dist/UI/Components/ErrorBoundary.js +2 -1
  120. package/build/dist/UI/Components/ErrorBoundary.js.map +1 -1
  121. package/build/dist/UI/Components/EventItem/EventItem.js +2 -2
  122. package/build/dist/UI/Components/EventItem/EventItem.js.map +1 -1
  123. package/build/dist/UI/Components/GanttChart/Bar/Index.js +1 -1
  124. package/build/dist/UI/Components/GanttChart/Bar/Index.js.map +1 -1
  125. package/build/dist/UI/Components/Graphs/UptimeBarTooltip.js +29 -23
  126. package/build/dist/UI/Components/Graphs/UptimeBarTooltip.js.map +1 -1
  127. package/build/dist/UI/Components/Header/Header.js +1 -0
  128. package/build/dist/UI/Components/Header/Header.js.map +1 -1
  129. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
  130. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  131. package/build/dist/UI/Components/Label/Label.js +2 -2
  132. package/build/dist/UI/Components/Label/Label.js.map +1 -1
  133. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +20 -8
  134. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  135. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +8 -2
  136. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  137. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +8 -8
  138. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +40 -20
  139. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  140. package/build/dist/UI/Components/Modal/ConfirmModal.js +1 -1
  141. package/build/dist/UI/Components/Modal/ConfirmModal.js.map +1 -1
  142. package/build/dist/UI/Components/Modal/Modal.js +109 -51
  143. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  144. package/build/dist/UI/Components/Modal/ModalFooter.js +13 -10
  145. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  146. package/build/dist/UI/Components/ModelTable/TableView.js +1 -1
  147. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  148. package/build/dist/UI/Components/MonitorGraphs/UptimeBarDayModal.js +17 -12
  149. package/build/dist/UI/Components/MonitorGraphs/UptimeBarDayModal.js.map +1 -1
  150. package/build/dist/UI/Components/Pill/Pill.js +12 -13
  151. package/build/dist/UI/Components/Pill/Pill.js.map +1 -1
  152. package/build/dist/UI/Components/StatusBubble/StatusBubble.js +3 -3
  153. package/build/dist/UI/Components/StatusBubble/StatusBubble.js.map +1 -1
  154. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +1 -1
  155. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  156. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +8 -2
  157. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  158. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +36 -6
  159. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  160. package/build/dist/UI/Components/Workflow/Component.js +43 -33
  161. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  162. package/build/dist/UI/Components/Workflow/ComponentPortViewer.js +6 -6
  163. package/build/dist/UI/Components/Workflow/ComponentPortViewer.js.map +1 -1
  164. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +8 -8
  165. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
  166. package/build/dist/UI/Components/Workflow/ComponentsModal.js +15 -9
  167. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  168. package/build/dist/UI/Components/Workflow/Workflow.js +13 -11
  169. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  170. package/build/dist/UI/Utils/Theme.js +106 -0
  171. package/build/dist/UI/Utils/Theme.js.map +1 -0
  172. package/build/dist/Utils/Dashboard/Components/DashboardLogChartComponent.js +92 -0
  173. package/build/dist/Utils/Dashboard/Components/DashboardLogChartComponent.js.map +1 -0
  174. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  175. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  176. package/build/dist/Utils/Monitor/MonitorMetricType.js +70 -3
  177. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  178. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import ClickhouseDatabase from "../Infrastructure/ClickhouseDatabase";
2
+ import { ResponseJSON, ResultSet } from "@clickhouse/client";
2
3
  import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
3
4
  import { MutableMetricService as MutableMetricServiceClass } from "./MutableMetricService";
4
5
  import Metric from "../../Models/AnalyticsModels/Metric";
@@ -14,17 +15,327 @@ import AggregationType, {
14
15
  isPercentileAggregation,
15
16
  } from "../../Types/BaseDatabase/AggregationType";
16
17
  import AggregationInterval from "../../Types/BaseDatabase/AggregationInterval";
18
+ import AggregatedResult from "../../Types/BaseDatabase/AggregatedResult";
17
19
  import AnalyticsTableName from "../../Types/AnalyticsDatabase/AnalyticsTableName";
18
20
  import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
21
+ import BadDataException from "../../Types/Exception/BadDataException";
22
+ import { JSONObject } from "../../Types/JSON";
19
23
  import { keyForHost } from "../../Utils/Telemetry/EntityKey";
20
24
  import ObjectID from "../../Types/ObjectID";
21
25
  import logger, { LogAttributes } from "../Utils/Logger";
22
26
 
27
+ /*
28
+ * Metric point types whose rows carry a pre-aggregated distribution
29
+ * (count/sum/min/max + buckets) instead of a single observation. For
30
+ * these rows the `value` column holds the datapoint's SUM of all
31
+ * observations in the export interval (see OtelMetricsIngestService's
32
+ * buildMetricRow: value = asInt ?? asDouble ?? sum), so aggregating
33
+ * `value` directly produces sum-scale numbers — e.g. "Avg of
34
+ * http.client.request.duration" would return ~110s for a service whose
35
+ * true mean latency is 0.586s at ~188 requests per export interval.
36
+ */
37
+ const DISTRIBUTION_POINT_TYPES: ReadonlyArray<string> = [
38
+ "Histogram",
39
+ "ExponentialHistogram",
40
+ "Summary",
41
+ ];
42
+
43
+ const MAX_GROUP_BY_ATTRIBUTE_KEYS: number = 10;
44
+ const MAX_GROUP_BY_ATTRIBUTE_KEY_LENGTH: number = 256;
45
+
46
+ const POINT_TYPE_CACHE_TTL_MS: number = 10 * 60 * 1000;
47
+ const POINT_TYPE_CACHE_MAX_ENTRIES: number = 5000;
48
+
23
49
  export class MetricService extends AnalyticsDatabaseService<Metric> {
50
+ /*
51
+ * (projectId|metricName) -> metricPointType, resolved lazily on the
52
+ * aggregate path so scalar aggregations of distribution metrics can
53
+ * skip the MVs (whose states collapse `value` — the histogram sum)
54
+ * and use the count-weighted expressions instead. Bounded + TTL'd so
55
+ * a tenant churning metric names can't grow it without limit.
56
+ */
57
+ private metricPointTypeCache: Map<
58
+ string,
59
+ { pointType: string | null; expiresAt: number }
60
+ > = new Map();
61
+
62
+ /*
63
+ * Point-type hint per aggregateBy call. toAggregateStatement is a
64
+ * synchronous override, so the async lookup happens in aggregateBy
65
+ * and is handed over keyed on the (shared) aggregateBy object.
66
+ */
67
+ private pointTypeHintByAggregate: WeakMap<
68
+ AggregateBy<Metric>,
69
+ string | null
70
+ > = new WeakMap();
71
+
72
+ private inFlightPointTypeLookups: Map<string, Promise<string | null>> =
73
+ new Map();
74
+
24
75
  public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
25
76
  super({ modelType: Metric, database: clickhouseDatabase });
26
77
  }
27
78
 
79
+ public override async aggregateBy(
80
+ aggregateBy: AggregateBy<Metric>,
81
+ ): Promise<AggregatedResult> {
82
+ /*
83
+ * Only scalar aggregations over `value` need the point type — the
84
+ * percentile path is already distribution-aware via the bucket
85
+ * fanout, and MV routing is the only decision the hint drives.
86
+ * Grouped queries (model-column or attribute-key) never route to
87
+ * the MVs, so the lookup would be dead weight there.
88
+ */
89
+ if (
90
+ !isPercentileAggregation(aggregateBy.aggregationType) &&
91
+ aggregateBy.aggregateColumnName.toString() === "value" &&
92
+ (!aggregateBy.groupBy || Object.keys(aggregateBy.groupBy).length === 0) &&
93
+ (!aggregateBy.groupByAttributeKeys ||
94
+ aggregateBy.groupByAttributeKeys.length === 0)
95
+ ) {
96
+ const pointType: string | null =
97
+ await this.resolveMetricPointType(aggregateBy);
98
+ this.pointTypeHintByAggregate.set(aggregateBy, pointType);
99
+ }
100
+
101
+ return super.aggregateBy(aggregateBy);
102
+ }
103
+
104
+ private async resolveMetricPointType(
105
+ aggregateBy: AggregateBy<Metric>,
106
+ ): Promise<string | null> {
107
+ const metricName: string | null = this.getExactMetricNameFromQuery(
108
+ aggregateBy.query,
109
+ );
110
+ if (!metricName) {
111
+ return null;
112
+ }
113
+
114
+ const queryRecord: Record<string, unknown> =
115
+ (aggregateBy.query as unknown as Record<string, unknown>) || {};
116
+ const projectIdValue: unknown = queryRecord["projectId"];
117
+ let projectId: string = "";
118
+ if (projectIdValue instanceof ObjectID) {
119
+ projectId = projectIdValue.toString();
120
+ } else if (typeof projectIdValue === "string") {
121
+ projectId = projectIdValue;
122
+ }
123
+ if (!projectId) {
124
+ return null;
125
+ }
126
+
127
+ const cacheKey: string = `${projectId}|${metricName}`;
128
+ const cached: { pointType: string | null; expiresAt: number } | undefined =
129
+ this.metricPointTypeCache.get(cacheKey);
130
+ if (cached && cached.expiresAt > Date.now()) {
131
+ return cached.pointType;
132
+ }
133
+
134
+ /*
135
+ * Coalesce concurrent misses: a dashboard refresh fires many
136
+ * aggregates for the same metric at once; without this every one
137
+ * of them would issue the same lookup before any could populate
138
+ * the cache.
139
+ */
140
+ const inFlight: Promise<string | null> | undefined =
141
+ this.inFlightPointTypeLookups.get(cacheKey);
142
+ if (inFlight) {
143
+ return inFlight;
144
+ }
145
+
146
+ const lookup: Promise<string | null> = this.lookupMetricPointType(
147
+ aggregateBy,
148
+ projectId,
149
+ metricName,
150
+ )
151
+ .then((pointType: string | null) => {
152
+ this.evictExpiredPointTypeCacheEntries();
153
+ this.metricPointTypeCache.set(cacheKey, {
154
+ pointType,
155
+ expiresAt: Date.now() + POINT_TYPE_CACHE_TTL_MS,
156
+ });
157
+ return pointType;
158
+ })
159
+ .finally(() => {
160
+ this.inFlightPointTypeLookups.delete(cacheKey);
161
+ });
162
+
163
+ this.inFlightPointTypeLookups.set(cacheKey, lookup);
164
+ return lookup;
165
+ }
166
+
167
+ private async lookupMetricPointType(
168
+ aggregateBy: AggregateBy<Metric>,
169
+ projectId: string,
170
+ metricName: string,
171
+ ): Promise<string | null> {
172
+ try {
173
+ if (!this.database) {
174
+ this.useDefaultDatabase();
175
+ }
176
+ const databaseName: string =
177
+ this.database.getDatasourceOptions().database!;
178
+
179
+ const statement: Statement = SQL`SELECT metricPointType FROM `;
180
+ statement.append(`${databaseName}.${this.model.tableName}`);
181
+ statement.append(
182
+ SQL` WHERE projectId = ${{
183
+ value: projectId,
184
+ type: TableColumnType.ObjectID,
185
+ }} AND name = ${{
186
+ value: metricName,
187
+ type: TableColumnType.Text,
188
+ }} AND isNotNull(metricPointType)`,
189
+ );
190
+
191
+ /*
192
+ * Bound the lookup to the aggregation's own window so ClickHouse
193
+ * can prune daily partitions (the table partitions on
194
+ * toYYYYMMDD(time)); without this the LIMIT 1 seek could touch
195
+ * every partition in retention for a metric with all-null point
196
+ * types. The point type is stable per metric name, so any window
197
+ * that has data answers correctly.
198
+ */
199
+ if (aggregateBy.startTimestamp && aggregateBy.endTimestamp) {
200
+ statement.append(
201
+ ` AND time >= toDateTime('${this.formatDateTime(
202
+ aggregateBy.startTimestamp,
203
+ )}') AND time <= toDateTime('${this.formatDateTime(
204
+ aggregateBy.endTimestamp,
205
+ )}')`,
206
+ );
207
+ }
208
+
209
+ statement.append(SQL` LIMIT 1`);
210
+
211
+ const dbResult: ResultSet<"JSON"> = await this.executeQuery(statement);
212
+ const responseJSON: ResponseJSON<JSONObject> =
213
+ await dbResult.json<JSONObject>();
214
+ const row: JSONObject | undefined = (responseJSON.data || [])[0];
215
+ const rawPointType: unknown = row ? row["metricPointType"] : undefined;
216
+ return typeof rawPointType === "string" ? rawPointType : null;
217
+ } catch (err) {
218
+ /*
219
+ * Lookup failures must not fail the aggregation — fall back to
220
+ * the (possibly MV-served) scalar path, which is today's
221
+ * behavior for unknown point types.
222
+ */
223
+ logger.debug("Metric point type lookup failed");
224
+ logger.debug(err);
225
+ return null;
226
+ }
227
+ }
228
+
229
+ private evictExpiredPointTypeCacheEntries(): void {
230
+ if (this.metricPointTypeCache.size < POINT_TYPE_CACHE_MAX_ENTRIES) {
231
+ return;
232
+ }
233
+ const now: number = Date.now();
234
+ for (const [key, entry] of this.metricPointTypeCache) {
235
+ if (entry.expiresAt <= now) {
236
+ this.metricPointTypeCache.delete(key);
237
+ }
238
+ }
239
+ /*
240
+ * Still full after dropping expired entries: evict oldest-inserted
241
+ * until under the cap (Map preserves insertion order). Never
242
+ * clear() — that would stampede every active dashboard into
243
+ * re-issuing its lookup at once.
244
+ */
245
+ while (this.metricPointTypeCache.size >= POINT_TYPE_CACHE_MAX_ENTRIES) {
246
+ const oldestKey: string | undefined = this.metricPointTypeCache
247
+ .keys()
248
+ .next().value;
249
+ if (oldestKey === undefined) {
250
+ break;
251
+ }
252
+ this.metricPointTypeCache.delete(oldestKey);
253
+ }
254
+ }
255
+
256
+ private isDistributionMetricAggregate(
257
+ aggregateBy: AggregateBy<Metric>,
258
+ ): boolean {
259
+ const pointType: string | null | undefined =
260
+ this.pointTypeHintByAggregate.get(aggregateBy);
261
+ return Boolean(pointType && DISTRIBUTION_POINT_TYPES.includes(pointType));
262
+ }
263
+
264
+ /**
265
+ * Validated, deduplicated attribute keys to group the aggregation by.
266
+ * Keys are bound as query parameters wherever they reach SQL, so this
267
+ * is shape validation (not injection defense): drop non-strings and
268
+ * empties, cap key length and count.
269
+ */
270
+ private getSanitizedGroupByAttributeKeys(
271
+ aggregateBy: AggregateBy<Metric>,
272
+ ): Array<string> {
273
+ const keys: Array<string> = [];
274
+ for (const rawKey of aggregateBy.groupByAttributeKeys || []) {
275
+ if (typeof rawKey !== "string") {
276
+ continue;
277
+ }
278
+ const key: string = rawKey.trim();
279
+ if (
280
+ !key ||
281
+ key.length > MAX_GROUP_BY_ATTRIBUTE_KEY_LENGTH ||
282
+ keys.includes(key)
283
+ ) {
284
+ continue;
285
+ }
286
+ keys.push(key);
287
+ }
288
+
289
+ if (keys.length > MAX_GROUP_BY_ATTRIBUTE_KEYS) {
290
+ throw new BadDataException(
291
+ `A maximum of ${MAX_GROUP_BY_ATTRIBUTE_KEYS} group-by attribute keys is supported.`,
292
+ );
293
+ }
294
+
295
+ return keys;
296
+ }
297
+
298
+ /**
299
+ * Appends `, attributes[{key}] AS __attr_grp_<i>` for every group-by
300
+ * attribute key. Used in the (inner) SELECT so the outer/main query
301
+ * can group on and re-project the extracted values.
302
+ */
303
+ private appendAttributeGroupExtractionColumns(
304
+ statement: Statement,
305
+ attributeGroupKeys: Array<string>,
306
+ ): void {
307
+ attributeGroupKeys.forEach((key: string, index: number) => {
308
+ statement.append(`, `);
309
+ statement.append(
310
+ SQL`attributes[${{ value: key, type: TableColumnType.Text }}]`,
311
+ );
312
+ statement.append(` AS __attr_grp_${index}`);
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Appends `, map({key0}, __attr_grp_0, ...) AS attributes` so grouped
318
+ * rows return exactly the selected keys as an attributes map — the
319
+ * same shape series splitters already read (`row.attributes[key]`).
320
+ */
321
+ private appendAttributeGroupMapColumn(
322
+ statement: Statement,
323
+ attributeGroupKeys: Array<string>,
324
+ ): void {
325
+ if (attributeGroupKeys.length === 0) {
326
+ return;
327
+ }
328
+ statement.append(`, map(`);
329
+ attributeGroupKeys.forEach((key: string, index: number) => {
330
+ if (index > 0) {
331
+ statement.append(`, `);
332
+ }
333
+ statement.append(SQL`${{ value: key, type: TableColumnType.Text }}`);
334
+ statement.append(`, __attr_grp_${index}`);
335
+ });
336
+ statement.append(`) AS attributes`);
337
+ }
338
+
28
339
  /*
29
340
  * Cascade deletes from `MetricItemV3` into the aggregating
30
341
  * materialized-view target tables.
@@ -182,6 +493,9 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
182
493
  return mutableMetricStatement;
183
494
  }
184
495
 
496
+ const attributeGroupKeys: Array<string> =
497
+ this.getSanitizedGroupByAttributeKeys(aggregateBy);
498
+
185
499
  if (!isPercentileAggregation(aggregateBy.aggregationType)) {
186
500
  /*
187
501
  * Try the per-host MV first — host detail pages are the
@@ -189,21 +503,43 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
189
503
  * the only one that can serve them. If it doesn't apply
190
504
  * (no host filter, or extra attrs/groupBy), fall through
191
505
  * to the project/primaryEntityId MV, then to the base table.
506
+ *
507
+ * Distribution metrics (histograms/summaries) must skip the
508
+ * MVs entirely: their states collapse `value`, which for
509
+ * distribution rows is the datapoint SUM — merging those can
510
+ * only ever produce sum-scale results. The base-table builder
511
+ * below weights by the datapoint `count` instead.
192
512
  */
193
- const hostMvStatement: {
194
- statement: Statement;
195
- columns: Array<string>;
196
- } | null = this.tryBuildHostAggregateMVStatement(aggregateBy);
197
- if (hostMvStatement) {
198
- return hostMvStatement;
513
+ if (
514
+ attributeGroupKeys.length === 0 &&
515
+ !this.isDistributionMetricAggregate(aggregateBy)
516
+ ) {
517
+ const hostMvStatement: {
518
+ statement: Statement;
519
+ columns: Array<string>;
520
+ } | null = this.tryBuildHostAggregateMVStatement(aggregateBy);
521
+ if (hostMvStatement) {
522
+ return hostMvStatement;
523
+ }
524
+ const mvStatement: {
525
+ statement: Statement;
526
+ columns: Array<string>;
527
+ } | null = this.tryBuildMinuteAggregateMVStatement(aggregateBy);
528
+ if (mvStatement) {
529
+ return mvStatement;
530
+ }
199
531
  }
200
- const mvStatement: {
201
- statement: Statement;
202
- columns: Array<string>;
203
- } | null = this.tryBuildMinuteAggregateMVStatement(aggregateBy);
204
- if (mvStatement) {
205
- return mvStatement;
532
+
533
+ if (
534
+ aggregateBy.aggregateColumnName.toString() === "value" &&
535
+ aggregateBy.aggregationTimestampColumnName.toString() === "time"
536
+ ) {
537
+ return this.buildScalarAggregateStatement(
538
+ aggregateBy,
539
+ attributeGroupKeys,
540
+ );
206
541
  }
542
+
207
543
  return super.toAggregateStatement(aggregateBy);
208
544
  }
209
545
 
@@ -233,7 +569,10 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
233
569
  * Group-by columns from the caller need to be carried through the
234
570
  * inner subquery so the outer GROUP BY can reference them. Only
235
571
  * columns that exist on the model are accepted (matches the base
236
- * generator's safety net).
572
+ * generator's safety net). A whole-map `attributes` group-by is
573
+ * dropped when key-scoped grouping is requested — keeping both
574
+ * would fragment the series again and collide with the
575
+ * `map(...) AS attributes` projection.
237
576
  */
238
577
  const groupByKeys: Array<string> = [];
239
578
  if (aggregateBy.groupBy) {
@@ -241,6 +580,9 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
241
580
  if (!this.model.getTableColumn(key)) {
242
581
  continue;
243
582
  }
583
+ if (key === "attributes" && attributeGroupKeys.length > 0) {
584
+ continue;
585
+ }
244
586
  groupByKeys.push(key);
245
587
  }
246
588
  }
@@ -336,8 +678,20 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
336
678
  statement.append(`, ${key}`);
337
679
  }
338
680
 
681
+ /*
682
+ * Attribute-key grouping: the inner subquery extracts each selected
683
+ * key into `__attr_grp_<i>`; the outer query groups on those and
684
+ * folds them back into a per-row `attributes` map so the result
685
+ * shape matches what series splitters read. Grouping happens on the
686
+ * individual keys — NOT the whole attributes map, which would
687
+ * fragment the quantile into one near-constant series per unique
688
+ * attribute combination.
689
+ */
690
+ this.appendAttributeGroupMapColumn(statement, attributeGroupKeys);
691
+
339
692
  statement.append(SQL` FROM (`);
340
693
  statement.append(`SELECT ${innerSelectClause}`);
694
+ this.appendAttributeGroupExtractionColumns(statement, attributeGroupKeys);
341
695
  statement.append(
342
696
  ` FROM ${databaseName}.${this.model.tableName} WHERE TRUE `,
343
697
  );
@@ -349,6 +703,9 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
349
703
  for (const key of groupByKeys) {
350
704
  statement.append(`, ${key}`);
351
705
  }
706
+ attributeGroupKeys.forEach((_key: string, index: number) => {
707
+ statement.append(`, __attr_grp_${index}`);
708
+ });
352
709
 
353
710
  statement.append(SQL` ORDER BY `).append(sortStatement);
354
711
 
@@ -385,6 +742,7 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
385
742
  aggregationColumn,
386
743
  aggregationTimestampColumn,
387
744
  ...groupByKeys,
745
+ ...(attributeGroupKeys.length > 0 ? ["attributes"] : []),
388
746
  ];
389
747
 
390
748
  logger.debug(`${this.model.tableName} Percentile Aggregate Statement`, {
@@ -397,6 +755,217 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
397
755
  return { statement, columns };
398
756
  }
399
757
 
758
+ /**
759
+ * Distribution-aware per-bucket expression for scalar aggregations
760
+ * over `value`. Distribution rows (histogram/summary — identified by
761
+ * a non-null `count`) carry the datapoint's observation total in
762
+ * `sum` (mirrored into `value` at ingest) and the observation count
763
+ * in `count`, so:
764
+ *
765
+ * Avg -> sum(sum) / sum(count) (count-weighted mean of
766
+ * observations, not the mean of per-export-interval sums)
767
+ * Sum -> sum(sum) (total of all observations)
768
+ * Count -> sum(count) (number of observations, not
769
+ * the number of export intervals)
770
+ * Min -> min(min), falling back to the datapoint mean when the
771
+ * optional OTLP min is absent
772
+ * Max -> max(max), same fallback
773
+ *
774
+ * Scalar rows (Sum/Gauge points — `count` is null) degrade to the
775
+ * plain value semantics these aggregations always had: each row is
776
+ * one observation of `value` with weight 1.
777
+ */
778
+ private getDistributionAwareAggregationExpression(
779
+ aggregationType: AggregationType,
780
+ column: string,
781
+ ): string {
782
+ const distRowCondition: string = `isNotNull(count) AND isNotNull(coalesce(sum, ${column}))`;
783
+ const observationTotalExpression: string = `sum(multiIf(${distRowCondition}, toFloat64(coalesce(sum, ${column})), isNotNull(${column}), toFloat64(${column}), 0))`;
784
+ const observationCountExpression: string = `sum(multiIf(${distRowCondition}, toFloat64(count), isNotNull(${column}), 1, 0))`;
785
+ const datapointMeanExpression: string = `toFloat64(coalesce(sum, ${column})) / toFloat64(count)`;
786
+
787
+ switch (aggregationType) {
788
+ case AggregationType.Sum:
789
+ return observationTotalExpression;
790
+ case AggregationType.Count:
791
+ return observationCountExpression;
792
+ case AggregationType.Avg:
793
+ return `if(${observationCountExpression} = 0, 0, ${observationTotalExpression} / ${observationCountExpression})`;
794
+ case AggregationType.Min:
795
+ return `min(multiIf(isNotNull(min), toFloat64(min), ${distRowCondition} AND count > 0, ${datapointMeanExpression}, isNotNull(${column}), toFloat64(${column}), NULL))`;
796
+ case AggregationType.Max:
797
+ return `max(multiIf(isNotNull(max), toFloat64(max), ${distRowCondition} AND count > 0, ${datapointMeanExpression}, isNotNull(${column}), toFloat64(${column}), NULL))`;
798
+ default:
799
+ return `${aggregationType.toLocaleLowerCase()}(${column})`;
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Base-table statement for non-percentile aggregations over `value`,
805
+ * used whenever the MV fast paths don't apply (attribute filters,
806
+ * group-by, or a distribution metric). Compared to the generic
807
+ * statement in AnalyticsDatabaseService this adds:
808
+ *
809
+ * - distribution-aware aggregation expressions (see
810
+ * getDistributionAwareAggregationExpression), and
811
+ * - grouping by individual attribute keys via
812
+ * `attributes['<key>']` (aliased and re-projected as a compact
813
+ * `attributes` map), which GroupBy<Metric> cannot express since
814
+ * it only references whole model columns.
815
+ *
816
+ * Model-column group-bys (including the legacy whole-map
817
+ * `groupBy: { attributes: true }` used by the infra pages) are
818
+ * carried through unchanged, appended as raw identifiers exactly
819
+ * like the percentile path does.
820
+ */
821
+ private buildScalarAggregateStatement(
822
+ aggregateBy: AggregateBy<Metric>,
823
+ attributeGroupKeys: Array<string>,
824
+ ): { statement: Statement; columns: Array<string> } {
825
+ if (!this.database) {
826
+ this.useDefaultDatabase();
827
+ }
828
+
829
+ const databaseName: string = this.database.getDatasourceOptions().database!;
830
+
831
+ const aggregationColumn: string =
832
+ aggregateBy.aggregateColumnName.toString();
833
+ const aggregationTimestampColumn: string =
834
+ aggregateBy.aggregationTimestampColumnName.toString();
835
+ const aggregationInterval: string = AggregateUtil.getAggregationInterval({
836
+ startDate: aggregateBy.startTimestamp!,
837
+ endDate: aggregateBy.endTimestamp!,
838
+ }).toLowerCase();
839
+
840
+ const groupByKeys: Array<string> = [];
841
+ if (aggregateBy.groupBy) {
842
+ for (const key of Object.keys(aggregateBy.groupBy)) {
843
+ if (!this.model.getTableColumn(key)) {
844
+ continue;
845
+ }
846
+ /*
847
+ * Key-scoped grouping supersedes a whole-map `attributes`
848
+ * group-by: keeping both would fragment the series again AND
849
+ * collide with the `map(...) AS attributes` projection below.
850
+ */
851
+ if (key === "attributes" && attributeGroupKeys.length > 0) {
852
+ continue;
853
+ }
854
+ groupByKeys.push(key);
855
+ }
856
+ }
857
+
858
+ const whereStatement: Statement = this.statementGenerator.toWhereStatement(
859
+ aggregateBy.query,
860
+ );
861
+ const sortStatement: Statement = this.statementGenerator.toSortStatement(
862
+ aggregateBy.sort!,
863
+ );
864
+
865
+ const aggregationExpression: string =
866
+ this.getDistributionAwareAggregationExpression(
867
+ aggregateBy.aggregationType,
868
+ aggregationColumn,
869
+ );
870
+
871
+ const statement: Statement = SQL``;
872
+
873
+ statement.append(
874
+ `SELECT ${aggregationExpression} as ${aggregationColumn}, date_trunc('${aggregationInterval}', toStartOfInterval(${aggregationTimestampColumn}, INTERVAL 1 ${aggregationInterval})) as ${aggregationTimestampColumn}`,
875
+ );
876
+ for (const key of groupByKeys) {
877
+ statement.append(`, ${key}`);
878
+ }
879
+ this.appendAttributeGroupMapColumn(statement, attributeGroupKeys);
880
+
881
+ if (attributeGroupKeys.length > 0) {
882
+ /*
883
+ * Subquery form: extract the selected keys into `__attr_grp_<i>`
884
+ * one level down so the outer `map(...) AS attributes` alias can
885
+ * never shadow (or cycle with) the real `attributes` column the
886
+ * extraction and WHERE filters read. Mirrors the percentile path.
887
+ */
888
+ const innerColumns: Array<string> = [
889
+ aggregationTimestampColumn,
890
+ "value",
891
+ "sum",
892
+ "count",
893
+ "min",
894
+ "max",
895
+ ];
896
+ for (const key of groupByKeys) {
897
+ if (!innerColumns.includes(key)) {
898
+ innerColumns.push(key);
899
+ }
900
+ }
901
+
902
+ statement.append(SQL` FROM (`);
903
+ statement.append(`SELECT ${innerColumns.join(", ")}`);
904
+ this.appendAttributeGroupExtractionColumns(statement, attributeGroupKeys);
905
+ statement.append(
906
+ ` FROM ${databaseName}.${this.model.tableName} WHERE TRUE `,
907
+ );
908
+ statement.append(whereStatement);
909
+ statement.append(this.getRetentionReadFilter());
910
+ statement.append(SQL`) `);
911
+ } else {
912
+ statement.append(
913
+ ` FROM ${databaseName}.${this.model.tableName} WHERE TRUE `,
914
+ );
915
+ statement.append(whereStatement);
916
+ statement.append(this.getRetentionReadFilter());
917
+ }
918
+
919
+ statement.append(SQL` GROUP BY `).append(`${aggregationTimestampColumn}`);
920
+ for (const key of groupByKeys) {
921
+ statement.append(`, ${key}`);
922
+ }
923
+ attributeGroupKeys.forEach((_key: string, index: number) => {
924
+ statement.append(`, __attr_grp_${index}`);
925
+ });
926
+
927
+ statement.append(SQL` ORDER BY `).append(sortStatement);
928
+
929
+ statement.append(
930
+ SQL` LIMIT ${{
931
+ value: Number(aggregateBy.limit),
932
+ type: TableColumnType.Number,
933
+ }}`,
934
+ );
935
+ statement.append(
936
+ SQL` OFFSET ${{
937
+ value: Number(aggregateBy.skip),
938
+ type: TableColumnType.Number,
939
+ }} `,
940
+ );
941
+
942
+ statement.append(
943
+ getQuerySettings({
944
+ additionalSettings: {
945
+ optimize_aggregation_in_order: 1,
946
+ optimize_move_to_prewhere: 1,
947
+ max_threads: 4,
948
+ },
949
+ }),
950
+ );
951
+
952
+ const columns: Array<string> = [
953
+ aggregationColumn,
954
+ aggregationTimestampColumn,
955
+ ...groupByKeys,
956
+ ...(attributeGroupKeys.length > 0 ? ["attributes"] : []),
957
+ ];
958
+
959
+ logger.debug(`${this.model.tableName} Scalar Aggregate Statement`, {
960
+ tableName: this.model.tableName,
961
+ } as LogAttributes);
962
+ logger.debug(statement, {
963
+ tableName: this.model.tableName,
964
+ } as LogAttributes);
965
+
966
+ return { statement, columns };
967
+ }
968
+
400
969
  private tryBuildMutableMetricAggregateStatement(
401
970
  aggregateBy: AggregateBy<Metric>,
402
971
  ): { statement: Statement; columns: Array<string> } | null {
@@ -564,6 +1133,13 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
564
1133
  private canRouteAggregateToMutableMetricTable(
565
1134
  aggregateBy: AggregateBy<Metric>,
566
1135
  ): boolean {
1136
+ if (
1137
+ aggregateBy.groupByAttributeKeys &&
1138
+ aggregateBy.groupByAttributeKeys.length > 0
1139
+ ) {
1140
+ return false;
1141
+ }
1142
+
567
1143
  const supportedColumns: Set<string> = new Set<string>([
568
1144
  "projectId",
569
1145
  "name",
@@ -687,6 +1263,13 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
687
1263
  return null;
688
1264
  }
689
1265
 
1266
+ if (
1267
+ aggregateBy.groupByAttributeKeys &&
1268
+ aggregateBy.groupByAttributeKeys.length > 0
1269
+ ) {
1270
+ return null;
1271
+ }
1272
+
690
1273
  /*
691
1274
  * The MV only carries projectId/name/primaryEntityId/bucketTime, so a
692
1275
  * query filtering on any other column (e.g. entityKeys membership,
@@ -842,6 +1425,13 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
842
1425
  return null;
843
1426
  }
844
1427
 
1428
+ if (
1429
+ aggregateBy.groupByAttributeKeys &&
1430
+ aggregateBy.groupByAttributeKeys.length > 0
1431
+ ) {
1432
+ return null;
1433
+ }
1434
+
845
1435
  /*
846
1436
  * Inspect the attribute filter. This MV is only safe when
847
1437
  * the user is filtering by exactly one attribute,