@oneuptime/common 10.0.80 → 10.0.83

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 (66) hide show
  1. package/Models/AnalyticsModels/Metric.ts +296 -2
  2. package/Server/Services/MetricService.ts +228 -3
  3. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +43 -3
  4. package/Server/Utils/Express.ts +5 -0
  5. package/Types/AnalyticsDatabase/TableColumnType.ts +1 -0
  6. package/Types/BaseDatabase/AggregationType.ts +35 -0
  7. package/Types/Monitor/IncomingMonitor/IncomingMonitorRequest.ts +1 -0
  8. package/UI/Components/Banner/Banner.tsx +7 -2
  9. package/UI/Components/Breadcrumbs/Breadcrumbs.tsx +6 -2
  10. package/UI/Components/Button/Button.tsx +10 -4
  11. package/UI/Components/Card/Card.tsx +11 -4
  12. package/UI/Components/EmptyState/EmptyState.tsx +6 -2
  13. package/UI/Components/EventItem/EventItem.tsx +9 -5
  14. package/UI/Components/Modal/ConfirmModal.tsx +5 -1
  15. package/UI/Components/Modal/Modal.tsx +21 -5
  16. package/UI/Components/ModelTable/BaseModelTable.tsx +7 -1
  17. package/UI/Components/Navbar/NavBar.tsx +6 -3
  18. package/UI/Components/Page/Page.tsx +6 -2
  19. package/UI/Components/SideMenu/SideMenu.tsx +18 -6
  20. package/UI/Components/SideMenu/SideMenuItem.tsx +9 -2
  21. package/UI/Components/SideMenu/SideMenuSection.tsx +4 -1
  22. package/UI/Utils/Translation.tsx +53 -0
  23. package/UI/esbuild-config.js +8 -0
  24. package/build/dist/Models/AnalyticsModels/Metric.js +250 -2
  25. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  26. package/build/dist/Server/Services/MetricService.js +183 -2
  27. package/build/dist/Server/Services/MetricService.js.map +1 -1
  28. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +36 -2
  29. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  30. package/build/dist/Server/Utils/Express.js +3 -0
  31. package/build/dist/Server/Utils/Express.js.map +1 -1
  32. package/build/dist/Types/AnalyticsDatabase/TableColumnType.js +1 -0
  33. package/build/dist/Types/AnalyticsDatabase/TableColumnType.js.map +1 -1
  34. package/build/dist/Types/BaseDatabase/AggregationType.js +30 -0
  35. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  36. package/build/dist/UI/Components/Banner/Banner.js +6 -2
  37. package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
  38. package/build/dist/UI/Components/Breadcrumbs/Breadcrumbs.js +5 -2
  39. package/build/dist/UI/Components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  40. package/build/dist/UI/Components/Button/Button.js +10 -4
  41. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  42. package/build/dist/UI/Components/Card/Card.js +6 -2
  43. package/build/dist/UI/Components/Card/Card.js.map +1 -1
  44. package/build/dist/UI/Components/EmptyState/EmptyState.js +4 -2
  45. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  46. package/build/dist/UI/Components/EventItem/EventItem.js +9 -7
  47. package/build/dist/UI/Components/EventItem/EventItem.js.map +1 -1
  48. package/build/dist/UI/Components/Modal/ConfirmModal.js +4 -1
  49. package/build/dist/UI/Components/Modal/ConfirmModal.js.map +1 -1
  50. package/build/dist/UI/Components/Modal/Modal.js +13 -3
  51. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  52. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +7 -1
  53. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  54. package/build/dist/UI/Components/Navbar/NavBar.js +6 -3
  55. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  56. package/build/dist/UI/Components/Page/Page.js +5 -2
  57. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  58. package/build/dist/UI/Components/SideMenu/SideMenu.js +12 -6
  59. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  60. package/build/dist/UI/Components/SideMenu/SideMenuItem.js +8 -2
  61. package/build/dist/UI/Components/SideMenu/SideMenuItem.js.map +1 -1
  62. package/build/dist/UI/Components/SideMenu/SideMenuSection.js +4 -1
  63. package/build/dist/UI/Components/SideMenu/SideMenuSection.js.map +1 -1
  64. package/build/dist/UI/Utils/Translation.js +36 -0
  65. package/build/dist/UI/Utils/Translation.js.map +1 -0
  66. package/package.json +3 -1
@@ -20,6 +20,7 @@ export enum MetricPointType {
20
20
  Gauge = "Gauge",
21
21
  Histogram = "Histogram",
22
22
  ExponentialHistogram = "ExponentialHistogram",
23
+ Summary = "Summary",
23
24
  }
24
25
 
25
26
  export enum ServiceType {
@@ -501,8 +502,116 @@ export default class Metric extends AnalyticsBaseModel {
501
502
  const explicitBoundsColumn: AnalyticsTableColumn = new AnalyticsTableColumn(
502
503
  {
503
504
  key: "explicitBounds",
504
- title: "Explicit Bonds",
505
- description: "Explicit Bonds",
505
+ title: "Explicit Bounds",
506
+ description:
507
+ "Upper bounds (exclusive of the +inf overflow bucket) for each explicit-bucket histogram bucket. Stored as Float64 so sub-integer boundaries (e.g. 0.005, 0.01) survive ingest — the previous Array(Int64) representation silently truncated those to 0.",
508
+ required: true,
509
+ defaultValue: [],
510
+ type: TableColumnType.ArrayDecimal,
511
+ accessControl: {
512
+ read: [
513
+ Permission.ProjectOwner,
514
+ Permission.ProjectAdmin,
515
+ Permission.ProjectMember,
516
+ Permission.ReadTelemetryServiceLog,
517
+ ],
518
+ create: [
519
+ Permission.ProjectOwner,
520
+ Permission.ProjectAdmin,
521
+ Permission.ProjectMember,
522
+ Permission.CreateTelemetryServiceLog,
523
+ ],
524
+ update: [],
525
+ },
526
+ },
527
+ );
528
+
529
+ /*
530
+ * --- ExponentialHistogram-only columns ----------------------------------
531
+ * These are populated only when metricPointType = ExponentialHistogram.
532
+ * For other metric types they are left at their defaults (0 / []).
533
+ */
534
+
535
+ const scaleColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
536
+ key: "scale",
537
+ title: "Scale",
538
+ description:
539
+ "ExponentialHistogram resolution. base = 2^(2^-scale); bucket index `i` covers (base^i, base^(i+1)].",
540
+ required: false,
541
+ type: TableColumnType.Number,
542
+ accessControl: {
543
+ read: [
544
+ Permission.ProjectOwner,
545
+ Permission.ProjectAdmin,
546
+ Permission.ProjectMember,
547
+ Permission.ReadTelemetryServiceLog,
548
+ ],
549
+ create: [
550
+ Permission.ProjectOwner,
551
+ Permission.ProjectAdmin,
552
+ Permission.ProjectMember,
553
+ Permission.CreateTelemetryServiceLog,
554
+ ],
555
+ update: [],
556
+ },
557
+ });
558
+
559
+ const zeroCountColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
560
+ key: "zeroCount",
561
+ title: "Zero Count",
562
+ description:
563
+ "ExponentialHistogram count of values within the zero region (|v| <= zeroThreshold).",
564
+ required: false,
565
+ type: TableColumnType.BigNumber,
566
+ accessControl: {
567
+ read: [
568
+ Permission.ProjectOwner,
569
+ Permission.ProjectAdmin,
570
+ Permission.ProjectMember,
571
+ Permission.ReadTelemetryServiceLog,
572
+ ],
573
+ create: [
574
+ Permission.ProjectOwner,
575
+ Permission.ProjectAdmin,
576
+ Permission.ProjectMember,
577
+ Permission.CreateTelemetryServiceLog,
578
+ ],
579
+ update: [],
580
+ },
581
+ });
582
+
583
+ const positiveOffsetColumn: AnalyticsTableColumn = new AnalyticsTableColumn(
584
+ {
585
+ key: "positiveOffset",
586
+ title: "Positive Bucket Offset",
587
+ description:
588
+ "Bucket index of the first entry in positiveBucketCounts (ExponentialHistogram).",
589
+ required: false,
590
+ type: TableColumnType.Number,
591
+ accessControl: {
592
+ read: [
593
+ Permission.ProjectOwner,
594
+ Permission.ProjectAdmin,
595
+ Permission.ProjectMember,
596
+ Permission.ReadTelemetryServiceLog,
597
+ ],
598
+ create: [
599
+ Permission.ProjectOwner,
600
+ Permission.ProjectAdmin,
601
+ Permission.ProjectMember,
602
+ Permission.CreateTelemetryServiceLog,
603
+ ],
604
+ update: [],
605
+ },
606
+ },
607
+ );
608
+
609
+ const positiveBucketCountsColumn: AnalyticsTableColumn =
610
+ new AnalyticsTableColumn({
611
+ key: "positiveBucketCounts",
612
+ title: "Positive Bucket Counts",
613
+ description:
614
+ "Counts for the positive range of an ExponentialHistogram, indexed from positiveOffset.",
506
615
  required: true,
507
616
  defaultValue: [],
508
617
  type: TableColumnType.ArrayBigNumber,
@@ -521,9 +630,118 @@ export default class Metric extends AnalyticsBaseModel {
521
630
  ],
522
631
  update: [],
523
632
  },
633
+ });
634
+
635
+ const negativeOffsetColumn: AnalyticsTableColumn = new AnalyticsTableColumn(
636
+ {
637
+ key: "negativeOffset",
638
+ title: "Negative Bucket Offset",
639
+ description:
640
+ "Bucket index of the first entry in negativeBucketCounts (ExponentialHistogram).",
641
+ required: false,
642
+ type: TableColumnType.Number,
643
+ accessControl: {
644
+ read: [
645
+ Permission.ProjectOwner,
646
+ Permission.ProjectAdmin,
647
+ Permission.ProjectMember,
648
+ Permission.ReadTelemetryServiceLog,
649
+ ],
650
+ create: [
651
+ Permission.ProjectOwner,
652
+ Permission.ProjectAdmin,
653
+ Permission.ProjectMember,
654
+ Permission.CreateTelemetryServiceLog,
655
+ ],
656
+ update: [],
657
+ },
524
658
  },
525
659
  );
526
660
 
661
+ const negativeBucketCountsColumn: AnalyticsTableColumn =
662
+ new AnalyticsTableColumn({
663
+ key: "negativeBucketCounts",
664
+ title: "Negative Bucket Counts",
665
+ description:
666
+ "Counts for the negative range of an ExponentialHistogram, indexed from negativeOffset.",
667
+ required: true,
668
+ defaultValue: [],
669
+ type: TableColumnType.ArrayBigNumber,
670
+ accessControl: {
671
+ read: [
672
+ Permission.ProjectOwner,
673
+ Permission.ProjectAdmin,
674
+ Permission.ProjectMember,
675
+ Permission.ReadTelemetryServiceLog,
676
+ ],
677
+ create: [
678
+ Permission.ProjectOwner,
679
+ Permission.ProjectAdmin,
680
+ Permission.ProjectMember,
681
+ Permission.CreateTelemetryServiceLog,
682
+ ],
683
+ update: [],
684
+ },
685
+ });
686
+
687
+ /*
688
+ * --- Summary-only columns -----------------------------------------------
689
+ * Populated only when metricPointType = Summary. Two parallel arrays
690
+ * keyed by index (mirrors the bucketCounts/explicitBounds convention):
691
+ * summaryQuantiles[i] in [0,1], summaryValues[i] is value at that quantile.
692
+ */
693
+
694
+ const summaryQuantilesColumn: AnalyticsTableColumn =
695
+ new AnalyticsTableColumn({
696
+ key: "summaryQuantiles",
697
+ title: "Summary Quantiles",
698
+ description:
699
+ "Quantile percentages in [0,1] for a Summary metric (parallel to summaryValues).",
700
+ required: true,
701
+ defaultValue: [],
702
+ type: TableColumnType.ArrayDecimal,
703
+ accessControl: {
704
+ read: [
705
+ Permission.ProjectOwner,
706
+ Permission.ProjectAdmin,
707
+ Permission.ProjectMember,
708
+ Permission.ReadTelemetryServiceLog,
709
+ ],
710
+ create: [
711
+ Permission.ProjectOwner,
712
+ Permission.ProjectAdmin,
713
+ Permission.ProjectMember,
714
+ Permission.CreateTelemetryServiceLog,
715
+ ],
716
+ update: [],
717
+ },
718
+ });
719
+
720
+ const summaryValuesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
721
+ key: "summaryValues",
722
+ title: "Summary Values",
723
+ description:
724
+ "Values corresponding to each quantile in summaryQuantiles for a Summary metric.",
725
+ required: true,
726
+ defaultValue: [],
727
+ type: TableColumnType.ArrayDecimal,
728
+ accessControl: {
729
+ read: [
730
+ Permission.ProjectOwner,
731
+ Permission.ProjectAdmin,
732
+ Permission.ProjectMember,
733
+ Permission.ReadTelemetryServiceLog,
734
+ ],
735
+ create: [
736
+ Permission.ProjectOwner,
737
+ Permission.ProjectAdmin,
738
+ Permission.ProjectMember,
739
+ Permission.CreateTelemetryServiceLog,
740
+ ],
741
+ update: [],
742
+ },
743
+ });
744
+
527
745
  const traceIdColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
528
746
  key: "traceId",
529
747
  title: "Trace ID",
@@ -647,6 +865,14 @@ export default class Metric extends AnalyticsBaseModel {
647
865
  maxColumn,
648
866
  bucketCountsColumn,
649
867
  explicitBoundsColumn,
868
+ scaleColumn,
869
+ zeroCountColumn,
870
+ positiveOffsetColumn,
871
+ positiveBucketCountsColumn,
872
+ negativeOffsetColumn,
873
+ negativeBucketCountsColumn,
874
+ summaryQuantilesColumn,
875
+ summaryValuesColumn,
650
876
  traceIdColumn,
651
877
  spanIdColumn,
652
878
  retentionDateColumn,
@@ -846,4 +1072,72 @@ export default class Metric extends AnalyticsBaseModel {
846
1072
  public set retentionDate(v: Date | undefined) {
847
1073
  this.setColumnValue("retentionDate", v);
848
1074
  }
1075
+
1076
+ public get scale(): number | undefined {
1077
+ return this.getColumnValue("scale") as number | undefined;
1078
+ }
1079
+
1080
+ public set scale(v: number | undefined) {
1081
+ this.setColumnValue("scale", v);
1082
+ }
1083
+
1084
+ public get zeroCount(): number | undefined {
1085
+ return this.getColumnValue("zeroCount") as number | undefined;
1086
+ }
1087
+
1088
+ public set zeroCount(v: number | undefined) {
1089
+ this.setColumnValue("zeroCount", v);
1090
+ }
1091
+
1092
+ public get positiveOffset(): number | undefined {
1093
+ return this.getColumnValue("positiveOffset") as number | undefined;
1094
+ }
1095
+
1096
+ public set positiveOffset(v: number | undefined) {
1097
+ this.setColumnValue("positiveOffset", v);
1098
+ }
1099
+
1100
+ public get positiveBucketCounts(): Array<number> | undefined {
1101
+ return this.getColumnValue("positiveBucketCounts") as
1102
+ | Array<number>
1103
+ | undefined;
1104
+ }
1105
+
1106
+ public set positiveBucketCounts(v: Array<number> | undefined) {
1107
+ this.setColumnValue("positiveBucketCounts", v);
1108
+ }
1109
+
1110
+ public get negativeOffset(): number | undefined {
1111
+ return this.getColumnValue("negativeOffset") as number | undefined;
1112
+ }
1113
+
1114
+ public set negativeOffset(v: number | undefined) {
1115
+ this.setColumnValue("negativeOffset", v);
1116
+ }
1117
+
1118
+ public get negativeBucketCounts(): Array<number> | undefined {
1119
+ return this.getColumnValue("negativeBucketCounts") as
1120
+ | Array<number>
1121
+ | undefined;
1122
+ }
1123
+
1124
+ public set negativeBucketCounts(v: Array<number> | undefined) {
1125
+ this.setColumnValue("negativeBucketCounts", v);
1126
+ }
1127
+
1128
+ public get summaryQuantiles(): Array<number> | undefined {
1129
+ return this.getColumnValue("summaryQuantiles") as Array<number> | undefined;
1130
+ }
1131
+
1132
+ public set summaryQuantiles(v: Array<number> | undefined) {
1133
+ this.setColumnValue("summaryQuantiles", v);
1134
+ }
1135
+
1136
+ public get summaryValues(): Array<number> | undefined {
1137
+ return this.getColumnValue("summaryValues") as Array<number> | undefined;
1138
+ }
1139
+
1140
+ public set summaryValues(v: Array<number> | undefined) {
1141
+ this.setColumnValue("summaryValues", v);
1142
+ }
849
1143
  }
@@ -1,10 +1,235 @@
1
1
  import ClickhouseDatabase from "../Infrastructure/ClickhouseDatabase";
2
2
  import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
3
- import MetricSum from "../../Models/AnalyticsModels/Metric";
3
+ import Metric from "../../Models/AnalyticsModels/Metric";
4
+ import AggregateBy, {
5
+ AggregateUtil,
6
+ } from "../Types/AnalyticsDatabase/AggregateBy";
7
+ import { SQL, Statement } from "../Utils/AnalyticsDatabase/Statement";
8
+ import {
9
+ getPercentileLevel,
10
+ isPercentileAggregation,
11
+ } from "../../Types/BaseDatabase/AggregationType";
12
+ import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
13
+ import logger, { LogAttributes } from "../Utils/Logger";
4
14
 
5
- export class MetricService extends AnalyticsDatabaseService<MetricSum> {
15
+ export class MetricService extends AnalyticsDatabaseService<Metric> {
6
16
  public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
7
- super({ modelType: MetricSum, database: clickhouseDatabase });
17
+ super({ modelType: Metric, database: clickhouseDatabase });
18
+ }
19
+
20
+ /**
21
+ * Histogram-aware aggregation override.
22
+ *
23
+ * For non-percentile aggregations (Sum/Avg/Min/Max/Count) we delegate
24
+ * to the base implementation. For percentile aggregations
25
+ * (P50/P90/P95/P99) we build a subquery that fans each metric row out
26
+ * into one or more `(midpoint, weight)` samples — derived from
27
+ * histogram buckets when present — and then runs
28
+ * `quantileExactWeighted` over the fanned-out distribution. This means
29
+ * a P95 of `http.server.request.duration` returns a real
30
+ * bucket-derived 95th percentile of observed values, not the 95th
31
+ * percentile of per-row `sum`s.
32
+ *
33
+ * Per `metricPointType` the fanout is:
34
+ *
35
+ * Histogram -> one (midpoint, count) per explicit bucket;
36
+ * midpoint = (lower + upper) / 2, with the
37
+ * implicit -inf/+inf buckets approximated
38
+ * against the nearest bound.
39
+ * ExponentialHistogram -> one (geomean, count) per positive bucket;
40
+ * base = 2^(2^-scale); bucket index `k` is
41
+ * `positiveOffset + i - 1` (1-indexed) and
42
+ * we use the geometric midpoint
43
+ * base^(k + 0.5). Negative buckets are
44
+ * currently ignored (rare in practice and
45
+ * would require a separate fanout).
46
+ * Summary -> exactly one sample: the value at the
47
+ * stored quantile closest to (and >=) the
48
+ * target `p`, weighted 1; falls back to the
49
+ * highest stored quantile when nothing
50
+ * covers `p`.
51
+ * Sum / Gauge / unknown -> raw `value` weighted 1 (same as the
52
+ * generic `quantile(p)(value)` path).
53
+ */
54
+ public override toAggregateStatement(aggregateBy: AggregateBy<Metric>): {
55
+ statement: Statement;
56
+ columns: Array<string>;
57
+ } {
58
+ if (!isPercentileAggregation(aggregateBy.aggregationType)) {
59
+ return super.toAggregateStatement(aggregateBy);
60
+ }
61
+
62
+ const percentileLevel: number | null = getPercentileLevel(
63
+ aggregateBy.aggregationType,
64
+ );
65
+ if (percentileLevel === null) {
66
+ return super.toAggregateStatement(aggregateBy);
67
+ }
68
+
69
+ if (!this.database) {
70
+ this.useDefaultDatabase();
71
+ }
72
+
73
+ const databaseName: string = this.database.getDatasourceOptions().database!;
74
+
75
+ const aggregationColumn: string =
76
+ aggregateBy.aggregateColumnName.toString();
77
+ const aggregationTimestampColumn: string =
78
+ aggregateBy.aggregationTimestampColumnName.toString();
79
+ const aggregationInterval: string = AggregateUtil.getAggregationInterval({
80
+ startDate: aggregateBy.startTimestamp!,
81
+ endDate: aggregateBy.endTimestamp!,
82
+ }).toLowerCase();
83
+
84
+ /*
85
+ * Group-by columns from the caller need to be carried through the
86
+ * inner subquery so the outer GROUP BY can reference them. Only
87
+ * columns that exist on the model are accepted (matches the base
88
+ * generator's safety net).
89
+ */
90
+ const groupByKeys: Array<string> = [];
91
+ if (aggregateBy.groupBy) {
92
+ for (const key of Object.keys(aggregateBy.groupBy)) {
93
+ if (!this.model.getTableColumn(key)) {
94
+ continue;
95
+ }
96
+ groupByKeys.push(key);
97
+ }
98
+ }
99
+
100
+ const whereStatement: Statement = this.statementGenerator.toWhereStatement(
101
+ aggregateBy.query,
102
+ );
103
+ const sortStatement: Statement = this.statementGenerator.toSortStatement(
104
+ aggregateBy.sort!,
105
+ );
106
+
107
+ /*
108
+ * Per-row fanout. The result of multiIf is `Array(Tuple(Float64,
109
+ * Float64))` — element 1 is the sample midpoint, element 2 is the
110
+ * weight (rounded to UInt64 in the outer SELECT for
111
+ * quantileExactWeighted). Each branch is guarded by a presence
112
+ * check so a row missing its expected payload (e.g. a zero-bucket
113
+ * histogram) silently drops to the scalar fallback rather than
114
+ * exploding.
115
+ */
116
+ const fanoutExpression: string = `
117
+ multiIf(
118
+ metricPointType = 'ExponentialHistogram' AND notEmpty(positiveBucketCounts),
119
+ arrayMap(
120
+ i -> tuple(
121
+ pow(
122
+ pow(2.0, pow(2.0, -toFloat64(coalesce(scale, 0)))),
123
+ toFloat64(coalesce(positiveOffset, 0)) + toFloat64(i) - 0.5
124
+ ),
125
+ toFloat64(positiveBucketCounts[i])
126
+ ),
127
+ arrayEnumerate(positiveBucketCounts)
128
+ ),
129
+ metricPointType = 'Histogram' AND notEmpty(bucketCounts),
130
+ arrayMap(
131
+ i -> tuple(
132
+ multiIf(
133
+ length(explicitBounds) = 0,
134
+ toFloat64(coalesce(value, sum, 0)),
135
+ i = 1,
136
+ toFloat64(explicitBounds[1]) / 2.0,
137
+ i > length(explicitBounds),
138
+ toFloat64(explicitBounds[length(explicitBounds)]) * 1.5,
139
+ (toFloat64(explicitBounds[i - 1]) + toFloat64(explicitBounds[i])) / 2.0
140
+ ),
141
+ toFloat64(bucketCounts[i])
142
+ ),
143
+ arrayEnumerate(bucketCounts)
144
+ ),
145
+ metricPointType = 'Summary' AND notEmpty(summaryValues),
146
+ [tuple(
147
+ if(
148
+ arrayFirstIndex(q -> q >= ${percentileLevel}, summaryQuantiles) > 0,
149
+ summaryValues[arrayFirstIndex(q -> q >= ${percentileLevel}, summaryQuantiles)],
150
+ summaryValues[length(summaryValues)]
151
+ ),
152
+ 1.0
153
+ )],
154
+ [tuple(toFloat64(coalesce(value, sum, 0)), 1.0)]
155
+ )
156
+ `;
157
+
158
+ /*
159
+ * Inner subquery: keeps the row's timestamp and any group-by
160
+ * columns, then fans the row into per-sample rows via arrayJoin.
161
+ * We use `__pcl_pair` so the column name doesn't collide with any
162
+ * model column should ClickHouse ever surface it through a tooling
163
+ * layer.
164
+ */
165
+ const innerSelectColumns: Array<string> = [aggregationTimestampColumn];
166
+ for (const key of groupByKeys) {
167
+ if (!innerSelectColumns.includes(key)) {
168
+ innerSelectColumns.push(key);
169
+ }
170
+ }
171
+
172
+ const innerSelectClause: string = `${innerSelectColumns.join(", ")}, arrayJoin(${fanoutExpression}) AS __pcl_pair`;
173
+
174
+ const statement: Statement = SQL``;
175
+
176
+ /*
177
+ * Outer SELECT: time bucket + weighted quantile + carry-forward
178
+ * group-by columns. Quantile weight must be UInt for
179
+ * quantileExactWeighted; we round to nearest integer (a count of
180
+ * 0.5 rounds to 0 which drops the sample, but bucket counts are
181
+ * always whole numbers in practice).
182
+ */
183
+ statement.append(
184
+ `SELECT quantileExactWeighted(${percentileLevel})(__pcl_pair.1, toUInt64(greatest(0, round(__pcl_pair.2)))) as ${aggregationColumn}, date_trunc('${aggregationInterval}', toStartOfInterval(${aggregationTimestampColumn}, INTERVAL 1 ${aggregationInterval})) as ${aggregationTimestampColumn}`,
185
+ );
186
+
187
+ for (const key of groupByKeys) {
188
+ statement.append(`, ${key}`);
189
+ }
190
+
191
+ statement.append(SQL` FROM (`);
192
+ statement.append(`SELECT ${innerSelectClause}`);
193
+ statement.append(
194
+ ` FROM ${databaseName}.${this.model.tableName} WHERE TRUE `,
195
+ );
196
+ statement.append(whereStatement);
197
+ statement.append(SQL`) `);
198
+
199
+ statement.append(SQL` GROUP BY `).append(`${aggregationTimestampColumn}`);
200
+ for (const key of groupByKeys) {
201
+ statement.append(`, ${key}`);
202
+ }
203
+
204
+ statement.append(SQL` ORDER BY `).append(sortStatement);
205
+
206
+ statement.append(
207
+ SQL` LIMIT ${{
208
+ value: Number(aggregateBy.limit),
209
+ type: TableColumnType.Number,
210
+ }}`,
211
+ );
212
+ statement.append(
213
+ SQL` OFFSET ${{
214
+ value: Number(aggregateBy.skip),
215
+ type: TableColumnType.Number,
216
+ }} `,
217
+ );
218
+
219
+ const columns: Array<string> = [
220
+ aggregationColumn,
221
+ aggregationTimestampColumn,
222
+ ...groupByKeys,
223
+ ];
224
+
225
+ logger.debug(`${this.model.tableName} Percentile Aggregate Statement`, {
226
+ tableName: this.model.tableName,
227
+ } as LogAttributes);
228
+ logger.debug(statement, {
229
+ tableName: this.model.tableName,
230
+ } as LogAttributes);
231
+
232
+ return { statement, columns };
8
233
  }
9
234
  }
10
235
 
@@ -35,6 +35,7 @@ import AggregateBy, {
35
35
  AggregateUtil,
36
36
  } from "../../Types/AnalyticsDatabase/AggregateBy";
37
37
  import CaptureSpan from "../Telemetry/CaptureSpan";
38
+ import { getPercentileLevel } from "../../../Types/BaseDatabase/AggregationType";
38
39
 
39
40
  export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
40
41
  public model!: TBaseModel;
@@ -275,6 +276,25 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
275
276
  .join(", ")}]`;
276
277
  }
277
278
 
279
+ if (column.type === TableColumnType.ArrayDecimal) {
280
+ value = `[${(value as Array<number>)
281
+ .map((v: number) => {
282
+ if (v && typeof v !== "number") {
283
+ v = parseFloat(v);
284
+ return isNaN(v) ? "NULL" : v;
285
+ }
286
+ /*
287
+ * Filter non-finite (NaN/+Inf/-Inf) -> NULL so ClickHouse Float64
288
+ * serialization succeeds (mirrors `toNumberOrNull` in OTLP ingest).
289
+ */
290
+ if (typeof v === "number" && !Number.isFinite(v)) {
291
+ return "NULL";
292
+ }
293
+ return v;
294
+ })
295
+ .join(", ")}]`;
296
+ }
297
+
278
298
  if (column.type === TableColumnType.MapStringString) {
279
299
  const mapObj: Record<string, string> = value as Record<string, string>;
280
300
  const entries: Array<string> = Object.entries(mapObj)
@@ -594,19 +614,37 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
594
614
  /*
595
615
  * EXAMPLE:
596
616
  * SELECT sum(Metric.value) as avg_value, date_trunc('hour', toStartOfInterval(createdAt, INTERVAL 1 hour)) as createdAt
617
+ *
618
+ * Percentile aggregations (P50/P90/P95/P99) compile to ClickHouse's
619
+ * `quantile(level)(column)`. This is the right thing for scalar
620
+ * columns (Span.duration, Metric.value when the metric is a Sum or
621
+ * Gauge, etc.). MetricService overrides this method when it has
622
+ * histogram bucket data so the percentile is computed from the
623
+ * actual sample distribution rather than from the per-row aggregated
624
+ * value.
597
625
  */
598
626
 
599
627
  const selectStatement: Statement = new Statement();
600
628
 
601
- const aggregationMethod: string =
602
- aggregateBy.aggregationType.toLocaleLowerCase();
603
629
  const aggregationInterval: string = AggregateUtil.getAggregationInterval({
604
630
  startDate: aggregateBy.startTimestamp!,
605
631
  endDate: aggregateBy.endTimestamp!,
606
632
  });
633
+ const aggregationColumn: string =
634
+ aggregateBy.aggregateColumnName.toString();
635
+ const aggregationTimestampColumn: string =
636
+ aggregateBy.aggregationTimestampColumnName.toString();
637
+
638
+ const percentileLevel: number | null = getPercentileLevel(
639
+ aggregateBy.aggregationType,
640
+ );
641
+ const aggregationExpression: string =
642
+ percentileLevel !== null
643
+ ? `quantile(${percentileLevel})(${aggregationColumn})`
644
+ : `${aggregateBy.aggregationType.toLocaleLowerCase()}(${aggregationColumn})`;
607
645
 
608
646
  selectStatement.append(
609
- `${aggregationMethod}(${aggregateBy.aggregateColumnName.toString()}) as ${aggregateBy.aggregateColumnName.toString()}, date_trunc('${aggregationInterval.toLowerCase()}', toStartOfInterval(${aggregateBy.aggregationTimestampColumnName.toString()}, INTERVAL 1 ${aggregationInterval.toLowerCase()})) as ${aggregateBy.aggregationTimestampColumnName.toString()}`,
647
+ `${aggregationExpression} as ${aggregationColumn}, date_trunc('${aggregationInterval.toLowerCase()}', toStartOfInterval(${aggregationTimestampColumn}, INTERVAL 1 ${aggregationInterval.toLowerCase()})) as ${aggregationTimestampColumn}`,
610
648
  );
611
649
 
612
650
  const columns: Array<string> = [
@@ -745,6 +783,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
745
783
  "Array(String)": TableColumnType.ArrayText,
746
784
  "Array(Int32)": TableColumnType.ArrayNumber,
747
785
  "Array(Int64)": TableColumnType.ArrayBigNumber,
786
+ "Array(Float64)": TableColumnType.ArrayDecimal,
748
787
  "Map(String, String)": TableColumnType.MapStringString,
749
788
  JSON: TableColumnType.JSON, //JSONArray is also JSON
750
789
  Bool: TableColumnType.Boolean,
@@ -766,6 +805,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
766
805
  [TableColumnType.JSONArray]: SQL`String`, // we use JSON as a string because ClickHouse has really good JSON support for string types
767
806
  [TableColumnType.ArrayNumber]: SQL`Array(Int32)`,
768
807
  [TableColumnType.ArrayBigNumber]: SQL`Array(Int64)`,
808
+ [TableColumnType.ArrayDecimal]: SQL`Array(Float64)`,
769
809
  [TableColumnType.ArrayText]: SQL`Array(String)`,
770
810
  [TableColumnType.LongNumber]: SQL`Int128`,
771
811
  [TableColumnType.BigNumber]: SQL`Int64`,
@@ -22,6 +22,11 @@ export const ExpressJson: GenericFunction = express.json;
22
22
  export const ExpressUrlEncoded: GenericFunction = express.urlencoded;
23
23
  export const ExpressRaw: GenericFunction = express.raw;
24
24
 
25
+ export const createExpressApp: () => express.Application =
26
+ (): express.Application => {
27
+ return express();
28
+ };
29
+
25
30
  export type ProbeRequest = {
26
31
  id: ObjectID;
27
32
  };
@@ -16,6 +16,7 @@ enum ColumnType {
16
16
  Port = "Port",
17
17
  MapStringString = "Map(String, String)",
18
18
  ArrayBigNumber = "Array of Big Numbers",
19
+ ArrayDecimal = "Array of Decimals",
19
20
  }
20
21
 
21
22
  export default ColumnType;