@oneuptime/common 11.5.1 → 11.5.3

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/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.ts +1129 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +3 -1
  3. package/Server/Services/AnalyticsDatabaseService.ts +59 -7
  4. package/Server/Services/MetricService.ts +124 -28
  5. package/Server/Services/SpanService.ts +11 -0
  6. package/Server/Types/AnalyticsDatabase/AggregateBy.ts +39 -0
  7. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +8 -5
  8. package/Server/Utils/Telemetry/LlmSpan.ts +38 -64
  9. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +84 -0
  10. package/Tests/Server/Services/MetricServiceAggregate.test.ts +167 -0
  11. package/Tests/Types/BaseDatabase/AggregationIntervalUtil.test.ts +40 -0
  12. package/Types/BaseDatabase/AggregateBy.ts +11 -1
  13. package/Types/BaseDatabase/AggregationInterval.ts +10 -0
  14. package/Types/BaseDatabase/AggregationIntervalUtil.ts +23 -0
  15. package/Types/Metrics/MetricFormulaConfigData.ts +5 -0
  16. package/Types/Metrics/MetricQueryConfigData.ts +19 -0
  17. package/Types/Telemetry/LlmConventions.ts +182 -0
  18. package/UI/Components/Charts/Area/AreaChart.tsx +15 -2
  19. package/UI/Components/Charts/Bar/BarChart.tsx +16 -2
  20. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +31 -32
  21. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +41 -17
  22. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +38 -34
  23. package/UI/Components/Charts/ChartLibrary/SparkChart/SparkChart.tsx +4 -3
  24. package/UI/Components/Charts/ChartLibrary/Utils/ChartColors.ts +48 -10
  25. package/UI/Components/Charts/Line/LineChart.tsx +15 -2
  26. package/UI/Components/Detail/PlaceholderText.tsx +1 -14
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.js +384 -0
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.js.map +1 -0
  29. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +3 -1
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  31. package/build/dist/Server/Services/AnalyticsDatabaseService.js +43 -5
  32. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  33. package/build/dist/Server/Services/MetricService.js +110 -23
  34. package/build/dist/Server/Services/MetricService.js.map +1 -1
  35. package/build/dist/Server/Services/SpanService.js +10 -0
  36. package/build/dist/Server/Services/SpanService.js.map +1 -1
  37. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +32 -0
  38. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
  39. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +2 -1
  40. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  41. package/build/dist/Server/Utils/Telemetry/LlmSpan.js +14 -54
  42. package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
  43. package/build/dist/Types/BaseDatabase/AggregationInterval.js +10 -0
  44. package/build/dist/Types/BaseDatabase/AggregationInterval.js.map +1 -1
  45. package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js +13 -0
  46. package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js.map +1 -1
  47. package/build/dist/Types/Telemetry/LlmConventions.js +149 -0
  48. package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -0
  49. package/build/dist/UI/Components/Charts/Area/AreaChart.js +3 -1
  50. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  51. package/build/dist/UI/Components/Charts/Bar/BarChart.js +3 -1
  52. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  53. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +13 -9
  54. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  55. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +19 -7
  56. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  57. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +21 -9
  58. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  59. package/build/dist/UI/Components/Charts/ChartLibrary/SparkChart/SparkChart.js.map +1 -1
  60. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/ChartColors.js +26 -0
  61. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/ChartColors.js.map +1 -1
  62. package/build/dist/UI/Components/Charts/Line/LineChart.js +3 -1
  63. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  64. package/build/dist/UI/Components/Detail/PlaceholderText.js +1 -4
  65. package/build/dist/UI/Components/Detail/PlaceholderText.js.map +1 -1
  66. package/package.json +1 -1
@@ -433,6 +433,7 @@ import { AddTelemetryEntityRelationshipMetrics1783762505482 } from "./1783762505
433
433
  import { AddIoTDeviceCredentialTable1783780000000 } from "./1783780000000-AddIoTDeviceCredentialTable";
434
434
  import { AddSnmpV3AuthColumnsToNetworkDevice1783790000000 } from "./1783790000000-AddSnmpV3AuthColumnsToNetworkDevice";
435
435
  import { AddAdditionalParamsToLlmProvider1783650000000 } from "./1783650000000-AddAdditionalParamsToLlmProvider";
436
+ import { MigrationName1783937343400 } from "./1783937343400-MigrationName";
436
437
 
437
438
  export default [
438
439
  InitialMigration,
@@ -869,5 +870,6 @@ export default [
869
870
  AddTelemetryEntityRelationshipMetrics1783762505482,
870
871
  AddIoTDeviceCredentialTable1783780000000,
871
872
  AddSnmpV3AuthColumnsToNetworkDevice1783790000000,
872
- AddAdditionalParamsToLlmProvider1783650000000
873
+ AddAdditionalParamsToLlmProvider1783650000000,
874
+ MigrationName1783937343400,
873
875
  ];
@@ -64,7 +64,10 @@ import Text from "../../Types/Text";
64
64
  import Typeof from "../../Types/Typeof";
65
65
  import API from "../../Utils/API";
66
66
  import { Stream } from "node:stream";
67
- import AggregateBy from "../Types/AnalyticsDatabase/AggregateBy";
67
+ import AggregateBy, {
68
+ AggregateUtil,
69
+ } from "../Types/AnalyticsDatabase/AggregateBy";
70
+ import AggregationInterval from "../../Types/BaseDatabase/AggregationInterval";
68
71
  import AggregatedResult from "../../Types/BaseDatabase/AggregatedResult";
69
72
  import AggregationType from "../../Types/BaseDatabase/AggregationType";
70
73
  import Sort from "../Types/AnalyticsDatabase/Sort";
@@ -744,6 +747,17 @@ export default class AnalyticsDatabaseService<
744
747
  );
745
748
  }
746
749
 
750
+ if (
751
+ aggregateBy.aggregationInterval !== undefined &&
752
+ !Object.values(AggregationInterval).includes(
753
+ aggregateBy.aggregationInterval,
754
+ )
755
+ ) {
756
+ throw new BadDataException(
757
+ `Invalid aggregationInterval: ${aggregateBy.aggregationInterval}. Allowed values: ${Object.values(AggregationInterval).join(", ")}`,
758
+ );
759
+ }
760
+
747
761
  if (!aggregateBy.aggregationTimestampColumnName) {
748
762
  throw new BadDataException(
749
763
  "aggregationTimestampColumnName is required",
@@ -1224,18 +1238,56 @@ export default class AnalyticsDatabaseService<
1224
1238
  .append(whereStatement)
1225
1239
  .append(this.getRetentionReadFilter());
1226
1240
 
1227
- statement
1228
- .append(SQL` GROUP BY `)
1229
- .append(`${aggregateBy.aggregationTimestampColumnName.toString()}`);
1241
+ /*
1242
+ * The time bucket is a grouping key only when we ARE bucketing by
1243
+ * time. For a `Total` (whole-window) aggregation the timestamp is
1244
+ * emitted as `min(...)` — an aggregate, not a group key — so it must
1245
+ * be left out of GROUP BY. When there is no group-by column either,
1246
+ * the GROUP BY clause is omitted entirely (a single global row).
1247
+ */
1248
+ const resolvedInterval: AggregationInterval =
1249
+ AggregateUtil.getAggregationInterval({
1250
+ startDate: aggregateBy.startTimestamp!,
1251
+ endDate: aggregateBy.endTimestamp!,
1252
+ aggregationInterval: aggregateBy.aggregationInterval,
1253
+ });
1254
+ const bucketByTime: boolean =
1255
+ !AggregateUtil.isTotalAggregation(resolvedInterval);
1256
+ const hasGroupBy: boolean = Boolean(
1257
+ aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0,
1258
+ );
1230
1259
 
1231
- if (aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0) {
1260
+ if (bucketByTime) {
1261
+ statement
1262
+ .append(SQL` GROUP BY `)
1263
+ .append(`${aggregateBy.aggregationTimestampColumnName.toString()}`);
1264
+ if (hasGroupBy) {
1265
+ statement
1266
+ .append(SQL` , `)
1267
+ .append(
1268
+ this.statementGenerator.toGroupByStatement(aggregateBy.groupBy!),
1269
+ );
1270
+ }
1271
+ } else if (hasGroupBy) {
1232
1272
  statement
1233
- .append(SQL` , `)
1273
+ .append(SQL` GROUP BY `)
1234
1274
  .append(
1235
- this.statementGenerator.toGroupByStatement(aggregateBy.groupBy),
1275
+ this.statementGenerator.toGroupByStatement(aggregateBy.groupBy!),
1236
1276
  );
1237
1277
  }
1238
1278
 
1279
+ /*
1280
+ * A group-less Total aggregation (`SELECT agg(...), min(ts) ...` with no
1281
+ * GROUP BY) returns exactly one row even over an empty window —
1282
+ * ClickHouse fills the aggregates with type defaults, so min(ts)
1283
+ * surfaces as a 1970 epoch point. Suppress that phantom row. This is a
1284
+ * no-op for every bucketed/grouped query (they only ever emit rows for
1285
+ * groups that actually have data).
1286
+ */
1287
+ if (!bucketByTime && !hasGroupBy) {
1288
+ statement.append(SQL` HAVING count() > 0`);
1289
+ }
1290
+
1239
1291
  statement.append(SQL` ORDER BY `).append(sortStatement);
1240
1292
 
1241
1293
  statement.append(
@@ -560,10 +560,14 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
560
560
  aggregateBy.aggregateColumnName.toString();
561
561
  const aggregationTimestampColumn: string =
562
562
  aggregateBy.aggregationTimestampColumnName.toString();
563
- const aggregationInterval: string = AggregateUtil.getAggregationInterval({
564
- startDate: aggregateBy.startTimestamp!,
565
- endDate: aggregateBy.endTimestamp!,
566
- }).toLowerCase();
563
+ const resolvedInterval: AggregationInterval =
564
+ AggregateUtil.getAggregationInterval({
565
+ startDate: aggregateBy.startTimestamp!,
566
+ endDate: aggregateBy.endTimestamp!,
567
+ aggregationInterval: aggregateBy.aggregationInterval,
568
+ });
569
+ const bucketByTime: boolean =
570
+ !AggregateUtil.isTotalAggregation(resolvedInterval);
567
571
 
568
572
  /*
569
573
  * Group-by columns from the caller need to be carried through the
@@ -671,7 +675,7 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
671
675
  * always whole numbers in practice).
672
676
  */
673
677
  statement.append(
674
- `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}`,
678
+ `SELECT quantileExactWeighted(${percentileLevel})(__pcl_pair.1, toUInt64(greatest(0, round(__pcl_pair.2)))) as ${aggregationColumn}, ${AggregateUtil.buildBucketTimestampSelect(resolvedInterval, aggregationTimestampColumn)}`,
675
679
  );
676
680
 
677
681
  for (const key of groupByKeys) {
@@ -699,13 +703,33 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
699
703
  statement.append(this.getRetentionReadFilter());
700
704
  statement.append(SQL`) `);
701
705
 
702
- statement.append(SQL` GROUP BY `).append(`${aggregationTimestampColumn}`);
706
+ /*
707
+ * `Total` collapses the window into one row per group: the timestamp
708
+ * becomes an aggregate (`min(...)`) and drops out of GROUP BY, and
709
+ * when nothing else is grouped the clause is omitted entirely.
710
+ */
711
+ const percentileGroupByTerms: Array<string> = [];
712
+ if (bucketByTime) {
713
+ percentileGroupByTerms.push(aggregationTimestampColumn);
714
+ }
703
715
  for (const key of groupByKeys) {
704
- statement.append(`, ${key}`);
716
+ percentileGroupByTerms.push(key);
705
717
  }
706
718
  attributeGroupKeys.forEach((_key: string, index: number) => {
707
- statement.append(`, __attr_grp_${index}`);
719
+ percentileGroupByTerms.push(`__attr_grp_${index}`);
708
720
  });
721
+ if (percentileGroupByTerms.length > 0) {
722
+ statement
723
+ .append(SQL` GROUP BY `)
724
+ .append(percentileGroupByTerms.join(", "));
725
+ } else {
726
+ /*
727
+ * Group-less Total: suppress the single phantom row an empty window
728
+ * would otherwise return (min(time) → 1970 epoch). Only reachable
729
+ * under Total — a bucketed query always groups by the time column.
730
+ */
731
+ statement.append(SQL` HAVING count() > 0`);
732
+ }
709
733
 
710
734
  statement.append(SQL` ORDER BY `).append(sortStatement);
711
735
 
@@ -832,10 +856,14 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
832
856
  aggregateBy.aggregateColumnName.toString();
833
857
  const aggregationTimestampColumn: string =
834
858
  aggregateBy.aggregationTimestampColumnName.toString();
835
- const aggregationInterval: string = AggregateUtil.getAggregationInterval({
836
- startDate: aggregateBy.startTimestamp!,
837
- endDate: aggregateBy.endTimestamp!,
838
- }).toLowerCase();
859
+ const resolvedInterval: AggregationInterval =
860
+ AggregateUtil.getAggregationInterval({
861
+ startDate: aggregateBy.startTimestamp!,
862
+ endDate: aggregateBy.endTimestamp!,
863
+ aggregationInterval: aggregateBy.aggregationInterval,
864
+ });
865
+ const bucketByTime: boolean =
866
+ !AggregateUtil.isTotalAggregation(resolvedInterval);
839
867
 
840
868
  const groupByKeys: Array<string> = [];
841
869
  if (aggregateBy.groupBy) {
@@ -871,7 +899,7 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
871
899
  const statement: Statement = SQL``;
872
900
 
873
901
  statement.append(
874
- `SELECT ${aggregationExpression} as ${aggregationColumn}, date_trunc('${aggregationInterval}', toStartOfInterval(${aggregationTimestampColumn}, INTERVAL 1 ${aggregationInterval})) as ${aggregationTimestampColumn}`,
902
+ `SELECT ${aggregationExpression} as ${aggregationColumn}, ${AggregateUtil.buildBucketTimestampSelect(resolvedInterval, aggregationTimestampColumn)}`,
875
903
  );
876
904
  for (const key of groupByKeys) {
877
905
  statement.append(`, ${key}`);
@@ -916,13 +944,32 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
916
944
  statement.append(this.getRetentionReadFilter());
917
945
  }
918
946
 
919
- statement.append(SQL` GROUP BY `).append(`${aggregationTimestampColumn}`);
947
+ /*
948
+ * `Total` drops the time bucket from GROUP BY (it becomes an
949
+ * aggregate `min(...)`), leaving one row per group — or a single
950
+ * global row when nothing else is grouped, in which case the GROUP
951
+ * BY clause is omitted entirely.
952
+ */
953
+ const scalarGroupByTerms: Array<string> = [];
954
+ if (bucketByTime) {
955
+ scalarGroupByTerms.push(aggregationTimestampColumn);
956
+ }
920
957
  for (const key of groupByKeys) {
921
- statement.append(`, ${key}`);
958
+ scalarGroupByTerms.push(key);
922
959
  }
923
960
  attributeGroupKeys.forEach((_key: string, index: number) => {
924
- statement.append(`, __attr_grp_${index}`);
961
+ scalarGroupByTerms.push(`__attr_grp_${index}`);
925
962
  });
963
+ if (scalarGroupByTerms.length > 0) {
964
+ statement.append(SQL` GROUP BY `).append(scalarGroupByTerms.join(", "));
965
+ } else {
966
+ /*
967
+ * Group-less Total: suppress the single phantom row an empty window
968
+ * would otherwise return (min(time) → 1970 epoch). Only reachable
969
+ * under Total — a bucketed query always groups by the time column.
970
+ */
971
+ statement.append(SQL` HAVING count() > 0`);
972
+ }
926
973
 
927
974
  statement.append(SQL` ORDER BY `).append(sortStatement);
928
975
 
@@ -1044,18 +1091,54 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1044
1091
  .append(outerWhereStatement)
1045
1092
  .append(SQL` AND retentionDate >= now()`);
1046
1093
 
1047
- statement
1048
- .append(SQL` GROUP BY `)
1049
- .append(`${aggregateBy.aggregationTimestampColumnName.toString()}`);
1094
+ /*
1095
+ * Mirror the base builder's Total handling: the SELECT above (shared
1096
+ * toAggregateSelectStatement) already emits `min(time)` for a `Total`
1097
+ * interval, so the time column must NOT appear in GROUP BY — grouping
1098
+ * by an aggregate alias is a ClickHouse error. Omit the clause
1099
+ * entirely when nothing else is grouped.
1100
+ */
1101
+ const mutableResolvedInterval: AggregationInterval =
1102
+ AggregateUtil.getAggregationInterval({
1103
+ startDate: aggregateBy.startTimestamp!,
1104
+ endDate: aggregateBy.endTimestamp!,
1105
+ aggregationInterval: aggregateBy.aggregationInterval,
1106
+ });
1107
+ const mutableBucketByTime: boolean = !AggregateUtil.isTotalAggregation(
1108
+ mutableResolvedInterval,
1109
+ );
1110
+ const mutableHasGroupBy: boolean = Boolean(
1111
+ aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0,
1112
+ );
1050
1113
 
1051
- if (aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0) {
1114
+ if (mutableBucketByTime) {
1115
+ statement
1116
+ .append(SQL` GROUP BY `)
1117
+ .append(`${aggregateBy.aggregationTimestampColumnName.toString()}`);
1118
+ if (mutableHasGroupBy) {
1119
+ statement
1120
+ .append(SQL` , `)
1121
+ .append(
1122
+ this.statementGenerator.toGroupByStatement(aggregateBy.groupBy!),
1123
+ );
1124
+ }
1125
+ } else if (mutableHasGroupBy) {
1052
1126
  statement
1053
- .append(SQL` , `)
1127
+ .append(SQL` GROUP BY `)
1054
1128
  .append(
1055
- this.statementGenerator.toGroupByStatement(aggregateBy.groupBy),
1129
+ this.statementGenerator.toGroupByStatement(aggregateBy.groupBy!),
1056
1130
  );
1057
1131
  }
1058
1132
 
1133
+ /*
1134
+ * Group-less Total: suppress the single phantom row an empty window
1135
+ * would otherwise return (min(time) → 1970 epoch). No-op for bucketed
1136
+ * or grouped queries.
1137
+ */
1138
+ if (!mutableBucketByTime && !mutableHasGroupBy) {
1139
+ statement.append(SQL` HAVING count() > 0`);
1140
+ }
1141
+
1059
1142
  statement.append(SQL` ORDER BY `).append(sortStatement);
1060
1143
 
1061
1144
  statement.append(
@@ -1236,14 +1319,18 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1236
1319
  const interval: AggregationInterval = AggregateUtil.getAggregationInterval({
1237
1320
  startDate: aggregateBy.startTimestamp!,
1238
1321
  endDate: aggregateBy.endTimestamp!,
1322
+ aggregationInterval: aggregateBy.aggregationInterval,
1239
1323
  });
1240
1324
  /*
1241
- * The MV is bucketed at 1 minute, so all values of AggregationInterval
1242
- * (Minute / Hour / Day / Week / Month / Year) are >= MV resolution
1243
- * and acceptable. Kept as a no-op read so the dependency on
1244
- * AggregateUtil makes the intent obvious.
1325
+ * The MV is bucketed at 1 minute, so every time-bucketed interval
1326
+ * (Minute / Hour / Day / Week / Month / Year) is >= MV resolution and
1327
+ * acceptable the honored interval flows into the date_trunc below.
1328
+ * `Total` (whole-window, no bucketing) is the one exception: fall back
1329
+ * to the raw-table builder, which knows how to collapse the window.
1245
1330
  */
1246
- void interval;
1331
+ if (AggregateUtil.isTotalAggregation(interval)) {
1332
+ return null;
1333
+ }
1247
1334
 
1248
1335
  const queryRecord: Record<string, unknown> =
1249
1336
  (aggregateBy.query as unknown as Record<string, unknown>) || {};
@@ -1510,8 +1597,17 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
1510
1597
  const interval: AggregationInterval = AggregateUtil.getAggregationInterval({
1511
1598
  startDate: aggregateBy.startTimestamp!,
1512
1599
  endDate: aggregateBy.endTimestamp!,
1600
+ aggregationInterval: aggregateBy.aggregationInterval,
1513
1601
  });
1514
- void interval;
1602
+ /*
1603
+ * `Total` (whole-window, no bucketing) is not served by this
1604
+ * time-bucketed per-host MV — fall back to the raw-table builder.
1605
+ * Every other interval is >= the MV's 1-minute resolution and flows
1606
+ * into the date_trunc below.
1607
+ */
1608
+ if (AggregateUtil.isTotalAggregation(interval)) {
1609
+ return null;
1610
+ }
1515
1611
 
1516
1612
  if (!this.database) {
1517
1613
  this.useDefaultDatabase();
@@ -245,6 +245,17 @@ export class SpanService extends AnalyticsDatabaseService<Span> {
245
245
  return null;
246
246
  }
247
247
 
248
+ /*
249
+ * This projection path derives its bucket interval from the window
250
+ * and buckets by minute; it does not implement the explicit
251
+ * `aggregationInterval` override (nor the `Total` whole-window mode).
252
+ * When a caller pins an interval, fall back to the generic base
253
+ * builder, which honors it.
254
+ */
255
+ if (aggregateBy.aggregationInterval !== undefined) {
256
+ return null;
257
+ }
258
+
248
259
  /*
249
260
  * Only the aggregates whose states the projection stores. P95 etc.
250
261
  * must fall back: the projection has no quantile(0.95) state and
@@ -16,12 +16,51 @@ export class AggregateUtil {
16
16
  * code reconstructing the server's bucket grid (heartbeat
17
17
  * availability charts) always agrees with the interval the
18
18
  * statement generator compiles into `toStartOfInterval(...)`.
19
+ *
20
+ * `aggregationInterval`, when provided, pins the bucket size
21
+ * independent of the window (including `Total` for a single
22
+ * whole-window bucket per group).
19
23
  */
20
24
  @CaptureSpan()
21
25
  public static getAggregationInterval(data: {
22
26
  startDate: Date;
23
27
  endDate: Date;
28
+ aggregationInterval?: AggregationInterval | undefined;
24
29
  }): AggregationInterval {
25
30
  return AggregationIntervalUtil.getAggregationIntervalForWindow(data);
26
31
  }
32
+
33
+ /**
34
+ * True when the resolved interval means "no time bucketing" — the
35
+ * entire window collapses into one aggregate per group.
36
+ */
37
+ public static isTotalAggregation(interval: AggregationInterval): boolean {
38
+ return interval === AggregationInterval.Total;
39
+ }
40
+
41
+ /**
42
+ * The SELECT fragment that produces the bucket timestamp column,
43
+ * already aliased to `timestampColumn`. Shared by every aggregate SQL
44
+ * builder so the `Total` (whole-window) shape stays identical across
45
+ * paths:
46
+ *
47
+ * - normal interval → `date_trunc('<i>', toStartOfInterval(col, INTERVAL 1 <i>)) as col`
48
+ * - Total → `min(col) as col` (one row per group; the
49
+ * earliest sample timestamp in the window is the bucket label)
50
+ *
51
+ * `timestampColumn` is a model-validated identifier (see
52
+ * AnalyticsDatabaseService._aggregateBy), so raw interpolation here is
53
+ * consistent with the surrounding builders.
54
+ */
55
+ public static buildBucketTimestampSelect(
56
+ resolvedInterval: AggregationInterval,
57
+ timestampColumn: string,
58
+ ): string {
59
+ if (AggregateUtil.isTotalAggregation(resolvedInterval)) {
60
+ return `min(${timestampColumn}) as ${timestampColumn}`;
61
+ }
62
+
63
+ const interval: string = resolvedInterval.toLowerCase();
64
+ return `date_trunc('${interval}', toStartOfInterval(${timestampColumn}, INTERVAL 1 ${interval})) as ${timestampColumn}`;
65
+ }
27
66
  }
@@ -51,6 +51,7 @@ import JSONFunctions from "../../../Types/JSONFunctions";
51
51
  import AggregateBy, {
52
52
  AggregateUtil,
53
53
  } from "../../Types/AnalyticsDatabase/AggregateBy";
54
+ import AggregationInterval from "../../../Types/BaseDatabase/AggregationInterval";
54
55
  import CaptureSpan from "../Telemetry/CaptureSpan";
55
56
  import { getPercentileLevel } from "../../../Types/BaseDatabase/AggregationType";
56
57
 
@@ -1194,10 +1195,12 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1194
1195
 
1195
1196
  const selectStatement: Statement = new Statement();
1196
1197
 
1197
- const aggregationInterval: string = AggregateUtil.getAggregationInterval({
1198
- startDate: aggregateBy.startTimestamp!,
1199
- endDate: aggregateBy.endTimestamp!,
1200
- });
1198
+ const aggregationInterval: AggregationInterval =
1199
+ AggregateUtil.getAggregationInterval({
1200
+ startDate: aggregateBy.startTimestamp!,
1201
+ endDate: aggregateBy.endTimestamp!,
1202
+ aggregationInterval: aggregateBy.aggregationInterval,
1203
+ });
1201
1204
  const aggregationColumn: string =
1202
1205
  aggregateBy.aggregateColumnName.toString();
1203
1206
  const aggregationTimestampColumn: string =
@@ -1212,7 +1215,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1212
1215
  : `${aggregateBy.aggregationType.toLocaleLowerCase()}(${aggregationColumn})`;
1213
1216
 
1214
1217
  selectStatement.append(
1215
- `${aggregationExpression} as ${aggregationColumn}, date_trunc('${aggregationInterval.toLowerCase()}', toStartOfInterval(${aggregationTimestampColumn}, INTERVAL 1 ${aggregationInterval.toLowerCase()})) as ${aggregationTimestampColumn}`,
1218
+ `${aggregationExpression} as ${aggregationColumn}, ${AggregateUtil.buildBucketTimestampSelect(aggregationInterval, aggregationTimestampColumn)}`,
1216
1219
  );
1217
1220
 
1218
1221
  const columns: Array<string> = [
@@ -1,4 +1,17 @@
1
1
  import Dictionary from "../../../Types/Dictionary";
2
+ import {
3
+ LlmAgentNameAttributeKeys,
4
+ LlmAttributeNamespacePrefixes,
5
+ LlmCostAttributeKeys,
6
+ LlmInputTokenAttributeKeys,
7
+ LlmOperationAttributeKeys,
8
+ LlmOutputTokenAttributeKeys,
9
+ LlmRequestModelAttributeKeys,
10
+ LlmResponseModelAttributeKeys,
11
+ LlmSystemAttributeKeys,
12
+ LlmToolNameAttributeKeys,
13
+ LlmTotalTokenAttributeKeys,
14
+ } from "../../../Types/Telemetry/LlmConventions";
2
15
  import { AttributeType } from "./Telemetry";
3
16
 
4
17
  /*
@@ -7,11 +20,9 @@ import { AttributeType } from "./Telemetry";
7
20
  * OneUptime ingests OpenTelemetry spans generically. To make LLM and agent
8
21
  * telemetry a first-class signal (filterable lists, token/cost/latency
9
22
  * rollups) we denormalize a small set of values out of the span attributes at
10
- * ingest time. We recognize the OpenTelemetry GenAI semantic conventions
11
- * (gen_ai.*) as primary, with cheap fallbacks for the two dominant
12
- * instrumentation libraries:
13
- * - OpenLLMetry / Traceloop (gen_ai.* + traceloop.*)
14
- * - OpenInference / Arize (llm.* + openinference.span.kind)
23
+ * ingest time. The set of recognized attribute keys lives in the shared
24
+ * Common/Types/Telemetry/LlmConventions module so this server-side extractor
25
+ * and the client-side display parser cannot drift out of sync.
15
26
  *
16
27
  * Prompt/completion CONTENT is intentionally NOT denormalized here — it stays
17
28
  * in the span's attributes/events map (already captured + scrubbed) and is
@@ -79,29 +90,19 @@ export default class LlmSpanUtil {
79
90
  return fields;
80
91
  }
81
92
 
82
- fields.llmSystem = this.getString(attributes, [
83
- "gen_ai.system",
84
- "gen_ai.provider.name",
85
- "llm.system",
86
- "llm.provider",
87
- ]);
88
-
89
- fields.llmOperation = this.getString(attributes, [
90
- "gen_ai.operation.name",
91
- "llm.request.type",
92
- "openinference.span.kind",
93
- ]);
94
-
95
- fields.llmRequestModel = this.getString(attributes, [
96
- "gen_ai.request.model",
97
- "llm.model_name",
98
- "llm.request.model",
99
- ]);
100
-
101
- fields.llmResponseModel = this.getString(attributes, [
102
- "gen_ai.response.model",
103
- "llm.response.model",
104
- ]);
93
+ fields.llmSystem = this.getString(attributes, LlmSystemAttributeKeys);
94
+
95
+ fields.llmOperation = this.getString(attributes, LlmOperationAttributeKeys);
96
+
97
+ fields.llmRequestModel = this.getString(
98
+ attributes,
99
+ LlmRequestModelAttributeKeys,
100
+ );
101
+
102
+ fields.llmResponseModel = this.getString(
103
+ attributes,
104
+ LlmResponseModelAttributeKeys,
105
+ );
105
106
 
106
107
  // Fall back to the response model when no request model was reported.
107
108
  if (!fields.llmRequestModel && fields.llmResponseModel) {
@@ -114,29 +115,15 @@ export default class LlmSpanUtil {
114
115
  * reject the row and fail the whole span batch.
115
116
  */
116
117
  fields.llmInputTokens = Math.trunc(
117
- this.getNumber(attributes, [
118
- "gen_ai.usage.input_tokens",
119
- "gen_ai.usage.prompt_tokens",
120
- "llm.token_count.prompt",
121
- "llm.usage.prompt_tokens",
122
- ]),
118
+ this.getNumber(attributes, LlmInputTokenAttributeKeys),
123
119
  );
124
120
 
125
121
  fields.llmOutputTokens = Math.trunc(
126
- this.getNumber(attributes, [
127
- "gen_ai.usage.output_tokens",
128
- "gen_ai.usage.completion_tokens",
129
- "llm.token_count.completion",
130
- "llm.usage.completion_tokens",
131
- ]),
122
+ this.getNumber(attributes, LlmOutputTokenAttributeKeys),
132
123
  );
133
124
 
134
125
  fields.llmTotalTokens = Math.trunc(
135
- this.getNumber(attributes, [
136
- "gen_ai.usage.total_tokens",
137
- "llm.token_count.total",
138
- "llm.usage.total_tokens",
139
- ]),
126
+ this.getNumber(attributes, LlmTotalTokenAttributeKeys),
140
127
  );
141
128
 
142
129
  // Derive total when only the parts were reported.
@@ -147,22 +134,11 @@ export default class LlmSpanUtil {
147
134
  fields.llmTotalTokens = fields.llmInputTokens + fields.llmOutputTokens;
148
135
  }
149
136
 
150
- fields.llmCost = this.getNumber(attributes, [
151
- "gen_ai.usage.cost",
152
- "gen_ai.usage.cost_usd",
153
- "gen_ai.usage.total_cost",
154
- "llm.usage.total_cost",
155
- ]);
137
+ fields.llmCost = this.getNumber(attributes, LlmCostAttributeKeys);
156
138
 
157
- fields.llmAgentName = this.getString(attributes, [
158
- "gen_ai.agent.name",
159
- "agent.name",
160
- ]);
139
+ fields.llmAgentName = this.getString(attributes, LlmAgentNameAttributeKeys);
161
140
 
162
- fields.llmToolName = this.getString(attributes, [
163
- "gen_ai.tool.name",
164
- "tool.name",
165
- ]);
141
+ fields.llmToolName = this.getString(attributes, LlmToolNameAttributeKeys);
166
142
 
167
143
  fields.isLlmSpan = this.detectIsLlmSpan(keys, fields);
168
144
 
@@ -187,11 +163,9 @@ export default class LlmSpanUtil {
187
163
 
188
164
  // Last-resort: any GenAI/LLM-namespaced attribute at all.
189
165
  return keys.some((key: string) => {
190
- return (
191
- key.startsWith("gen_ai.") ||
192
- key.startsWith("llm.") ||
193
- key.startsWith("traceloop.")
194
- );
166
+ return LlmAttributeNamespacePrefixes.some((prefix: string) => {
167
+ return key.startsWith(prefix);
168
+ });
195
169
  });
196
170
  }
197
171