@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.
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.ts +1129 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +3 -1
- package/Server/Services/AnalyticsDatabaseService.ts +59 -7
- package/Server/Services/MetricService.ts +124 -28
- package/Server/Services/SpanService.ts +11 -0
- package/Server/Types/AnalyticsDatabase/AggregateBy.ts +39 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +8 -5
- package/Server/Utils/Telemetry/LlmSpan.ts +38 -64
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +84 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +167 -0
- package/Tests/Types/BaseDatabase/AggregationIntervalUtil.test.ts +40 -0
- package/Types/BaseDatabase/AggregateBy.ts +11 -1
- package/Types/BaseDatabase/AggregationInterval.ts +10 -0
- package/Types/BaseDatabase/AggregationIntervalUtil.ts +23 -0
- package/Types/Metrics/MetricFormulaConfigData.ts +5 -0
- package/Types/Metrics/MetricQueryConfigData.ts +19 -0
- package/Types/Telemetry/LlmConventions.ts +182 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +16 -2
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +31 -32
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +41 -17
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +38 -34
- package/UI/Components/Charts/ChartLibrary/SparkChart/SparkChart.tsx +4 -3
- package/UI/Components/Charts/ChartLibrary/Utils/ChartColors.ts +48 -10
- package/UI/Components/Charts/Line/LineChart.tsx +15 -2
- package/UI/Components/Detail/PlaceholderText.tsx +1 -14
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.js +384 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783937343400-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +3 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +43 -5
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +110 -23
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/SpanService.js +10 -0
- package/build/dist/Server/Services/SpanService.js.map +1 -1
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +32 -0
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +2 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js +14 -54
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
- package/build/dist/Types/BaseDatabase/AggregationInterval.js +10 -0
- package/build/dist/Types/BaseDatabase/AggregationInterval.js.map +1 -1
- package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js +13 -0
- package/build/dist/Types/BaseDatabase/AggregationIntervalUtil.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmConventions.js +149 -0
- package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -0
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +3 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +3 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +13 -9
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +19 -7
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +21 -9
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/SparkChart/SparkChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/ChartColors.js +26 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/ChartColors.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +3 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/PlaceholderText.js +1 -4
- package/build/dist/UI/Components/Detail/PlaceholderText.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import AnalyticsTableEngine from "../../../Types/AnalyticsDatabase/AnalyticsTabl
|
|
|
11
11
|
import AnalyticsTableColumn from "../../../Types/AnalyticsDatabase/TableColumn";
|
|
12
12
|
import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
|
|
13
13
|
import AggregationType from "../../../Types/BaseDatabase/AggregationType";
|
|
14
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
14
15
|
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
15
16
|
import GenericObject from "../../../Types/GenericObject";
|
|
16
17
|
import ObjectID from "../../../Types/ObjectID";
|
|
@@ -314,6 +315,89 @@ describe("AnalyticsDatabaseService", () => {
|
|
|
314
315
|
),
|
|
315
316
|
).rejects.toThrow("Invalid aggregationTimestampColumnName");
|
|
316
317
|
});
|
|
318
|
+
|
|
319
|
+
test("should reject an unrecognized aggregationInterval", async () => {
|
|
320
|
+
await expect(
|
|
321
|
+
service.aggregateBy(
|
|
322
|
+
makeAggregateBy({ aggregationInterval: "NotAnInterval" }),
|
|
323
|
+
),
|
|
324
|
+
).rejects.toThrow("Invalid aggregationInterval");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
* The base AnalyticsDatabaseService.toAggregateStatement is what every
|
|
330
|
+
* analytics model that does NOT override aggregation (Log, AuditLog, …)
|
|
331
|
+
* uses. Exercise its aggregationInterval / Total GROUP BY assembly here,
|
|
332
|
+
* since MetricService's own builders bypass it.
|
|
333
|
+
*/
|
|
334
|
+
describe("toAggregateStatement aggregationInterval (base path)", () => {
|
|
335
|
+
beforeEach(() => {
|
|
336
|
+
jest.spyOn(logger, "debug").mockImplementation(() => {
|
|
337
|
+
return undefined!;
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
afterEach(() => {
|
|
342
|
+
jest.restoreAllMocks();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const makeBaseAggregateBy: (overrides?: Record<string, unknown>) => any = (
|
|
346
|
+
overrides: Record<string, unknown> = {},
|
|
347
|
+
): any => {
|
|
348
|
+
return {
|
|
349
|
+
aggregationType: AggregationType.Sum,
|
|
350
|
+
aggregateColumnName: "column_2",
|
|
351
|
+
aggregationTimestampColumnName: "column_ObjectID",
|
|
352
|
+
startTimestamp: new Date("2024-01-01"),
|
|
353
|
+
endTimestamp: new Date("2024-01-02"),
|
|
354
|
+
query: {},
|
|
355
|
+
sort: { column_ObjectID: SortOrder.Ascending },
|
|
356
|
+
limit: 10,
|
|
357
|
+
skip: 0,
|
|
358
|
+
props: { isRoot: true },
|
|
359
|
+
...overrides,
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
test("derives the window bucket and groups by time when interval is omitted", () => {
|
|
364
|
+
const query: string = service.toAggregateStatement(makeBaseAggregateBy())
|
|
365
|
+
.statement.query;
|
|
366
|
+
|
|
367
|
+
// 1-day window derives an Hour bucket, grouped by the time column.
|
|
368
|
+
expect(query).toContain(
|
|
369
|
+
"date_trunc('hour', toStartOfInterval(column_ObjectID, INTERVAL 1 hour))",
|
|
370
|
+
);
|
|
371
|
+
expect(query).toContain("GROUP BY column_ObjectID");
|
|
372
|
+
expect(query).not.toContain("min(column_ObjectID)");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("Total with no group-by drops the time bucket and guards empty windows", () => {
|
|
376
|
+
const query: string = service.toAggregateStatement(
|
|
377
|
+
makeBaseAggregateBy({ aggregationInterval: "Total" }),
|
|
378
|
+
).statement.query;
|
|
379
|
+
|
|
380
|
+
expect(query).toContain("min(column_ObjectID) as column_ObjectID");
|
|
381
|
+
expect(query).not.toContain("date_trunc");
|
|
382
|
+
expect(query).not.toContain("GROUP BY");
|
|
383
|
+
expect(query).toContain("HAVING count() > 0");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("Total with a model-column group-by keeps the column, drops the time bucket", () => {
|
|
387
|
+
const query: string = service.toAggregateStatement(
|
|
388
|
+
makeBaseAggregateBy({
|
|
389
|
+
aggregationInterval: "Total",
|
|
390
|
+
groupBy: { column_1: true },
|
|
391
|
+
}),
|
|
392
|
+
).statement.query;
|
|
393
|
+
|
|
394
|
+
expect(query).toContain("min(column_ObjectID) as column_ObjectID");
|
|
395
|
+
expect(query).toContain("GROUP BY");
|
|
396
|
+
// The time column must not be a grouping key under Total.
|
|
397
|
+
expect(query).not.toContain("GROUP BY column_ObjectID");
|
|
398
|
+
// A grouped query never needs the empty-window guard.
|
|
399
|
+
expect(query).not.toContain("HAVING count() > 0");
|
|
400
|
+
});
|
|
317
401
|
});
|
|
318
402
|
|
|
319
403
|
describe("toDeleteStatement", () => {
|
|
@@ -3,6 +3,7 @@ import { MetricService } from "../../../Server/Services/MetricService";
|
|
|
3
3
|
import Metric from "../../../Models/AnalyticsModels/Metric";
|
|
4
4
|
import AggregateBy from "../../../Server/Types/AnalyticsDatabase/AggregateBy";
|
|
5
5
|
import { Statement } from "../../../Server/Utils/AnalyticsDatabase/Statement";
|
|
6
|
+
import AggregationInterval from "../../../Types/BaseDatabase/AggregationInterval";
|
|
6
7
|
import AggregationType from "../../../Types/BaseDatabase/AggregationType";
|
|
7
8
|
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
8
9
|
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
@@ -191,4 +192,170 @@ describe("MetricService aggregate statement generation", () => {
|
|
|
191
192
|
expect(query).toContain("GROUP BY time");
|
|
192
193
|
});
|
|
193
194
|
});
|
|
195
|
+
|
|
196
|
+
/*
|
|
197
|
+
* The `aggregationInterval` override lets callers pin the time bucket
|
|
198
|
+
* independent of the query window (the two scenarios from the customer
|
|
199
|
+
* thread: "daily uptime per host over a week" and "one weekly average
|
|
200
|
+
* per host"). The buildAggregateBy window is 5 minutes, so its
|
|
201
|
+
* window-derived interval is Minute — every assertion below proves the
|
|
202
|
+
* override wins over that default.
|
|
203
|
+
*/
|
|
204
|
+
describe("explicit aggregationInterval override", () => {
|
|
205
|
+
it("pins a Day bucket over the window-derived Minute (per-host daily)", () => {
|
|
206
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
207
|
+
aggregationInterval: AggregationInterval.Day,
|
|
208
|
+
groupByAttributeKeys: ["host.name"],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const query: string = getQuery(aggregateBy);
|
|
212
|
+
|
|
213
|
+
expect(query).toContain(
|
|
214
|
+
"date_trunc('day', toStartOfInterval(time, INTERVAL 1 day))",
|
|
215
|
+
);
|
|
216
|
+
expect(query).toContain("GROUP BY time, __attr_grp_0");
|
|
217
|
+
// The 5-minute window's default Minute bucket must NOT leak through.
|
|
218
|
+
expect(query).not.toContain("date_trunc('minute'");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("honors an explicit Day interval on the minute MV fast path", () => {
|
|
222
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
223
|
+
aggregationInterval: AggregationInterval.Day,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const query: string = getQuery(aggregateBy);
|
|
227
|
+
|
|
228
|
+
expect(query).toContain("MetricItemAggMV1m");
|
|
229
|
+
expect(query).toContain("date_trunc('day'");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("honors an explicit Day interval on the percentile path", () => {
|
|
233
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
234
|
+
aggregationType: AggregationType.P90,
|
|
235
|
+
aggregationInterval: AggregationInterval.Day,
|
|
236
|
+
groupByAttributeKeys: ["host.name"],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const query: string = getQuery(aggregateBy);
|
|
240
|
+
|
|
241
|
+
expect(query).toContain("quantileExactWeighted(0.9)");
|
|
242
|
+
expect(query).toContain("date_trunc('day'");
|
|
243
|
+
expect(query).toContain("GROUP BY time, __attr_grp_0");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reproduces the window-derived bucket SQL when omitted (regression)", () => {
|
|
247
|
+
/*
|
|
248
|
+
* Distribution hint forces the base scalar builder (skips the MVs).
|
|
249
|
+
* The 5-minute window must still derive a Minute bucket, unchanged.
|
|
250
|
+
*/
|
|
251
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy();
|
|
252
|
+
(
|
|
253
|
+
service as unknown as {
|
|
254
|
+
pointTypeHintByAggregate: WeakMap<AggregateBy<Metric>, string | null>;
|
|
255
|
+
}
|
|
256
|
+
).pointTypeHintByAggregate.set(aggregateBy, "Histogram");
|
|
257
|
+
|
|
258
|
+
const query: string = getQuery(aggregateBy);
|
|
259
|
+
|
|
260
|
+
expect(query).toContain(
|
|
261
|
+
"date_trunc('minute', toStartOfInterval(time, INTERVAL 1 minute))",
|
|
262
|
+
);
|
|
263
|
+
expect(query).toContain("GROUP BY time");
|
|
264
|
+
expect(query).not.toContain("min(time) as time");
|
|
265
|
+
expect(query).not.toContain("HAVING count() > 0");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/*
|
|
270
|
+
* `Total` = no time bucketing: the entire window collapses into a single
|
|
271
|
+
* aggregate per group. The bucket timestamp becomes `min(time)` (an
|
|
272
|
+
* aggregate, not a group key), so `time` must drop out of GROUP BY —
|
|
273
|
+
* and the clause disappears entirely when nothing else is grouped.
|
|
274
|
+
*/
|
|
275
|
+
describe("Total (whole-window) aggregation", () => {
|
|
276
|
+
it("collapses the window into one row per host (scalar Avg)", () => {
|
|
277
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
278
|
+
aggregationInterval: AggregationInterval.Total,
|
|
279
|
+
groupByAttributeKeys: ["host.name"],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const query: string = getQuery(aggregateBy);
|
|
283
|
+
|
|
284
|
+
expect(query).toContain("min(time) as time");
|
|
285
|
+
expect(query).not.toContain("date_trunc");
|
|
286
|
+
expect(query).toContain("GROUP BY __attr_grp_0");
|
|
287
|
+
expect(query).not.toContain("GROUP BY time");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("emits a single global aggregate with no GROUP BY when nothing is grouped", () => {
|
|
291
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
292
|
+
aggregationInterval: AggregationInterval.Total,
|
|
293
|
+
/*
|
|
294
|
+
* An attribute filter (not a host filter) blocks every MV and
|
|
295
|
+
* routes to the base scalar builder without a group-by.
|
|
296
|
+
*/
|
|
297
|
+
query: {
|
|
298
|
+
projectId: projectId,
|
|
299
|
+
name: "http.client.request.duration",
|
|
300
|
+
time: new InBetween(startDate, endDate),
|
|
301
|
+
attributes: { "oneuptime.service.name": "starship-web" },
|
|
302
|
+
} as unknown as AggregateBy<Metric>["query"],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const query: string = getQuery(aggregateBy);
|
|
306
|
+
|
|
307
|
+
expect(query).not.toContain("MetricItemAggMV1m");
|
|
308
|
+
expect(query).toContain("min(time) as time");
|
|
309
|
+
expect(query).not.toContain("GROUP BY");
|
|
310
|
+
// The phantom row an empty window would return is suppressed.
|
|
311
|
+
expect(query).toContain("HAVING count() > 0");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("falls back from the minute MV to the base table for Total", () => {
|
|
315
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
316
|
+
aggregationInterval: AggregationInterval.Total,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const query: string = getQuery(aggregateBy);
|
|
320
|
+
|
|
321
|
+
// The time-bucketed MV cannot serve a whole-window aggregation.
|
|
322
|
+
expect(query).not.toContain("MetricItemAggMV1m");
|
|
323
|
+
expect(query).toContain("min(time) as time");
|
|
324
|
+
expect(query).not.toContain("GROUP BY");
|
|
325
|
+
expect(query).toContain("HAVING count() > 0");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps a model-column groupBy while dropping the time bucket (Total)", () => {
|
|
329
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
330
|
+
aggregationInterval: AggregationInterval.Total,
|
|
331
|
+
groupBy: { metricPointType: true } as AggregateBy<Metric>["groupBy"],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const query: string = getQuery(aggregateBy);
|
|
335
|
+
|
|
336
|
+
expect(query).toContain("min(time) as time");
|
|
337
|
+
// The model column survives as the sole grouping key...
|
|
338
|
+
expect(query).toContain("GROUP BY metricPointType");
|
|
339
|
+
// ...and the time bucket is gone (it is now an aggregate).
|
|
340
|
+
expect(query).not.toContain("GROUP BY time");
|
|
341
|
+
expect(query).not.toContain("date_trunc");
|
|
342
|
+
// A grouped query never emits the empty-window guard.
|
|
343
|
+
expect(query).not.toContain("HAVING count() > 0");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("collapses the window into one row per host on the percentile path", () => {
|
|
347
|
+
const aggregateBy: AggregateBy<Metric> = buildAggregateBy({
|
|
348
|
+
aggregationType: AggregationType.P90,
|
|
349
|
+
aggregationInterval: AggregationInterval.Total,
|
|
350
|
+
groupByAttributeKeys: ["host.name"],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const query: string = getQuery(aggregateBy);
|
|
354
|
+
|
|
355
|
+
expect(query).toContain("quantileExactWeighted(0.9)");
|
|
356
|
+
expect(query).toContain("min(time) as time");
|
|
357
|
+
expect(query).toContain("GROUP BY __attr_grp_0");
|
|
358
|
+
expect(query).not.toContain("date_trunc");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
194
361
|
});
|
|
@@ -53,6 +53,34 @@ describe("AggregationIntervalUtil", () => {
|
|
|
53
53
|
},
|
|
54
54
|
);
|
|
55
55
|
|
|
56
|
+
test("an explicit override wins over the window-derived interval", () => {
|
|
57
|
+
// A 2-hour window derives Minute, but the override pins Day.
|
|
58
|
+
expect(
|
|
59
|
+
AggregationIntervalUtil.getAggregationIntervalForWindow({
|
|
60
|
+
...windowOf(2 * HOUR),
|
|
61
|
+
aggregationInterval: AggregationInterval.Day,
|
|
62
|
+
}),
|
|
63
|
+
).toBe(AggregationInterval.Day);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the Total override is returned verbatim (whole-window bucketing)", () => {
|
|
67
|
+
expect(
|
|
68
|
+
AggregationIntervalUtil.getAggregationIntervalForWindow({
|
|
69
|
+
...windowOf(14 * DAY),
|
|
70
|
+
aggregationInterval: AggregationInterval.Total,
|
|
71
|
+
}),
|
|
72
|
+
).toBe(AggregationInterval.Total);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an invalid override falls through to the window-derived interval", () => {
|
|
76
|
+
expect(
|
|
77
|
+
AggregationIntervalUtil.getAggregationIntervalForWindow({
|
|
78
|
+
...windowOf(2 * HOUR),
|
|
79
|
+
aggregationInterval: "NotAnInterval" as AggregationInterval,
|
|
80
|
+
}),
|
|
81
|
+
).toBe(AggregationInterval.Minute);
|
|
82
|
+
});
|
|
83
|
+
|
|
56
84
|
test("returns the widest interval as the window grows", () => {
|
|
57
85
|
const order: Array<AggregationInterval> = [
|
|
58
86
|
AggregationInterval.Minute,
|
|
@@ -105,6 +133,18 @@ describe("AggregationIntervalUtil", () => {
|
|
|
105
133
|
},
|
|
106
134
|
);
|
|
107
135
|
|
|
136
|
+
test("Total has no fixed width, so it maps to a very large sentinel > Year", () => {
|
|
137
|
+
expect(
|
|
138
|
+
AggregationIntervalUtil.getAggregationIntervalMs(
|
|
139
|
+
AggregationInterval.Total,
|
|
140
|
+
),
|
|
141
|
+
).toBeGreaterThan(
|
|
142
|
+
AggregationIntervalUtil.getAggregationIntervalMs(
|
|
143
|
+
AggregationInterval.Year,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
108
148
|
test("intervals are strictly increasing in width", () => {
|
|
109
149
|
const ordered: Array<AggregationInterval> = [
|
|
110
150
|
AggregationInterval.Minute,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import GroupBy from "../../Server/Types/Database/GroupBy";
|
|
2
2
|
import GenericObject from "../GenericObject";
|
|
3
|
+
import AggregationInterval from "./AggregationInterval";
|
|
3
4
|
import AggregationType from "./AggregationType";
|
|
4
5
|
import Query from "./Query";
|
|
5
6
|
import Sort from "./Sort";
|
|
@@ -7,7 +8,16 @@ import Sort from "./Sort";
|
|
|
7
8
|
export default interface AggregateBy<TBaseModel extends GenericObject> {
|
|
8
9
|
aggregateColumnName: keyof TBaseModel;
|
|
9
10
|
aggregationType: AggregationType;
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Optional explicit time-bucket size. When omitted (the default), the
|
|
13
|
+
* bucket size is derived from the query window (≤3h → minute, ≤7d →
|
|
14
|
+
* hour, ≤6w → day, …) — see AggregationIntervalUtil. Set this to pin a
|
|
15
|
+
* granularity independent of the window (e.g. `Day` buckets over a
|
|
16
|
+
* one-week window), or to `Total` to aggregate the whole window into a
|
|
17
|
+
* single value per group (no time bucketing). Invalid values are
|
|
18
|
+
* rejected with a 400 in AnalyticsDatabaseService.aggregateBy.
|
|
19
|
+
*/
|
|
20
|
+
aggregationInterval?: AggregationInterval | undefined;
|
|
11
21
|
aggregationTimestampColumnName: keyof TBaseModel;
|
|
12
22
|
startTimestamp: Date;
|
|
13
23
|
endTimestamp: Date;
|
|
@@ -5,6 +5,16 @@ export enum AggregationInterval {
|
|
|
5
5
|
Week = "Week",
|
|
6
6
|
Month = "Month",
|
|
7
7
|
Year = "Year",
|
|
8
|
+
/**
|
|
9
|
+
* No time bucketing: collapse the entire [startTimestamp, endTimestamp]
|
|
10
|
+
* window into a single aggregate per group (one total value over the
|
|
11
|
+
* whole range). Only meaningful as an explicit
|
|
12
|
+
* `AggregateBy.aggregationInterval` override — the window-derived
|
|
13
|
+
* interval picker never returns it. Builders emit one row per group with
|
|
14
|
+
* the earliest sample timestamp in the window as the bucket label (see
|
|
15
|
+
* AggregateUtil.buildBucketTimestampSelect).
|
|
16
|
+
*/
|
|
17
|
+
Total = "Total",
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
export default AggregationInterval;
|
|
@@ -16,7 +16,21 @@ export class AggregationIntervalUtil {
|
|
|
16
16
|
public static getAggregationIntervalForWindow(data: {
|
|
17
17
|
startDate: Date;
|
|
18
18
|
endDate: Date;
|
|
19
|
+
/**
|
|
20
|
+
* Explicit override. When set to a valid AggregationInterval it is
|
|
21
|
+
* returned verbatim (including `Total`), so callers can pin a bucket
|
|
22
|
+
* size independent of the window. Unknown/undefined falls through to
|
|
23
|
+
* the window-derived interval below.
|
|
24
|
+
*/
|
|
25
|
+
aggregationInterval?: AggregationInterval | undefined;
|
|
19
26
|
}): AggregationInterval {
|
|
27
|
+
if (
|
|
28
|
+
data.aggregationInterval &&
|
|
29
|
+
Object.values(AggregationInterval).includes(data.aggregationInterval)
|
|
30
|
+
) {
|
|
31
|
+
return data.aggregationInterval;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
const startDate: Date = OneUptimeDate.fromString(data.startDate);
|
|
21
35
|
const endDate: Date = OneUptimeDate.fromString(data.endDate);
|
|
22
36
|
|
|
@@ -65,6 +79,15 @@ export class AggregationIntervalUtil {
|
|
|
65
79
|
return 1000 * 60 * 60 * 24 * 30;
|
|
66
80
|
case AggregationInterval.Year:
|
|
67
81
|
return 1000 * 60 * 60 * 24 * 365;
|
|
82
|
+
case AggregationInterval.Total:
|
|
83
|
+
/*
|
|
84
|
+
* `Total` is a single whole-window bucket with no fixed width. The
|
|
85
|
+
* window-derived picker never returns it, so grid-reconstruction
|
|
86
|
+
* callers (heartbeat charts, XAxis) never see it. Return a very
|
|
87
|
+
* large finite width (~1000 years) so any defensive fixed-step
|
|
88
|
+
* grid walk emits a single slot instead of looping forever.
|
|
89
|
+
*/
|
|
90
|
+
return 1000 * 60 * 60 * 24 * 365 * 1000;
|
|
68
91
|
default:
|
|
69
92
|
return 1000 * 60;
|
|
70
93
|
}
|
|
@@ -8,4 +8,9 @@ export default interface MetricFormulaConfigData {
|
|
|
8
8
|
chartType?: MetricChartType | undefined;
|
|
9
9
|
warningThreshold?: number | undefined;
|
|
10
10
|
criticalThreshold?: number | undefined;
|
|
11
|
+
/*
|
|
12
|
+
* Optional user-chosen color for the formula's series, stored as a hex
|
|
13
|
+
* string (e.g. "#6366f1"). Unset = auto-assigned from the chart palette.
|
|
14
|
+
*/
|
|
15
|
+
color?: string | undefined;
|
|
11
16
|
}
|
|
@@ -31,6 +31,25 @@ export default interface MetricQueryConfigData {
|
|
|
31
31
|
| undefined;
|
|
32
32
|
warningThreshold?: number | undefined;
|
|
33
33
|
criticalThreshold?: number | undefined;
|
|
34
|
+
/*
|
|
35
|
+
* Optional user-chosen color for this query's series. Stored as a hex
|
|
36
|
+
* string (e.g. "#6366f1") — the preset swatches write their palette hex
|
|
37
|
+
* and the custom picker writes an arbitrary hex. When set, it becomes the
|
|
38
|
+
* lead color for this query's chart (single-series → the series color;
|
|
39
|
+
* grouped multi-series → the first series, with the default palette filling
|
|
40
|
+
* the rest). Unset = auto-assigned from the chart-type palette.
|
|
41
|
+
*/
|
|
42
|
+
color?: string | undefined;
|
|
43
|
+
/*
|
|
44
|
+
* Per-group color pins for GROUP-BY queries. Keyed by the individual
|
|
45
|
+
* "key=value" segment that MetricCharts' series renderer emits for a
|
|
46
|
+
* grouped series — e.g. { "service.name=api": "#f59e0b" }. Use "(unset)"
|
|
47
|
+
* as the value for null/empty groups to match the series name. A series
|
|
48
|
+
* whose composed name contains a pinned segment renders in that color
|
|
49
|
+
* (first matching segment wins for multi-key group-bys); unpinned series
|
|
50
|
+
* fall back to `color` (lead) then the chart-type palette. Stored as hex.
|
|
51
|
+
*/
|
|
52
|
+
colorsByGroup?: Record<string, string> | undefined;
|
|
34
53
|
/*
|
|
35
54
|
* When true, the post-aggregation series points are transformed into
|
|
36
55
|
* a per-second rate of change: `(value - previousValue) / Δt`. This is
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Single source of truth for the OpenTelemetry GenAI (gen_ai.*) semantic
|
|
3
|
+
* convention attribute keys OneUptime recognizes when it detects and
|
|
4
|
+
* denormalizes LLM / GenAI / agent telemetry, plus cheap fallbacks for the two
|
|
5
|
+
* dominant instrumentation libraries:
|
|
6
|
+
* - OpenLLMetry / Traceloop (gen_ai.* + traceloop.*)
|
|
7
|
+
* - OpenInference / Arize (llm.* + openinference.span.kind)
|
|
8
|
+
*
|
|
9
|
+
* Both the server-side ingest extractor
|
|
10
|
+
* (Common/Server/Utils/Telemetry/LlmSpan.ts) and the client-side display parser
|
|
11
|
+
* (App/FeatureSet/Dashboard/src/Utils/LlmSpanDisplay.ts) import these lists so
|
|
12
|
+
* the two cannot silently drift apart when a new attribute is added — add a
|
|
13
|
+
* newly recognized key HERE, once.
|
|
14
|
+
*
|
|
15
|
+
* Order matters: within each list the preferred convention comes first and the
|
|
16
|
+
* lookup helpers return the first key that is present.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Provider / system, e.g. "openai", "anthropic", "aws.bedrock".
|
|
20
|
+
export const LlmSystemAttributeKeys: Array<string> = [
|
|
21
|
+
"gen_ai.system",
|
|
22
|
+
"gen_ai.provider.name",
|
|
23
|
+
"llm.system",
|
|
24
|
+
"llm.provider",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Operation, e.g. "chat", "embeddings", "execute_tool", "invoke_agent".
|
|
28
|
+
export const LlmOperationAttributeKeys: Array<string> = [
|
|
29
|
+
"gen_ai.operation.name",
|
|
30
|
+
"llm.request.type",
|
|
31
|
+
"openinference.span.kind",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Model requested by the caller.
|
|
35
|
+
export const LlmRequestModelAttributeKeys: Array<string> = [
|
|
36
|
+
"gen_ai.request.model",
|
|
37
|
+
"llm.model_name",
|
|
38
|
+
"llm.request.model",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Model the provider actually served (often the resolved/pinned model).
|
|
42
|
+
export const LlmResponseModelAttributeKeys: Array<string> = [
|
|
43
|
+
"gen_ai.response.model",
|
|
44
|
+
"llm.response.model",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const LlmInputTokenAttributeKeys: Array<string> = [
|
|
48
|
+
"gen_ai.usage.input_tokens",
|
|
49
|
+
"gen_ai.usage.prompt_tokens",
|
|
50
|
+
"llm.token_count.prompt",
|
|
51
|
+
"llm.usage.prompt_tokens",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const LlmOutputTokenAttributeKeys: Array<string> = [
|
|
55
|
+
"gen_ai.usage.output_tokens",
|
|
56
|
+
"gen_ai.usage.completion_tokens",
|
|
57
|
+
"llm.token_count.completion",
|
|
58
|
+
"llm.usage.completion_tokens",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const LlmTotalTokenAttributeKeys: Array<string> = [
|
|
62
|
+
"gen_ai.usage.total_tokens",
|
|
63
|
+
"llm.token_count.total",
|
|
64
|
+
"llm.usage.total_tokens",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Cost in USD. Only populated when the SDK reports it (no built-in pricing).
|
|
68
|
+
export const LlmCostAttributeKeys: Array<string> = [
|
|
69
|
+
"gen_ai.usage.cost",
|
|
70
|
+
"gen_ai.usage.cost_usd",
|
|
71
|
+
"gen_ai.usage.total_cost",
|
|
72
|
+
"llm.usage.total_cost",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export const LlmAgentNameAttributeKeys: Array<string> = [
|
|
76
|
+
"gen_ai.agent.name",
|
|
77
|
+
"agent.name",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export const LlmToolNameAttributeKeys: Array<string> = [
|
|
81
|
+
"gen_ai.tool.name",
|
|
82
|
+
"tool.name",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* Request-parameter keys — surfaced only in the display panel, never
|
|
87
|
+
* denormalized to DB columns.
|
|
88
|
+
*/
|
|
89
|
+
export const LlmTemperatureAttributeKeys: Array<string> = [
|
|
90
|
+
"gen_ai.request.temperature",
|
|
91
|
+
"llm.request.temperature",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export const LlmMaxTokensAttributeKeys: Array<string> = [
|
|
95
|
+
"gen_ai.request.max_tokens",
|
|
96
|
+
"llm.request.max_tokens",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export const LlmTopPAttributeKeys: Array<string> = [
|
|
100
|
+
"gen_ai.request.top_p",
|
|
101
|
+
"llm.request.top_p",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export const LlmFinishReasonAttributeKeys: Array<string> = [
|
|
105
|
+
"gen_ai.response.finish_reasons",
|
|
106
|
+
"gen_ai.response.finish_reason",
|
|
107
|
+
"llm.response.finish_reason",
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* Attribute-key namespace prefixes. Any span carrying an attribute in one of
|
|
112
|
+
* these namespaces is treated as an LLM/GenAI span as a last resort.
|
|
113
|
+
*/
|
|
114
|
+
export const LlmAttributeNamespacePrefixes: Array<string> = [
|
|
115
|
+
"gen_ai.",
|
|
116
|
+
"llm.",
|
|
117
|
+
"traceloop.",
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* Indexed prompt/completion message conventions of the shape
|
|
122
|
+
* `${prefix}.${i}.${contentSuffix}` / `${prefix}.${i}.${roleSuffix}`. Used only
|
|
123
|
+
* by the display parser to reconstruct message content for rendering.
|
|
124
|
+
*/
|
|
125
|
+
export interface LlmIndexedMessageConvention {
|
|
126
|
+
prefix: string;
|
|
127
|
+
contentSuffix: string;
|
|
128
|
+
roleSuffix: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const LlmPromptIndexedMessageConventions: Array<LlmIndexedMessageConvention> =
|
|
132
|
+
[
|
|
133
|
+
// OpenLLMetry indexed prompts.
|
|
134
|
+
{ prefix: "gen_ai.prompt", contentSuffix: "content", roleSuffix: "role" },
|
|
135
|
+
// OpenInference indexed input messages.
|
|
136
|
+
{
|
|
137
|
+
prefix: "llm.input_messages",
|
|
138
|
+
contentSuffix: "message.content",
|
|
139
|
+
roleSuffix: "message.role",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
export const LlmCompletionIndexedMessageConventions: Array<LlmIndexedMessageConvention> =
|
|
144
|
+
[
|
|
145
|
+
// OpenLLMetry indexed completions.
|
|
146
|
+
{
|
|
147
|
+
prefix: "gen_ai.completion",
|
|
148
|
+
contentSuffix: "content",
|
|
149
|
+
roleSuffix: "role",
|
|
150
|
+
},
|
|
151
|
+
// OpenInference indexed output messages.
|
|
152
|
+
{
|
|
153
|
+
prefix: "llm.output_messages",
|
|
154
|
+
contentSuffix: "message.content",
|
|
155
|
+
roleSuffix: "message.role",
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
// JSON-encoded message-array attribute keys (checked in order).
|
|
160
|
+
export const LlmPromptJsonAttributeKeys: Array<string> = [
|
|
161
|
+
"gen_ai.input.messages",
|
|
162
|
+
"gen_ai.prompt",
|
|
163
|
+
"input.value",
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
export const LlmCompletionJsonAttributeKeys: Array<string> = [
|
|
167
|
+
"gen_ai.output.messages",
|
|
168
|
+
"gen_ai.completion",
|
|
169
|
+
"output.value",
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
// Span-event names carrying prompt/completion content.
|
|
173
|
+
export const LlmPromptEventNames: Array<string> = [
|
|
174
|
+
"gen_ai.system.message",
|
|
175
|
+
"gen_ai.user.message",
|
|
176
|
+
"gen_ai.tool.message",
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
export const LlmCompletionEventNames: Array<string> = [
|
|
180
|
+
"gen_ai.assistant.message",
|
|
181
|
+
"gen_ai.choice",
|
|
182
|
+
];
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { AreaChart } from "../ChartLibrary/AreaChart/AreaChart";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AvailableChartColorsKeys,
|
|
4
|
+
ChartColorValue,
|
|
5
|
+
} from "../ChartLibrary/Utils/ChartColors";
|
|
3
6
|
import React, {
|
|
4
7
|
FunctionComponent,
|
|
5
8
|
ReactElement,
|
|
@@ -51,6 +54,12 @@ export interface ComponentProps {
|
|
|
51
54
|
*/
|
|
52
55
|
anomalyBandLowerSeriesName?: string | undefined;
|
|
53
56
|
anomalyBandUpperSeriesName?: string | undefined;
|
|
57
|
+
/*
|
|
58
|
+
* Optional per-series color override (named palette keys or hex strings).
|
|
59
|
+
* When provided, replaces the default AreaChartPalette. Indexed by series
|
|
60
|
+
* position (index % length).
|
|
61
|
+
*/
|
|
62
|
+
colors?: Array<ChartColorValue> | undefined;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
export interface AreaInternalProps extends ComponentProps {
|
|
@@ -138,7 +147,11 @@ const AreaChartElement: FunctionComponent<AreaInternalProps> = (
|
|
|
138
147
|
tickGap={30}
|
|
139
148
|
index={"Time"}
|
|
140
149
|
categories={categories}
|
|
141
|
-
colors={
|
|
150
|
+
colors={
|
|
151
|
+
props.colors && props.colors.length > 0
|
|
152
|
+
? props.colors
|
|
153
|
+
: AreaChartPalette
|
|
154
|
+
}
|
|
142
155
|
valueFormatter={props.yAxis.options.formatter || undefined}
|
|
143
156
|
showTooltip={true}
|
|
144
157
|
showLegend={props.showLegend !== false}
|