@oneuptime/common 10.0.96 → 10.0.98

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 (131) hide show
  1. package/Models/AnalyticsModels/Log.ts +6 -0
  2. package/Models/AnalyticsModels/Metric.ts +6 -0
  3. package/Models/AnalyticsModels/Profile.ts +6 -0
  4. package/Models/AnalyticsModels/Span.ts +6 -0
  5. package/Models/DatabaseModels/Alert.ts +52 -0
  6. package/Models/DatabaseModels/DockerHost.ts +3 -10
  7. package/Models/DatabaseModels/Host.ts +1015 -0
  8. package/Models/DatabaseModels/HostOwnerTeam.ts +462 -0
  9. package/Models/DatabaseModels/HostOwnerUser.ts +461 -0
  10. package/Models/DatabaseModels/Incident.ts +52 -0
  11. package/Models/DatabaseModels/Index.ts +6 -0
  12. package/Models/DatabaseModels/KubernetesCluster.ts +0 -7
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1778006035712-AddHostTables.ts +201 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1778013317872-AddHostIpAddresses.ts +15 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1778066346303-WidenHostOsVersionToLongText.ts +42 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1778070278986-MigrationName.ts +79 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  18. package/Server/Services/DockerHostService.ts +18 -5
  19. package/Server/Services/HostOwnerTeamService.ts +10 -0
  20. package/Server/Services/HostOwnerUserService.ts +10 -0
  21. package/Server/Services/HostService.ts +251 -0
  22. package/Server/Services/LogAggregationService.ts +10 -3
  23. package/Server/Services/MetricService.ts +200 -0
  24. package/Server/Services/TraceAggregationService.ts +8 -3
  25. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +46 -18
  26. package/Server/Utils/Monitor/MonitorAlert.ts +37 -0
  27. package/Server/Utils/Monitor/MonitorIncident.ts +37 -0
  28. package/Tests/Server/Services/LogAggregationService.test.ts +25 -0
  29. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +145 -0
  30. package/Types/Metrics/MetricQueryConfigData.ts +9 -0
  31. package/Types/Permission.ts +134 -0
  32. package/UI/Components/Charts/Area/AreaChart.tsx +1 -1
  33. package/UI/Components/Charts/Bar/BarChart.tsx +1 -1
  34. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +17 -8
  35. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +11 -6
  36. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +17 -8
  37. package/UI/Components/Charts/Line/LineChart.tsx +1 -1
  38. package/UI/Components/ExpandableText/ExpandableText.tsx +29 -7
  39. package/UI/Components/JSONTable/JSONTable.tsx +27 -1
  40. package/UI/Components/LogsViewer/LogsViewer.tsx +3 -0
  41. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +109 -23
  42. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +11 -4
  43. package/UI/Components/Navbar/NavBarMenu.tsx +17 -2
  44. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +10 -3
  45. package/Utils/ValueFormatter.ts +57 -3
  46. package/build/dist/Models/AnalyticsModels/Log.js +6 -0
  47. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  48. package/build/dist/Models/AnalyticsModels/Metric.js +6 -0
  49. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  50. package/build/dist/Models/AnalyticsModels/Profile.js +6 -0
  51. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  52. package/build/dist/Models/AnalyticsModels/Span.js +6 -0
  53. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  54. package/build/dist/Models/DatabaseModels/Alert.js +51 -0
  55. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  56. package/build/dist/Models/DatabaseModels/DockerHost.js +3 -10
  57. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  58. package/build/dist/Models/DatabaseModels/Host.js +1041 -0
  59. package/build/dist/Models/DatabaseModels/Host.js.map +1 -0
  60. package/build/dist/Models/DatabaseModels/HostOwnerTeam.js +480 -0
  61. package/build/dist/Models/DatabaseModels/HostOwnerTeam.js.map +1 -0
  62. package/build/dist/Models/DatabaseModels/HostOwnerUser.js +479 -0
  63. package/build/dist/Models/DatabaseModels/HostOwnerUser.js.map +1 -0
  64. package/build/dist/Models/DatabaseModels/Incident.js +51 -0
  65. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  66. package/build/dist/Models/DatabaseModels/Index.js +6 -0
  67. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  68. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +0 -7
  69. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778006035712-AddHostTables.js +76 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778006035712-AddHostTables.js.map +1 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778013317872-AddHostIpAddresses.js +12 -0
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778013317872-AddHostIpAddresses.js.map +1 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778066346303-WidenHostOsVersionToLongText.js +31 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778066346303-WidenHostOsVersionToLongText.js.map +1 -0
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778070278986-MigrationName.js +34 -0
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778070278986-MigrationName.js.map +1 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  80. package/build/dist/Server/Services/DockerHostService.js +12 -5
  81. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  82. package/build/dist/Server/Services/HostOwnerTeamService.js +9 -0
  83. package/build/dist/Server/Services/HostOwnerTeamService.js.map +1 -0
  84. package/build/dist/Server/Services/HostOwnerUserService.js +9 -0
  85. package/build/dist/Server/Services/HostOwnerUserService.js.map +1 -0
  86. package/build/dist/Server/Services/HostService.js +214 -0
  87. package/build/dist/Server/Services/HostService.js.map +1 -0
  88. package/build/dist/Server/Services/LogAggregationService.js +10 -3
  89. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  90. package/build/dist/Server/Services/MetricService.js +160 -0
  91. package/build/dist/Server/Services/MetricService.js.map +1 -1
  92. package/build/dist/Server/Services/TraceAggregationService.js +8 -3
  93. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  94. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +46 -18
  95. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  96. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +32 -0
  97. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  98. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +32 -0
  99. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  100. package/build/dist/Tests/Server/Services/LogAggregationService.test.js +13 -0
  101. package/build/dist/Tests/Server/Services/LogAggregationService.test.js.map +1 -1
  102. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js +123 -0
  103. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js.map +1 -1
  104. package/build/dist/Types/Permission.js +120 -0
  105. package/build/dist/Types/Permission.js.map +1 -1
  106. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  107. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  108. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +15 -7
  109. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  110. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +10 -6
  111. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  112. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +16 -8
  113. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  114. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  115. package/build/dist/UI/Components/ExpandableText/ExpandableText.js +10 -5
  116. package/build/dist/UI/Components/ExpandableText/ExpandableText.js.map +1 -1
  117. package/build/dist/UI/Components/JSONTable/JSONTable.js +8 -1
  118. package/build/dist/UI/Components/JSONTable/JSONTable.js.map +1 -1
  119. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +1 -1
  120. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  121. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +40 -14
  122. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  123. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +10 -4
  124. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  125. package/build/dist/UI/Components/Navbar/NavBarMenu.js +15 -2
  126. package/build/dist/UI/Components/Navbar/NavBarMenu.js.map +1 -1
  127. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +10 -3
  128. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  129. package/build/dist/Utils/ValueFormatter.js +47 -3
  130. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  131. package/package.json +1 -1
@@ -0,0 +1,251 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/Host";
3
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ObjectID from "../../Types/ObjectID";
5
+ import QueryHelper from "../Types/Database/QueryHelper";
6
+ import OneUptimeDate from "../../Types/Date";
7
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
8
+ import GlobalCache from "../Infrastructure/GlobalCache";
9
+ import crypto from "crypto";
10
+
11
+ const LAST_SEEN_CACHE_NAMESPACE: string = "host-last-seen";
12
+ const LAST_SEEN_THROTTLE_SECONDS: number = 60;
13
+
14
+ export class Service extends DatabaseService<Model> {
15
+ public constructor() {
16
+ super(Model);
17
+ }
18
+
19
+ @CaptureSpan()
20
+ public async findOrCreateByHostIdentifier(data: {
21
+ projectId: ObjectID;
22
+ hostIdentifier: string;
23
+ }): Promise<Model> {
24
+ const existingHost: Model | null = await this.findOneBy({
25
+ query: {
26
+ projectId: data.projectId,
27
+ hostIdentifier: data.hostIdentifier,
28
+ },
29
+ select: {
30
+ _id: true,
31
+ projectId: true,
32
+ hostIdentifier: true,
33
+ },
34
+ props: {
35
+ isRoot: true,
36
+ },
37
+ });
38
+
39
+ if (existingHost) {
40
+ return existingHost;
41
+ }
42
+
43
+ try {
44
+ const newHost: Model = new Model();
45
+ newHost.projectId = data.projectId;
46
+ newHost.name = data.hostIdentifier;
47
+ newHost.hostIdentifier = data.hostIdentifier;
48
+ newHost.otelCollectorStatus = "connected";
49
+ newHost.lastSeenAt = OneUptimeDate.getCurrentDate();
50
+
51
+ const createdHost: Model = await this.create({
52
+ data: newHost,
53
+ props: {
54
+ isRoot: true,
55
+ },
56
+ });
57
+
58
+ return createdHost;
59
+ } catch {
60
+ const reFetchedHost: Model | null = await this.findOneBy({
61
+ query: {
62
+ projectId: data.projectId,
63
+ hostIdentifier: data.hostIdentifier,
64
+ },
65
+ select: {
66
+ _id: true,
67
+ projectId: true,
68
+ hostIdentifier: true,
69
+ },
70
+ props: {
71
+ isRoot: true,
72
+ },
73
+ });
74
+
75
+ if (reFetchedHost) {
76
+ return reFetchedHost;
77
+ }
78
+
79
+ throw new Error("Failed to create or find host: " + data.hostIdentifier);
80
+ }
81
+ }
82
+
83
+ @CaptureSpan()
84
+ public async updateLastSeen(
85
+ hostId: ObjectID,
86
+ extra?: {
87
+ osType?: string | undefined;
88
+ osVersion?: string | undefined;
89
+ hostId?: string | undefined;
90
+ hostArch?: string | undefined;
91
+ hostType?: string | undefined;
92
+ hostIpAddresses?: string | undefined;
93
+ cpuCores?: number | undefined;
94
+ totalMemoryBytes?: number | undefined;
95
+ processCount?: number | undefined;
96
+ containerRuntime?: string | undefined;
97
+ dockerHostId?: ObjectID | undefined;
98
+ kubernetesClusterId?: ObjectID | undefined;
99
+ },
100
+ ): Promise<void> {
101
+ /*
102
+ * Throttle: the same telemetry batch repeats every metric/log/trace
103
+ * push and re-sends identical host metadata. Skip the DB write when
104
+ * we recently wrote the exact same values; only refresh `lastSeenAt`
105
+ * once per throttle window. If any extra value changed (e.g. cpuCores
106
+ * updated, IP address changed), bust the cache and write immediately.
107
+ */
108
+ const cacheKey: string = hostId.toString();
109
+ const extrasFingerprint: string = this.fingerprintExtras(extra);
110
+ const cached: string | null = await GlobalCache.getString(
111
+ LAST_SEEN_CACHE_NAMESPACE,
112
+ cacheKey,
113
+ );
114
+
115
+ if (cached === extrasFingerprint) {
116
+ return; // same data was written recently
117
+ }
118
+
119
+ await GlobalCache.setString(
120
+ LAST_SEEN_CACHE_NAMESPACE,
121
+ cacheKey,
122
+ extrasFingerprint,
123
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
124
+ );
125
+
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ const data: any = {
128
+ lastSeenAt: OneUptimeDate.getCurrentDate(),
129
+ otelCollectorStatus: "connected",
130
+ };
131
+
132
+ if (extra?.osType) {
133
+ data.osType = extra.osType;
134
+ }
135
+ if (extra?.osVersion) {
136
+ data.osVersion = extra.osVersion;
137
+ }
138
+ if (extra?.hostId) {
139
+ data.hostId = extra.hostId;
140
+ }
141
+ if (extra?.hostArch) {
142
+ data.hostArch = extra.hostArch;
143
+ }
144
+ if (extra?.hostType) {
145
+ data.hostType = extra.hostType;
146
+ }
147
+ if (extra?.hostIpAddresses) {
148
+ data.hostIpAddresses = extra.hostIpAddresses;
149
+ }
150
+ if (extra?.cpuCores !== undefined) {
151
+ data.cpuCores = extra.cpuCores;
152
+ }
153
+ if (extra?.totalMemoryBytes !== undefined) {
154
+ data.totalMemoryBytes = extra.totalMemoryBytes;
155
+ }
156
+ if (extra?.processCount !== undefined) {
157
+ data.processCount = extra.processCount;
158
+ }
159
+ if (extra?.containerRuntime) {
160
+ data.containerRuntime = extra.containerRuntime;
161
+ }
162
+ if (extra?.dockerHostId) {
163
+ data.dockerHostId = extra.dockerHostId;
164
+ }
165
+ if (extra?.kubernetesClusterId) {
166
+ data.kubernetesClusterId = extra.kubernetesClusterId;
167
+ }
168
+
169
+ await this.updateOneById({
170
+ id: hostId,
171
+ data: data,
172
+ props: {
173
+ isRoot: true,
174
+ },
175
+ });
176
+ }
177
+
178
+ private fingerprintExtras(extra?: {
179
+ osType?: string | undefined;
180
+ osVersion?: string | undefined;
181
+ hostId?: string | undefined;
182
+ hostArch?: string | undefined;
183
+ hostType?: string | undefined;
184
+ hostIpAddresses?: string | undefined;
185
+ cpuCores?: number | undefined;
186
+ totalMemoryBytes?: number | undefined;
187
+ processCount?: number | undefined;
188
+ containerRuntime?: string | undefined;
189
+ dockerHostId?: ObjectID | undefined;
190
+ kubernetesClusterId?: ObjectID | undefined;
191
+ }): string {
192
+ const normalized: Record<string, string | number | null> = {
193
+ osType: extra?.osType ?? null,
194
+ osVersion: extra?.osVersion ?? null,
195
+ hostId: extra?.hostId ?? null,
196
+ hostArch: extra?.hostArch ?? null,
197
+ hostType: extra?.hostType ?? null,
198
+ hostIpAddresses: extra?.hostIpAddresses ?? null,
199
+ cpuCores: extra?.cpuCores ?? null,
200
+ totalMemoryBytes: extra?.totalMemoryBytes ?? null,
201
+ processCount: extra?.processCount ?? null,
202
+ containerRuntime: extra?.containerRuntime ?? null,
203
+ dockerHostId: extra?.dockerHostId?.toString() ?? null,
204
+ kubernetesClusterId: extra?.kubernetesClusterId?.toString() ?? null,
205
+ };
206
+
207
+ return crypto
208
+ .createHash("sha1")
209
+ .update(JSON.stringify(normalized))
210
+ .digest("hex");
211
+ }
212
+
213
+ @CaptureSpan()
214
+ public async markDisconnectedHosts(): Promise<void> {
215
+ const fiveMinutesAgo: Date = OneUptimeDate.addRemoveMinutes(
216
+ OneUptimeDate.getCurrentDate(),
217
+ -5,
218
+ );
219
+
220
+ const connectedHosts: Array<Model> = await this.findBy({
221
+ query: {
222
+ otelCollectorStatus: "connected",
223
+ lastSeenAt: QueryHelper.lessThan(fiveMinutesAgo),
224
+ },
225
+ select: {
226
+ _id: true,
227
+ },
228
+ limit: LIMIT_MAX,
229
+ skip: 0,
230
+ props: {
231
+ isRoot: true,
232
+ },
233
+ });
234
+
235
+ for (const host of connectedHosts) {
236
+ if (host._id) {
237
+ await this.updateOneById({
238
+ id: new ObjectID(host._id.toString()),
239
+ data: {
240
+ otelCollectorStatus: "disconnected",
241
+ },
242
+ props: {
243
+ isRoot: true,
244
+ },
245
+ });
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ export default new Service();
@@ -669,14 +669,21 @@ export class LogAggregationService {
669
669
  for (const [attrKey, attrValue] of Object.entries(request.attributes)) {
670
670
  LogAggregationService.validateFacetKey(attrKey);
671
671
 
672
+ /*
673
+ * Match attribute keys case-insensitively — keys in the data come
674
+ * from many sources (OTEL conventions are dot.lowercase, app code
675
+ * often uses camelCase like `requestId`), and forcing users to
676
+ * remember the exact casing is a poor experience. The user-supplied
677
+ * key is validated above.
678
+ */
672
679
  statement.append(
673
- SQL` AND attributes[${{
680
+ SQL` AND arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
674
681
  type: TableColumnType.Text,
675
682
  value: attrKey,
676
- }}] = ${{
683
+ }}) AND v = ${{
677
684
  type: TableColumnType.Text,
678
685
  value: attrValue,
679
- }}`,
686
+ }}, mapKeys(attributes), mapValues(attributes))`,
680
687
  );
681
688
  }
682
689
  }
@@ -57,6 +57,20 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
57
57
  columns: Array<string>;
58
58
  } {
59
59
  if (!isPercentileAggregation(aggregateBy.aggregationType)) {
60
+ /*
61
+ * Try the per-host MV first — host detail pages are the
62
+ * dominant attribute-filtered path and the per-host MV is
63
+ * the only one that can serve them. If it doesn't apply
64
+ * (no host filter, or extra attrs/groupBy), fall through
65
+ * to the project/serviceId MV, then to the base table.
66
+ */
67
+ const hostMvStatement: {
68
+ statement: Statement;
69
+ columns: Array<string>;
70
+ } | null = this.tryBuildHostAggregateMVStatement(aggregateBy);
71
+ if (hostMvStatement) {
72
+ return hostMvStatement;
73
+ }
60
74
  const mvStatement: {
61
75
  statement: Statement;
62
76
  columns: Array<string>;
@@ -401,6 +415,192 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
401
415
  };
402
416
  }
403
417
 
418
+ /*
419
+ * Per-host materialized-view fast path.
420
+ *
421
+ * Returns a statement that reads from MetricItemAggMV1mByHost
422
+ * (created by AddMetricMinuteAggregateByHostMaterializedView)
423
+ * when:
424
+ *
425
+ * - The aggregation is Sum/Avg/Min/Max/Count over `value`.
426
+ * - The only attribute filter is `resource.host.name` as a
427
+ * bare-string equality (the dashboard's host detail page
428
+ * pattern).
429
+ * - The query carries no group-by other than the time
430
+ * bucket — the MV is keyed by hostIdentifier and does not
431
+ * preserve other attribute breakdowns.
432
+ *
433
+ * Returns `null` if any condition fails so the caller falls
434
+ * through to the next fast path / base table. The result row
435
+ * shape (columns: aggregateColumn, timestampColumn) matches
436
+ * the base statement so downstream code needs no changes.
437
+ */
438
+ private tryBuildHostAggregateMVStatement(
439
+ aggregateBy: AggregateBy<Metric>,
440
+ ): { statement: Statement; columns: Array<string> } | null {
441
+ const aggType: AggregationType = aggregateBy.aggregationType;
442
+ const supported: ReadonlyArray<AggregationType> = [
443
+ AggregationType.Sum,
444
+ AggregationType.Avg,
445
+ AggregationType.Min,
446
+ AggregationType.Max,
447
+ AggregationType.Count,
448
+ ];
449
+ if (!supported.includes(aggType)) {
450
+ return null;
451
+ }
452
+
453
+ if (
454
+ aggregateBy.aggregateColumnName.toString() !== "value" ||
455
+ aggregateBy.aggregationTimestampColumnName.toString() !== "time"
456
+ ) {
457
+ return null;
458
+ }
459
+
460
+ if (aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0) {
461
+ return null;
462
+ }
463
+
464
+ /*
465
+ * Inspect the attribute filter. This MV is only safe when
466
+ * the user is filtering by exactly one attribute,
467
+ * `resource.host.name`, with a bare-string value (the
468
+ * canonical Overview/Metrics-page pattern). Anything else —
469
+ * extra attribute filters, NotEqual, Search, etc. — has to
470
+ * fall back so the result stays correct.
471
+ */
472
+ const queryRecord: Record<string, unknown> =
473
+ (aggregateBy.query as unknown as Record<string, unknown>) || {};
474
+ const attrs: unknown = queryRecord["attributes"];
475
+ if (!attrs || typeof attrs !== "object") {
476
+ return null;
477
+ }
478
+ const attrEntries: Array<[string, unknown]> = Object.entries(
479
+ attrs as Record<string, unknown>,
480
+ );
481
+ if (attrEntries.length !== 1) {
482
+ return null;
483
+ }
484
+ const [attrKey, attrValue] = attrEntries[0]!;
485
+ if (attrKey !== "resource.host.name") {
486
+ return null;
487
+ }
488
+ if (attrValue === undefined || attrValue === null) {
489
+ return null;
490
+ }
491
+ const hostIdentifier: string =
492
+ typeof attrValue === "string" ? attrValue : "";
493
+ if (!hostIdentifier) {
494
+ return null;
495
+ }
496
+
497
+ const interval: AggregationInterval = AggregateUtil.getAggregationInterval({
498
+ startDate: aggregateBy.startTimestamp!,
499
+ endDate: aggregateBy.endTimestamp!,
500
+ });
501
+ void interval;
502
+
503
+ if (!this.database) {
504
+ this.useDefaultDatabase();
505
+ }
506
+ const databaseName: string = this.database.getDatasourceOptions().database!;
507
+
508
+ const intervalLower: string = interval.toLowerCase();
509
+
510
+ let mergedExpr: string;
511
+ if (aggType === AggregationType.Sum) {
512
+ mergedExpr = `sumMerge(valueSumState)`;
513
+ } else if (aggType === AggregationType.Count) {
514
+ mergedExpr = `countMerge(valueCountState)`;
515
+ } else if (aggType === AggregationType.Min) {
516
+ mergedExpr = `minMerge(valueMinState)`;
517
+ } else if (aggType === AggregationType.Max) {
518
+ mergedExpr = `maxMerge(valueMaxState)`;
519
+ } else {
520
+ mergedExpr = `if(countMerge(valueCountState) = 0, 0, sumMerge(valueSumState) / countMerge(valueCountState))`;
521
+ }
522
+
523
+ /*
524
+ * Strip both `time` (column doesn't exist on the MV; we
525
+ * inject an explicit bucketTime range below) and
526
+ * `attributes` (the attribute filter is now an explicit
527
+ * `hostIdentifier =` predicate against an MV column).
528
+ */
529
+ const filteredQuery: typeof aggregateBy.query =
530
+ this.stripAttributesAndTimeFromQuery(
531
+ aggregateBy.query,
532
+ ) as typeof aggregateBy.query;
533
+ const nonTimeWhere: Statement =
534
+ this.statementGenerator.toWhereStatement(filteredQuery);
535
+ const sortStatement: Statement = this.statementGenerator.toSortStatement(
536
+ aggregateBy.sort!,
537
+ );
538
+
539
+ const statement: Statement = SQL``;
540
+
541
+ statement.append(
542
+ `SELECT ${mergedExpr} as value, date_trunc('${intervalLower}', toStartOfInterval(bucketTime, INTERVAL 1 ${intervalLower})) as time`,
543
+ );
544
+ statement.append(SQL` FROM ${databaseName}.MetricItemAggMV1mByHost`);
545
+ statement.append(
546
+ ` WHERE bucketTime >= toDateTime('${this.formatDateTime(aggregateBy.startTimestamp!)}') AND bucketTime <= toDateTime('${this.formatDateTime(aggregateBy.endTimestamp!)}')`,
547
+ );
548
+ statement.append(
549
+ SQL` AND hostIdentifier = ${{
550
+ value: hostIdentifier,
551
+ type: TableColumnType.Text,
552
+ }}`,
553
+ );
554
+ statement.append(SQL` `).append(nonTimeWhere);
555
+
556
+ statement.append(SQL` GROUP BY `).append(`time`);
557
+ statement.append(SQL` ORDER BY `).append(sortStatement);
558
+ statement.append(
559
+ SQL` LIMIT ${{
560
+ value: Number(aggregateBy.limit),
561
+ type: TableColumnType.Number,
562
+ }}`,
563
+ );
564
+ statement.append(
565
+ SQL` OFFSET ${{
566
+ value: Number(aggregateBy.skip),
567
+ type: TableColumnType.Number,
568
+ }} `,
569
+ );
570
+ statement.append(
571
+ ` SETTINGS optimize_aggregation_in_order=1, optimize_move_to_prewhere=1, max_threads=4`,
572
+ );
573
+
574
+ logger.debug(`${this.model.tableName} Host MV Aggregate Statement`, {
575
+ tableName: this.model.tableName,
576
+ } as LogAttributes);
577
+ logger.debug(statement, {
578
+ tableName: this.model.tableName,
579
+ } as LogAttributes);
580
+
581
+ return {
582
+ statement,
583
+ columns: [
584
+ aggregateBy.aggregateColumnName.toString(),
585
+ aggregateBy.aggregationTimestampColumnName.toString(),
586
+ ],
587
+ };
588
+ }
589
+
590
+ private stripAttributesAndTimeFromQuery(query: unknown): typeof query {
591
+ if (!query || typeof query !== "object") {
592
+ return query;
593
+ }
594
+ const out: Record<string, unknown> = {};
595
+ for (const [k, v] of Object.entries(query as Record<string, unknown>)) {
596
+ if (k === "time" || k === "attributes") {
597
+ continue;
598
+ }
599
+ out[k] = v;
600
+ }
601
+ return out as typeof query;
602
+ }
603
+
404
604
  private stripTimeFromQuery(query: unknown): typeof query {
405
605
  if (!query || typeof query !== "object") {
406
606
  return query;
@@ -503,14 +503,19 @@ export class TraceAggregationService {
503
503
  for (const [attrKey, attrValue] of Object.entries(request.attributes)) {
504
504
  TraceAggregationService.validateFacetKey(attrKey);
505
505
 
506
+ /*
507
+ * Match attribute keys case-insensitively — see the matching note in
508
+ * LogAggregationService.appendCommonFilters. Casings vary across
509
+ * OTEL conventions and app-emitted attributes.
510
+ */
506
511
  statement.append(
507
- SQL` AND attributes[${{
512
+ SQL` AND arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
508
513
  type: TableColumnType.Text,
509
514
  value: attrKey,
510
- }}] = ${{
515
+ }}) AND v = ${{
511
516
  type: TableColumnType.Text,
512
517
  value: attrValue,
513
- }}`,
518
+ }}, mapKeys(attributes), mapValues(attributes))`,
514
519
  );
515
520
  }
516
521
  }
@@ -463,10 +463,35 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
463
463
  }
464
464
 
465
465
  /*
466
- * ClickHouse Map columns return the value type's default for
467
- * missing keys (empty string for String values), so to express
468
- * "is empty" we have to cover both the missing-key and the
469
- * empty-string case explicitly.
466
+ * Map filters split into two paths:
467
+ *
468
+ * 1. Programmatic equality / null / numeric comparisons
469
+ * EqualTo, NotEqual, IsNull, NotNull, GreaterThan, etc.,
470
+ * or bare string/number values. Callers are dashboard
471
+ * pages and services that pass canonical keys already
472
+ * matching the stored casing, so we use ClickHouse's
473
+ * direct Map subscript `attributes['k']`. That's an O(1)
474
+ * hash lookup per row and lets the query planner push the
475
+ * predicate into PREWHERE, instead of paying the
476
+ * `arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(...))`
477
+ * cost which materializes mapKeys/mapValues per row and
478
+ * lowercases every stored key on every query. Restoring
479
+ * this fast path is the single biggest performance fix
480
+ * for Host / Logs / Traces detail pages.
481
+ *
482
+ * 2. User-typed substring/wildcard operators — Search,
483
+ * StartsWith, EndsWith, NotContains. These come from the
484
+ * search bar where users shouldn't have to remember
485
+ * whether the attribute key is `requestId` or `requestid`,
486
+ * so we keep the case-insensitive `arrayExists` form. The
487
+ * cost is acceptable because a search-bar query is
488
+ * bounded (one user, one click) and these operators
489
+ * already imply a row scan.
490
+ *
491
+ * ClickHouse Map subscripts return the value type's default
492
+ * for missing keys (empty string for String values), which
493
+ * is what the IsNull / NotNull / NotEqual branches below
494
+ * mirror to preserve the previous semantics.
470
495
  */
471
496
  if (mapEntry instanceof IsNull) {
472
497
  whereStatement.append(
@@ -496,13 +521,13 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
496
521
 
497
522
  if (mapEntry instanceof Search) {
498
523
  whereStatement.append(
499
- SQL`AND ${key}[${{
524
+ SQL`AND arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
500
525
  value: mapKey,
501
526
  type: TableColumnType.Text,
502
- }}] ILIKE ${{
527
+ }}) AND v ILIKE ${{
503
528
  value: mapEntry as Search<string>,
504
529
  type: TableColumnType.Text,
505
- }}`,
530
+ }}, mapKeys(${key}), mapValues(${key}))`,
506
531
  );
507
532
  continue;
508
533
  }
@@ -510,13 +535,13 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
510
535
  if (mapEntry instanceof NotContains) {
511
536
  const literalValue: string = `%${(mapEntry.value as string) || ""}%`;
512
537
  whereStatement.append(
513
- SQL`AND ${key}[${{
538
+ SQL`AND NOT arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
514
539
  value: mapKey,
515
540
  type: TableColumnType.Text,
516
- }}] NOT ILIKE ${{
541
+ }}) AND v ILIKE ${{
517
542
  value: literalValue,
518
543
  type: TableColumnType.Text,
519
- }}`,
544
+ }}, mapKeys(${key}), mapValues(${key}))`,
520
545
  );
521
546
  continue;
522
547
  }
@@ -524,13 +549,13 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
524
549
  if (mapEntry instanceof StartsWith) {
525
550
  const literalValue: string = `${(mapEntry.value as string) || ""}%`;
526
551
  whereStatement.append(
527
- SQL`AND ${key}[${{
552
+ SQL`AND arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
528
553
  value: mapKey,
529
554
  type: TableColumnType.Text,
530
- }}] ILIKE ${{
555
+ }}) AND v ILIKE ${{
531
556
  value: literalValue,
532
557
  type: TableColumnType.Text,
533
- }}`,
558
+ }}, mapKeys(${key}), mapValues(${key}))`,
534
559
  );
535
560
  continue;
536
561
  }
@@ -538,13 +563,13 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
538
563
  if (mapEntry instanceof EndsWith) {
539
564
  const literalValue: string = `%${(mapEntry.value as string) || ""}`;
540
565
  whereStatement.append(
541
- SQL`AND ${key}[${{
566
+ SQL`AND arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(${{
542
567
  value: mapKey,
543
568
  type: TableColumnType.Text,
544
- }}] ILIKE ${{
569
+ }}) AND v ILIKE ${{
545
570
  value: literalValue,
546
571
  type: TableColumnType.Text,
547
- }}`,
572
+ }}, mapKeys(${key}), mapValues(${key}))`,
548
573
  );
549
574
  continue;
550
575
  }
@@ -577,7 +602,10 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
577
602
 
578
603
  /*
579
604
  * Map values are stored as text; cast to Float64 for numeric
580
- * comparisons and skip rows where the cast fails (non-numeric).
605
+ * comparisons. toFloat64OrNull yields NULL for non-numeric
606
+ * values (including the empty-string default for missing
607
+ * keys), which compares to false against any numeric
608
+ * threshold and naturally drops those rows.
581
609
  */
582
610
  if (mapEntry instanceof GreaterThan) {
583
611
  whereStatement.append(
@@ -631,7 +659,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
631
659
  continue;
632
660
  }
633
661
 
634
- // Bare string/number/boolean — back-compat with existing data.
662
+ // Bare string/number/boolean — direct Map subscript.
635
663
  whereStatement.append(
636
664
  SQL`AND ${key}[${{
637
665
  value: mapKey,
@@ -1,6 +1,7 @@
1
1
  import Alert from "../../../Models/DatabaseModels/Alert";
2
2
  import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
3
3
  import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
4
+ import Host from "../../../Models/DatabaseModels/Host";
4
5
  import Label from "../../../Models/DatabaseModels/Label";
5
6
  import Monitor from "../../../Models/DatabaseModels/Monitor";
6
7
  import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
@@ -17,6 +18,7 @@ import { DisableAutomaticAlertCreation } from "../../EnvironmentConfig";
17
18
  import AlertService from "../../Services/AlertService";
18
19
  import AlertSeverityService from "../../Services/AlertSeverityService";
19
20
  import AlertStateTimelineService from "../../Services/AlertStateTimelineService";
21
+ import HostService from "../../Services/HostService";
20
22
  import logger, { LogAttributes } from "../Logger";
21
23
  import CaptureSpan from "../Telemetry/CaptureSpan";
22
24
  import DataToProcess from "./DataToProcess";
@@ -268,6 +270,41 @@ export default class MonitorAlert {
268
270
  }
269
271
  if (seriesLabels && Object.keys(seriesLabels).length > 0) {
270
272
  alert.seriesLabels = seriesLabels;
273
+
274
+ /*
275
+ * Link the alert to the Host that emitted this series, if
276
+ * the metric carried a `host.name` resource attribute. The
277
+ * Host record was auto-discovered during OTel ingestion.
278
+ * Metric attributes are stored with the `resource.` prefix in
279
+ * ClickHouse, so the group-by dropdown surfaces
280
+ * `resource.host.name` — but accept the bare `host.name` too
281
+ * for any non-OTel ingest paths.
282
+ */
283
+ const hostName: string | undefined =
284
+ typeof seriesLabels["resource.host.name"] === "string"
285
+ ? (seriesLabels["resource.host.name"] as string)
286
+ : typeof seriesLabels["host.name"] === "string"
287
+ ? (seriesLabels["host.name"] as string)
288
+ : undefined;
289
+
290
+ if (hostName) {
291
+ const host: Host | null = await HostService.findOneBy({
292
+ query: {
293
+ projectId: input.monitor.projectId!,
294
+ hostIdentifier: hostName,
295
+ },
296
+ select: {
297
+ _id: true,
298
+ },
299
+ props: {
300
+ isRoot: true,
301
+ },
302
+ });
303
+
304
+ if (host) {
305
+ alert.hosts = [host];
306
+ }
307
+ }
271
308
  }
272
309
 
273
310
  alert.onCallDutyPolicies =