@oneuptime/common 10.0.96 → 10.0.97

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 (128) 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/HostOwnerTeamService.ts +10 -0
  19. package/Server/Services/HostOwnerUserService.ts +10 -0
  20. package/Server/Services/HostService.ts +227 -0
  21. package/Server/Services/LogAggregationService.ts +10 -3
  22. package/Server/Services/MetricService.ts +200 -0
  23. package/Server/Services/TraceAggregationService.ts +8 -3
  24. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +46 -18
  25. package/Server/Utils/Monitor/MonitorAlert.ts +31 -0
  26. package/Server/Utils/Monitor/MonitorIncident.ts +31 -0
  27. package/Tests/Server/Services/LogAggregationService.test.ts +25 -0
  28. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +145 -0
  29. package/Types/Metrics/MetricQueryConfigData.ts +9 -0
  30. package/Types/Permission.ts +134 -0
  31. package/UI/Components/Charts/Area/AreaChart.tsx +1 -1
  32. package/UI/Components/Charts/Bar/BarChart.tsx +1 -1
  33. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +15 -8
  34. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +12 -9
  35. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +17 -10
  36. package/UI/Components/Charts/Line/LineChart.tsx +1 -1
  37. package/UI/Components/ExpandableText/ExpandableText.tsx +29 -7
  38. package/UI/Components/JSONTable/JSONTable.tsx +27 -1
  39. package/UI/Components/LogsViewer/LogsViewer.tsx +3 -0
  40. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +109 -23
  41. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +11 -4
  42. package/UI/Components/Navbar/NavBarMenu.tsx +17 -2
  43. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +10 -3
  44. package/Utils/ValueFormatter.ts +57 -3
  45. package/build/dist/Models/AnalyticsModels/Log.js +6 -0
  46. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  47. package/build/dist/Models/AnalyticsModels/Metric.js +6 -0
  48. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  49. package/build/dist/Models/AnalyticsModels/Profile.js +6 -0
  50. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  51. package/build/dist/Models/AnalyticsModels/Span.js +6 -0
  52. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  53. package/build/dist/Models/DatabaseModels/Alert.js +51 -0
  54. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  55. package/build/dist/Models/DatabaseModels/DockerHost.js +3 -10
  56. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  57. package/build/dist/Models/DatabaseModels/Host.js +1041 -0
  58. package/build/dist/Models/DatabaseModels/Host.js.map +1 -0
  59. package/build/dist/Models/DatabaseModels/HostOwnerTeam.js +480 -0
  60. package/build/dist/Models/DatabaseModels/HostOwnerTeam.js.map +1 -0
  61. package/build/dist/Models/DatabaseModels/HostOwnerUser.js +479 -0
  62. package/build/dist/Models/DatabaseModels/HostOwnerUser.js.map +1 -0
  63. package/build/dist/Models/DatabaseModels/Incident.js +51 -0
  64. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  65. package/build/dist/Models/DatabaseModels/Index.js +6 -0
  66. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  67. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +0 -7
  68. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778006035712-AddHostTables.js +76 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778006035712-AddHostTables.js.map +1 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778013317872-AddHostIpAddresses.js +12 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778013317872-AddHostIpAddresses.js.map +1 -0
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778066346303-WidenHostOsVersionToLongText.js +31 -0
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778066346303-WidenHostOsVersionToLongText.js.map +1 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778070278986-MigrationName.js +34 -0
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778070278986-MigrationName.js.map +1 -0
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  79. package/build/dist/Server/Services/HostOwnerTeamService.js +9 -0
  80. package/build/dist/Server/Services/HostOwnerTeamService.js.map +1 -0
  81. package/build/dist/Server/Services/HostOwnerUserService.js +9 -0
  82. package/build/dist/Server/Services/HostOwnerUserService.js.map +1 -0
  83. package/build/dist/Server/Services/HostService.js +206 -0
  84. package/build/dist/Server/Services/HostService.js.map +1 -0
  85. package/build/dist/Server/Services/LogAggregationService.js +10 -3
  86. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  87. package/build/dist/Server/Services/MetricService.js +160 -0
  88. package/build/dist/Server/Services/MetricService.js.map +1 -1
  89. package/build/dist/Server/Services/TraceAggregationService.js +8 -3
  90. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  91. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +46 -18
  92. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  93. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +26 -0
  94. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  95. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +26 -0
  96. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  97. package/build/dist/Tests/Server/Services/LogAggregationService.test.js +13 -0
  98. package/build/dist/Tests/Server/Services/LogAggregationService.test.js.map +1 -1
  99. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js +123 -0
  100. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js.map +1 -1
  101. package/build/dist/Types/Permission.js +120 -0
  102. package/build/dist/Types/Permission.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  104. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +13 -7
  106. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +11 -9
  108. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +16 -10
  110. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  112. package/build/dist/UI/Components/ExpandableText/ExpandableText.js +10 -5
  113. package/build/dist/UI/Components/ExpandableText/ExpandableText.js.map +1 -1
  114. package/build/dist/UI/Components/JSONTable/JSONTable.js +8 -1
  115. package/build/dist/UI/Components/JSONTable/JSONTable.js.map +1 -1
  116. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +1 -1
  117. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  118. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +40 -14
  119. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  120. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +10 -4
  121. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  122. package/build/dist/UI/Components/Navbar/NavBarMenu.js +15 -2
  123. package/build/dist/UI/Components/Navbar/NavBarMenu.js.map +1 -1
  124. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +10 -3
  125. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  126. package/build/dist/Utils/ValueFormatter.js +47 -3
  127. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  128. package/package.json +1 -1
@@ -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,35 @@ 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
+ */
279
+ const hostName: string | undefined =
280
+ typeof seriesLabels["host.name"] === "string"
281
+ ? (seriesLabels["host.name"] as string)
282
+ : undefined;
283
+
284
+ if (hostName) {
285
+ const host: Host | null = await HostService.findOneBy({
286
+ query: {
287
+ projectId: input.monitor.projectId!,
288
+ hostIdentifier: hostName,
289
+ },
290
+ select: {
291
+ _id: true,
292
+ },
293
+ props: {
294
+ isRoot: true,
295
+ },
296
+ });
297
+
298
+ if (host) {
299
+ alert.hosts = [host];
300
+ }
301
+ }
271
302
  }
272
303
 
273
304
  alert.onCallDutyPolicies =
@@ -1,3 +1,4 @@
1
+ import Host from "../../../Models/DatabaseModels/Host";
1
2
  import Incident from "../../../Models/DatabaseModels/Incident";
2
3
  import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
3
4
  import IncidentStateTimeline from "../../../Models/DatabaseModels/IncidentStateTimeline";
@@ -15,6 +16,7 @@ import ObjectID from "../../../Types/ObjectID";
15
16
  import ProbeMonitorResponse from "../../../Types/Probe/ProbeMonitorResponse";
16
17
  import { TelemetryQuery } from "../../../Types/Telemetry/TelemetryQuery";
17
18
  import { DisableAutomaticIncidentCreation } from "../../EnvironmentConfig";
19
+ import HostService from "../../Services/HostService";
18
20
  import IncidentService from "../../Services/IncidentService";
19
21
  import IncidentSeverityService from "../../Services/IncidentSeverityService";
20
22
  import IncidentStateTimelineService from "../../Services/IncidentStateTimelineService";
@@ -313,6 +315,35 @@ export default class MonitorIncident {
313
315
  }
314
316
  if (seriesLabels && Object.keys(seriesLabels).length > 0) {
315
317
  incident.seriesLabels = seriesLabels;
318
+
319
+ /*
320
+ * Link the incident to the Host that emitted this series, if
321
+ * the metric carried a `host.name` resource attribute. The
322
+ * Host record was auto-discovered during OTel ingestion.
323
+ */
324
+ const hostName: string | undefined =
325
+ typeof seriesLabels["host.name"] === "string"
326
+ ? (seriesLabels["host.name"] as string)
327
+ : undefined;
328
+
329
+ if (hostName) {
330
+ const host: Host | null = await HostService.findOneBy({
331
+ query: {
332
+ projectId: input.monitor.projectId!,
333
+ hostIdentifier: hostName,
334
+ },
335
+ select: {
336
+ _id: true,
337
+ },
338
+ props: {
339
+ isRoot: true,
340
+ },
341
+ });
342
+
343
+ if (host) {
344
+ incident.hosts = [host];
345
+ }
346
+ }
316
347
  }
317
348
 
318
349
  incident.onCallDutyPolicies =
@@ -80,4 +80,29 @@ describe("LogAggregationService", () => {
80
80
  });
81
81
  }).toThrow("Invalid facetKey");
82
82
  });
83
+
84
+ test("histogram attribute filter matches attribute keys case-insensitively", () => {
85
+ /*
86
+ * Users typing `requestid` should still match data stored with the key
87
+ * `requestId` (camelCase). The histogram filter shares the same WHERE
88
+ * clause builder (`appendCommonFilters`) with the list/facet queries, so
89
+ * verifying it on histogram covers all three.
90
+ */
91
+ const statement: Statement = (
92
+ LogAggregationService as any
93
+ ).buildHistogramStatement({
94
+ ...defaultRequest,
95
+ facetKey: undefined,
96
+ attributes: { requestid: "uuid-123" },
97
+ });
98
+
99
+ expect(statement.query).toContain(
100
+ "arrayExists((k, v) -> lowerUTF8(k) = lowerUTF8(",
101
+ );
102
+ expect(statement.query).toContain(
103
+ ", mapKeys(attributes), mapValues(attributes))",
104
+ );
105
+ expect(Object.values(statement.query_params)).toContain("requestid");
106
+ expect(Object.values(statement.query_params)).toContain("uuid-123");
107
+ });
83
108
  });
@@ -13,6 +13,13 @@ import AnalyticsTableEngine from "../../../../Types/AnalyticsDatabase/AnalyticsT
13
13
  import AnalyticsTableColumn from "../../../../Types/AnalyticsDatabase/TableColumn";
14
14
  import TableColumnType from "../../../../Types/AnalyticsDatabase/TableColumnType";
15
15
  import OneUptimeDate from "../../../../Types/Date";
16
+ import EqualTo from "../../../../Types/BaseDatabase/EqualTo";
17
+ import NotEqual from "../../../../Types/BaseDatabase/NotEqual";
18
+ import IsNull from "../../../../Types/BaseDatabase/IsNull";
19
+ import NotNull from "../../../../Types/BaseDatabase/NotNull";
20
+ import GreaterThan from "../../../../Types/BaseDatabase/GreaterThan";
21
+ import Search from "../../../../Types/BaseDatabase/Search";
22
+ import StartsWith from "../../../../Types/BaseDatabase/StartsWith";
16
23
 
17
24
  function expectStatement(actual: Statement, expected: Statement): void {
18
25
  expect(actual.query).toBe(expected.query);
@@ -179,6 +186,144 @@ describe("StatementGenerator", () => {
179
186
  p3: OneUptimeDate.toClickhouseDateTime(date),
180
187
  });
181
188
  });
189
+
190
+ describe("MapStringString columns", () => {
191
+ class MapModel extends AnalyticsBaseModel {
192
+ public constructor() {
193
+ super({
194
+ tableName: "<map-table>",
195
+ singularName: "<singular>",
196
+ pluralName: "<plural>",
197
+ tableColumns: [
198
+ new AnalyticsTableColumn({
199
+ key: "_id",
200
+ title: "<title>",
201
+ description: "<description>",
202
+ required: true,
203
+ type: TableColumnType.ObjectID,
204
+ }),
205
+ new AnalyticsTableColumn({
206
+ key: "attributes",
207
+ title: "<title>",
208
+ description: "<description>",
209
+ required: true,
210
+ defaultValue: {},
211
+ type: TableColumnType.MapStringString,
212
+ }),
213
+ ],
214
+ crudApiPath: new Route("route"),
215
+ primaryKeys: ["_id"],
216
+ sortKeys: ["_id"],
217
+ partitionKey: "_id",
218
+ tableEngine: AnalyticsTableEngine.MergeTree,
219
+ });
220
+ }
221
+ }
222
+
223
+ let mapGenerator: StatementGenerator<MapModel>;
224
+ beforeEach(() => {
225
+ mapGenerator = new StatementGenerator<MapModel>({
226
+ modelType: MapModel,
227
+ database: ClickhouseAppInstance,
228
+ });
229
+ });
230
+
231
+ test("uses direct map subscript for bare-value equality", () => {
232
+ const statement: Statement = mapGenerator.toWhereStatement({
233
+ attributes: { requestId: "uuid-123" },
234
+ } as any);
235
+ /*
236
+ * Programmatic callers pass canonical keys, so bare-value
237
+ * equality compiles to `attributes['k'] = v` — an O(1) Map
238
+ * subscript that the planner can push into PREWHERE. The
239
+ * slower case-insensitive arrayExists form is reserved for
240
+ * the user-typed Search/StartsWith/EndsWith/NotContains
241
+ * operators below.
242
+ */
243
+ expect(statement.query).toBe(
244
+ "AND {p0:Identifier}[{p1:String}] = {p2:String}",
245
+ );
246
+ expect(statement.query_params).toStrictEqual({
247
+ p0: "attributes",
248
+ p1: "requestId",
249
+ p2: "uuid-123",
250
+ });
251
+ });
252
+
253
+ test("uses direct map subscript for EqualTo wrapper", () => {
254
+ const statement: Statement = mapGenerator.toWhereStatement({
255
+ attributes: { requestId: new EqualTo("uuid-123") },
256
+ } as any);
257
+ expect(statement.query).toBe(
258
+ "AND {p0:Identifier}[{p1:String}] = {p2:String}",
259
+ );
260
+ expect(statement.query_params).toStrictEqual({
261
+ p0: "attributes",
262
+ p1: "requestId",
263
+ p2: "uuid-123",
264
+ });
265
+ });
266
+
267
+ test("uses direct map subscript for NotEqual wrapper", () => {
268
+ const statement: Statement = mapGenerator.toWhereStatement({
269
+ attributes: { requestId: new NotEqual("uuid-123") },
270
+ } as any);
271
+ expect(statement.query).toBe(
272
+ "AND {p0:Identifier}[{p1:String}] != {p2:String}",
273
+ );
274
+ });
275
+
276
+ test("uses mapContains+subscript for IsNull wrapper", () => {
277
+ const statement: Statement = mapGenerator.toWhereStatement({
278
+ attributes: { requestId: new IsNull() },
279
+ } as any);
280
+ expect(statement.query).toBe(
281
+ "AND ((NOT mapContains({p0:Identifier}, {p1:String})) OR {p2:Identifier}[{p3:String}] = '')",
282
+ );
283
+ });
284
+
285
+ test("uses mapContains+subscript for NotNull wrapper", () => {
286
+ const statement: Statement = mapGenerator.toWhereStatement({
287
+ attributes: { requestId: new NotNull() },
288
+ } as any);
289
+ expect(statement.query).toBe(
290
+ "AND mapContains({p0:Identifier}, {p1:String}) AND {p2:Identifier}[{p3:String}] != ''",
291
+ );
292
+ });
293
+
294
+ test("uses direct map subscript for numeric GreaterThan wrapper", () => {
295
+ const statement: Statement = mapGenerator.toWhereStatement({
296
+ attributes: { httpStatus: new GreaterThan(500) },
297
+ } as any);
298
+ expect(statement.query).toBe(
299
+ "AND toFloat64OrNull({p0:Identifier}[{p1:String}]) > {p2:Int32}",
300
+ );
301
+ });
302
+
303
+ test("keeps case-insensitive arrayExists for Search wrapper", () => {
304
+ const statement: Statement = mapGenerator.toWhereStatement({
305
+ attributes: { requestId: new Search("uuid") },
306
+ } as any);
307
+ /*
308
+ * Search comes from the user-typed search bar — keep the
309
+ * case-insensitive ILIKE form so the user doesn't have to
310
+ * remember whether the stored key is `requestId` or
311
+ * `requestid`.
312
+ */
313
+ expect(statement.query).toContain("arrayExists");
314
+ expect(statement.query).toContain("lowerUTF8");
315
+ expect(statement.query).toContain("ILIKE");
316
+ });
317
+
318
+ test("keeps case-insensitive arrayExists for StartsWith wrapper", () => {
319
+ const statement: Statement = mapGenerator.toWhereStatement({
320
+ attributes: { requestId: new StartsWith("uuid") },
321
+ } as any);
322
+ expect(statement.query).toContain("arrayExists");
323
+ expect(statement.query).toContain("lowerUTF8");
324
+ expect(statement.query).toContain("ILIKE");
325
+ });
326
+ });
182
327
  });
183
328
 
184
329
  describe("toSelectStatement", () => {
@@ -20,4 +20,13 @@ export default interface MetricQueryConfigData {
20
20
  yAxisValueFormatter?: ((value: number) => string) | undefined;
21
21
  warningThreshold?: number | undefined;
22
22
  criticalThreshold?: number | undefined;
23
+ /*
24
+ * When true, the post-aggregation series points are transformed into
25
+ * a per-second rate of change: `(value - previousValue) / Δt`. This is
26
+ * the right view for OTel cumulative counters (e.g. `system.disk.io`,
27
+ * `system.network.io`) — without it, the chart plots monotonically
28
+ * growing bytes-since-process-start. Negative deltas (counter resets
29
+ * on agent restart) are clamped to 0.
30
+ */
31
+ transformAsRate?: boolean | undefined;
23
32
  }