mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-07 12:00:42 +01:00
Compare commits
1 Commits
nv/heatmap
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0951a98fcb |
@@ -3064,79 +3064,6 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3489,7 +3416,6 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -3505,7 +3431,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3516,7 +3441,6 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3530,18 +3454,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -7073,7 +6985,10 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7088,51 +7003,12 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
Querybuildertypesv5Bucket:
|
||||
properties:
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptions:
|
||||
discriminator:
|
||||
mapping:
|
||||
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLinear:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLog:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketsKind:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
Querybuildertypesv5BuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7313,16 +7189,6 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7330,12 +7196,6 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7794,8 +7654,6 @@ components:
|
||||
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
|
||||
and ClickHouse SQL queries.
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
compositeQuery:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5CompositeQuery'
|
||||
end:
|
||||
@@ -7895,7 +7753,6 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -7970,6 +7827,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
|
||||
@@ -601,18 +601,6 @@ export const listViewInitialLogQuery: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
|
||||
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
|
||||
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
|
||||
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
export const listViewInitialTraceQuery: Query = {
|
||||
// it should be the above commented query
|
||||
...initialQueriesMap.traces,
|
||||
|
||||
@@ -766,10 +766,15 @@ export function QueryBuilderProvider({
|
||||
queryItem.dataSource
|
||||
].builder.queryData;
|
||||
|
||||
propsRequired?.push('dataSource');
|
||||
propsRequired?.forEach((p: any) => {
|
||||
set(queryItem, p, get(newQueryItem, p));
|
||||
});
|
||||
// `dataSource` travels with the panel type's fields, but is appended to a
|
||||
// copy: `propsRequired` is the list held in
|
||||
// `panelTypeDataSourceFormValuesMap`, and pushing onto it grew that
|
||||
// module-level array by one entry on every call.
|
||||
if (propsRequired) {
|
||||
[...propsRequired, 'dataSource'].forEach((p: any) => {
|
||||
set(queryItem, p, get(newQueryItem, p));
|
||||
});
|
||||
}
|
||||
return queryItem;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,13 +211,11 @@ export enum QueryFunctionsTypes {
|
||||
FILL_ZERO = 'fillZero',
|
||||
}
|
||||
|
||||
export type PanelTypeKeys =
|
||||
| 'TIME_SERIES'
|
||||
| 'VALUE'
|
||||
| 'TABLE'
|
||||
| 'LIST'
|
||||
| 'TRACE'
|
||||
| 'EMPTY_WIDGET';
|
||||
/**
|
||||
* Key names of {@link PANEL_TYPES}. Derived rather than listed: the hand-written
|
||||
* version had fallen behind the enum by three members (`BAR`, `PIE`, `HISTOGRAM`).
|
||||
*/
|
||||
export type PanelTypeKeys = keyof typeof PANEL_TYPES;
|
||||
|
||||
export enum ReduceOperators {
|
||||
LAST = 'last',
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var embedded embed.FS
|
||||
|
||||
// diskAssets is preferred over the embedded copy when it exists, so editing the
|
||||
// UI and reloading the page needs no rebuild. It resolves against the launch
|
||||
// config's cwd, the repo root.
|
||||
const diskAssets = "heatmap-poc/static"
|
||||
|
||||
func main() {
|
||||
addr := envOr("HEATMAP_POC_ADDR", "localhost:8099")
|
||||
upstream, err := url.Parse(envOr("SIGNOZ_URL", "http://localhost:8080"))
|
||||
if err != nil {
|
||||
slog.Error("SIGNOZ_URL is not a URL", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("SIGNOZ_API_KEY")
|
||||
if apiKey == "" {
|
||||
slog.Warn("SIGNOZ_API_KEY is unset, every upstream call will be rejected as unauthenticated")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/", &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(upstream)
|
||||
r.Out.Host = upstream.Host
|
||||
r.Out.Header.Set("SIGNOZ-API-KEY", apiKey)
|
||||
},
|
||||
ErrorHandler: func(rw http.ResponseWriter, _ *http.Request, err error) {
|
||||
slog.Error("upstream call failed", "error", err)
|
||||
http.Error(rw, err.Error(), http.StatusBadGateway)
|
||||
},
|
||||
})
|
||||
mux.Handle("/", http.FileServerFS(assets()))
|
||||
|
||||
slog.Info("listening", "url", "http://"+addr, "upstream", upstream.String())
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
slog.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func assets() fs.FS {
|
||||
if stat, err := os.Stat(diskAssets); err == nil && stat.IsDir() {
|
||||
slog.Info("serving the UI from disk", "dir", diskAssets)
|
||||
return os.DirFS(diskAssets)
|
||||
}
|
||||
slog.Info("serving the embedded UI")
|
||||
sub, err := fs.Sub(embedded, "static")
|
||||
if err != nil {
|
||||
slog.Error("embedded assets are unreadable", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,788 +0,0 @@
|
||||
const TIME_AGGREGATIONS = ["latest", "sum", "avg", "min", "max", "count", "count_distinct", "rate", "increase"];
|
||||
const SPACE_AGGREGATIONS = ["sum", "avg", "min", "max", "count"];
|
||||
|
||||
// the types whose samples reach the reader as plain values, so a bucket axis has
|
||||
// to be chosen for them
|
||||
const VALUE_TYPES = new Set(["gauge", "sum", "summary"]);
|
||||
|
||||
// single hue, dark to light: a count's magnitude is the only thing it encodes
|
||||
const RAMP = ["#1c3557", "#22406c", "#284c82", "#2e5998", "#3668ae", "#4a80c4", "#689dd6", "#8dbbe6", "#b9d8f5"];
|
||||
const ZERO_FILL = "#0e1016";
|
||||
|
||||
const PAD = { left: 78, right: 8, top: 8, bottom: 22 };
|
||||
const MAX_CHART_HEIGHT = 560;
|
||||
const MAX_JSON_CHARS = 300_000;
|
||||
|
||||
const state = {
|
||||
mode: "metric",
|
||||
rows: [],
|
||||
groupBy: [],
|
||||
bucketKind: "default",
|
||||
colorScale: "linear",
|
||||
grid: null,
|
||||
emptyReason: "Run a query to draw the heatmap.",
|
||||
hidden: new Set(),
|
||||
requestText: "",
|
||||
responseText: "",
|
||||
};
|
||||
|
||||
const catalogue = new Map();
|
||||
const attributesByMetric = new Map();
|
||||
let nextRowId = 0;
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const esc = (value) => String(value).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
function timeWindow() {
|
||||
const end = Date.now();
|
||||
return { start: end - Number($("#range").value) * 60_000, end };
|
||||
}
|
||||
|
||||
function rowLetter(index) {
|
||||
return String.fromCharCode(65 + index);
|
||||
}
|
||||
|
||||
function typeSupport(type) {
|
||||
if (VALUE_TYPES.has(type)) {
|
||||
return { aggregations: true, buckets: true };
|
||||
}
|
||||
if (type === "histogram") {
|
||||
return { aggregations: false, buckets: false, note: "Read with increase/sum over its own le labels. Bucket options are rejected." };
|
||||
}
|
||||
if (type === "exponentialhistogram") {
|
||||
return { aggregations: false, buckets: false, bad: true, note: "Exponential histograms are not supported yet — this comes back 501." };
|
||||
}
|
||||
return { aggregations: false, buckets: false, bad: true, note: "No type is recorded for this metric, so no bucket axis can be chosen — this comes back 400." };
|
||||
}
|
||||
|
||||
function scaleHint(scale) {
|
||||
const perTwice = 2 ** scale;
|
||||
return perTwice >= 1 ? `${perTwice} bucket${perTwice === 1 ? "" : "s"} per 2x` : `1 bucket per ${2 ** -scale}x`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return value > 0 ? "∞" : "-∞";
|
||||
}
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
const magnitude = Math.abs(value);
|
||||
if (magnitude >= 1e6 || magnitude < 1e-3) {
|
||||
return value.toExponential(1).replace("e+", "e");
|
||||
}
|
||||
if (Number.isInteger(value)) {
|
||||
return String(value);
|
||||
}
|
||||
const text = value.toPrecision(magnitude >= 1 ? 4 : 3);
|
||||
return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
|
||||
}
|
||||
|
||||
function formatTime(ms, spanMs) {
|
||||
const at = new Date(ms);
|
||||
const clock = at.toTimeString().slice(0, 5);
|
||||
return spanMs > 24 * 3600_000 ? `${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")} ${clock}` : clock;
|
||||
}
|
||||
|
||||
/* ---------- upstream ---------- */
|
||||
|
||||
async function getJSON(path, params) {
|
||||
const response = await fetch(`${path}?${new URLSearchParams(params)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path} came back ${response.status}: ${(await response.text()).slice(0, 400)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function searchMetrics(searchText) {
|
||||
const { start, end } = timeWindow();
|
||||
const body = await getJSON("/api/v2/metrics", { start, end, limit: 60, searchText });
|
||||
const metrics = body?.data?.metrics ?? [];
|
||||
for (const metric of metrics) {
|
||||
catalogue.set(metric.metricName, metric);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function metricAttributes(metricName) {
|
||||
if (attributesByMetric.has(metricName)) {
|
||||
return attributesByMetric.get(metricName);
|
||||
}
|
||||
const { start, end } = timeWindow();
|
||||
const body = await getJSON("/api/v2/metrics/attributes", { metricName, start, end });
|
||||
const keys = (body?.data?.attributes ?? []).map((attribute) => attribute.key);
|
||||
attributesByMetric.set(metricName, keys);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/* ---------- metric rows ---------- */
|
||||
|
||||
function addRow() {
|
||||
state.rows.push({ id: nextRowId++, metric: "", type: "", timeAggregation: "max", spaceAggregation: "max", filter: "" });
|
||||
renderRows();
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const host = $("#rows");
|
||||
host.textContent = "";
|
||||
|
||||
state.rows.forEach((row, index) => {
|
||||
const node = $("#row-template").content.firstElementChild.cloneNode(true);
|
||||
const input = node.querySelector(".metric-input");
|
||||
const list = node.querySelector("datalist");
|
||||
const badge = node.querySelector(".badge");
|
||||
const aggregations = node.querySelector(".agg-fields");
|
||||
const note = node.querySelector(".row-note");
|
||||
const listId = `metrics-${row.id}`;
|
||||
|
||||
node.querySelector(".row-name").textContent = rowLetter(index);
|
||||
list.id = listId;
|
||||
input.setAttribute("list", listId);
|
||||
input.value = row.metric;
|
||||
node.querySelector(".filter-input").value = row.filter;
|
||||
node.querySelector(".remove-row").hidden = state.mode !== "formula" || state.rows.length < 2;
|
||||
|
||||
const support = typeSupport(row.type);
|
||||
if (row.metric) {
|
||||
badge.hidden = false;
|
||||
badge.textContent = row.type || "no type";
|
||||
badge.classList.toggle("bad", Boolean(support.bad));
|
||||
aggregations.hidden = !support.aggregations;
|
||||
note.hidden = !support.note;
|
||||
note.textContent = support.note ?? "";
|
||||
note.classList.toggle("warn", Boolean(support.bad));
|
||||
}
|
||||
|
||||
for (const [select, options, chosen] of [
|
||||
[node.querySelector(".time-agg"), TIME_AGGREGATIONS, row.timeAggregation],
|
||||
[node.querySelector(".space-agg"), SPACE_AGGREGATIONS, row.spaceAggregation],
|
||||
]) {
|
||||
select.innerHTML = options.map((option) => `<option value="${option}"${option === chosen ? " selected" : ""}>${option}</option>`).join("");
|
||||
}
|
||||
|
||||
const fillOptions = async () => {
|
||||
try {
|
||||
const metrics = await searchMetrics(input.value.trim());
|
||||
list.innerHTML = metrics.map((metric) => `<option value="${esc(metric.metricName)}" label="${esc(metric.type || "no type")}"></option>`).join("");
|
||||
} catch (error) {
|
||||
showBanner(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
let searchTimer = 0;
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(fillOptions, 220);
|
||||
});
|
||||
input.addEventListener("focus", () => {
|
||||
if (!list.children.length) {
|
||||
fillOptions();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("change", async () => {
|
||||
const name = input.value.trim();
|
||||
row.metric = name;
|
||||
row.type = catalogue.get(name)?.type ?? "";
|
||||
if (name && !catalogue.has(name)) {
|
||||
try {
|
||||
await searchMetrics(name);
|
||||
row.type = catalogue.get(name)?.type ?? "";
|
||||
} catch (error) {
|
||||
showBanner(error.message);
|
||||
}
|
||||
}
|
||||
renderRows();
|
||||
refreshPanes();
|
||||
refreshGroupOptions();
|
||||
});
|
||||
|
||||
node.querySelector(".time-agg").addEventListener("change", (event) => {
|
||||
row.timeAggregation = event.target.value;
|
||||
});
|
||||
node.querySelector(".space-agg").addEventListener("change", (event) => {
|
||||
row.spaceAggregation = event.target.value;
|
||||
});
|
||||
node.querySelector(".filter-input").addEventListener("change", (event) => {
|
||||
row.filter = event.target.value.trim();
|
||||
});
|
||||
node.querySelector(".remove-row").addEventListener("click", () => {
|
||||
state.rows = state.rows.filter((candidate) => candidate.id !== row.id);
|
||||
renderRows();
|
||||
refreshPanes();
|
||||
refreshGroupOptions();
|
||||
});
|
||||
|
||||
host.append(node);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- group by ---------- */
|
||||
|
||||
function renderGroupChips() {
|
||||
$("#group-chips").innerHTML = state.groupBy
|
||||
.map((key) => `<span class="chip">${esc(key)}<button type="button" data-key="${esc(key)}" title="Remove">×</button></span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function refreshGroupOptions() {
|
||||
const metrics = state.rows.map((row) => row.metric).filter(Boolean);
|
||||
const hint = $("#group-hint");
|
||||
if (!metrics.length) {
|
||||
$("#group-options").innerHTML = "";
|
||||
hint.hidden = false;
|
||||
hint.textContent = "Pick a metric to load its attributes.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = new Set();
|
||||
for (const row of state.rows.filter((candidate) => candidate.metric)) {
|
||||
for (const key of await metricAttributes(row.metric)) {
|
||||
// the builder strips `le` from a histogram's group by and reads the
|
||||
// bucket axis off it instead, so offering it here would do nothing
|
||||
if (key !== "le" || row.type !== "histogram") {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const available = [...keys].filter((key) => !state.groupBy.includes(key)).sort();
|
||||
$("#group-options").innerHTML = available.map((key) => `<option value="${esc(key)}"></option>`).join("");
|
||||
hint.hidden = available.length > 0;
|
||||
hint.textContent = available.length ? "" : "No further attributes on the selected metrics in this window.";
|
||||
} catch (error) {
|
||||
showBanner(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
|
||||
// A formula is bucketed from its own output, so its inputs may be any type. In
|
||||
// metric mode the one metric decides, and an unpicked one keeps the pane up.
|
||||
function bucketsAllowed() {
|
||||
if (state.mode === "promql") {
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "formula") {
|
||||
return true;
|
||||
}
|
||||
const row = state.rows[0];
|
||||
return !row?.metric || typeSupport(row.type).buckets;
|
||||
}
|
||||
|
||||
function refreshPanes() {
|
||||
const isPromql = state.mode === "promql";
|
||||
const isFormula = state.mode === "formula";
|
||||
|
||||
$("#builder-pane").hidden = isPromql;
|
||||
$("#promql-pane").hidden = !isPromql;
|
||||
$("#add-row").hidden = !isFormula;
|
||||
$("#formula-field").hidden = !isFormula;
|
||||
|
||||
const allowed = bucketsAllowed();
|
||||
$("#bucket-pane").hidden = !allowed;
|
||||
if (!allowed) {
|
||||
setBucketKind("default");
|
||||
}
|
||||
}
|
||||
|
||||
function setBucketKind(kind) {
|
||||
state.bucketKind = kind;
|
||||
for (const button of $("#bucket-kinds").children) {
|
||||
button.classList.toggle("on", button.dataset.kind === kind);
|
||||
}
|
||||
$("#bucket-default-hint").hidden = kind !== "default";
|
||||
$("#scale-field").hidden = kind !== "log";
|
||||
$("#linear-fields").hidden = kind !== "linear";
|
||||
$("#scale-hint").textContent = scaleHint(Number($("#scale").value));
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
state.mode = mode;
|
||||
for (const button of $("#modes").children) {
|
||||
button.classList.toggle("on", button.dataset.mode === mode);
|
||||
}
|
||||
if (mode === "metric") {
|
||||
state.rows = state.rows.slice(0, 1);
|
||||
}
|
||||
if (!state.rows.length) {
|
||||
addRow();
|
||||
}
|
||||
if (mode === "formula" && state.rows.length < 2) {
|
||||
addRow();
|
||||
}
|
||||
renderRows();
|
||||
refreshPanes();
|
||||
refreshGroupOptions();
|
||||
}
|
||||
|
||||
/* ---------- request ---------- */
|
||||
|
||||
function buildRequest() {
|
||||
const { start, end } = timeWindow();
|
||||
const step = Number($("#step").value) || 60;
|
||||
const request = {
|
||||
schemaVersion: "v1",
|
||||
start,
|
||||
end,
|
||||
requestType: "heatmap",
|
||||
compositeQuery: { queries: [] },
|
||||
formatOptions: { formatTableResultForUI: false, fillGaps: false },
|
||||
noCache: $("#no-cache").checked,
|
||||
};
|
||||
|
||||
if (state.mode === "promql") {
|
||||
const query = $("#promql").value.trim();
|
||||
if (!query) {
|
||||
throw new Error("Enter a PromQL query.");
|
||||
}
|
||||
request.compositeQuery.queries.push({ type: "promql", spec: { name: "A", query, step, disabled: false } });
|
||||
return request;
|
||||
}
|
||||
|
||||
const inFormula = state.mode === "formula";
|
||||
state.rows.forEach((row, index) => {
|
||||
if (!row.metric) {
|
||||
throw new Error(`Query ${rowLetter(index)} has no metric selected.`);
|
||||
}
|
||||
const histogram = row.type === "histogram";
|
||||
const spec = {
|
||||
name: rowLetter(index),
|
||||
signal: "metrics",
|
||||
aggregations: [
|
||||
{
|
||||
metricName: row.metric,
|
||||
timeAggregation: histogram ? "increase" : row.timeAggregation,
|
||||
spaceAggregation: histogram ? "sum" : row.spaceAggregation,
|
||||
},
|
||||
],
|
||||
stepInterval: step,
|
||||
// only the enabled query renders the heatmap, so a formula's inputs ride
|
||||
// along disabled
|
||||
disabled: inFormula,
|
||||
};
|
||||
if (state.groupBy.length) {
|
||||
spec.groupBy = state.groupBy.map((name) => ({ name }));
|
||||
}
|
||||
if (row.filter) {
|
||||
spec.filter = { expression: row.filter };
|
||||
}
|
||||
request.compositeQuery.queries.push({ type: "builder_query", spec });
|
||||
});
|
||||
|
||||
if (inFormula) {
|
||||
const expression = $("#formula").value.trim();
|
||||
if (!expression) {
|
||||
throw new Error("A formula heatmap needs an expression.");
|
||||
}
|
||||
request.compositeQuery.queries.push({ type: "builder_formula", spec: { name: "F1", expression, disabled: false } });
|
||||
}
|
||||
|
||||
if (state.bucketKind === "log") {
|
||||
request.bucketOptions = { kind: "log", spec: { scale: Number($("#scale").value) } };
|
||||
} else if (state.bucketKind === "linear") {
|
||||
request.bucketOptions = { kind: "linear", spec: { maxValue: Number($("#max-value").value), numBuckets: Number($("#num-buckets").value) } };
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let request;
|
||||
try {
|
||||
request = buildRequest();
|
||||
} catch (error) {
|
||||
showBanner(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
state.window = { start: request.start, end: request.end };
|
||||
state.requestText = JSON.stringify(request, null, "\t");
|
||||
renderJSON("#request", state.requestText);
|
||||
$("#run").disabled = true;
|
||||
$("#run").textContent = "Running…";
|
||||
showBanner("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v5/query_range", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
state.responseText = JSON.stringify(parsed, null, "\t");
|
||||
} catch {
|
||||
state.responseText = text;
|
||||
}
|
||||
renderJSON("#response", state.responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
const problem = parsed?.error;
|
||||
const detail = (problem?.errors ?? []).map((entry) => entry.message ?? JSON.stringify(entry)).join("\n");
|
||||
showBanner(`${response.status} ${problem?.code ?? ""}\n${problem?.message ?? text.slice(0, 600)}${detail ? `\n${detail}` : ""}`.trim());
|
||||
setGrid(null, "The request was rejected — see the message above.");
|
||||
return;
|
||||
}
|
||||
|
||||
const warning = parsed?.data?.warning;
|
||||
if (warning?.message) {
|
||||
showBanner([warning.message, ...(warning.warnings ?? []).map((entry) => entry.message)].join("\n"));
|
||||
}
|
||||
const grid = buildGrid(parsed);
|
||||
setGrid(grid, "The response carried no series, so there is nothing to draw.");
|
||||
} catch (error) {
|
||||
showBanner(error.message);
|
||||
setGrid(null, "The request could not be sent — see the message above.");
|
||||
} finally {
|
||||
$("#run").disabled = false;
|
||||
$("#run").textContent = "Run query";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- response ---------- */
|
||||
|
||||
function buildGrid(body) {
|
||||
const results = body?.data?.data?.results ?? [];
|
||||
const result = results.find((entry) => Array.isArray(entry?.aggregations) && entry.aggregations.length);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aggregation = result.aggregations[0];
|
||||
const buckets = aggregation.meta?.buckets ?? [];
|
||||
const rows = buckets.length + 1;
|
||||
const partial = new Set();
|
||||
|
||||
const series = (aggregation.series ?? []).map((entry) => {
|
||||
const labels = (entry.labels ?? []).map((label) => `${label.key?.name ?? "?"}=${label.value}`);
|
||||
const byTs = new Map();
|
||||
let total = 0;
|
||||
for (const point of entry.values ?? []) {
|
||||
const counts = point.values ?? [];
|
||||
byTs.set(point.timestamp, counts);
|
||||
total += counts.reduce((sum, count) => sum + count, 0);
|
||||
if (point.partial) {
|
||||
partial.add(point.timestamp);
|
||||
}
|
||||
}
|
||||
return { key: labels.join(", ") || "(no labels)", byTs, total };
|
||||
});
|
||||
|
||||
const timestamps = [...new Set(series.flatMap((entry) => [...entry.byTs.keys()]))].sort((a, b) => a - b);
|
||||
series.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
|
||||
|
||||
return { queryName: result.queryName, buckets, rows, timestamps, partial, series };
|
||||
}
|
||||
|
||||
function setGrid(grid, emptyReason) {
|
||||
state.grid = grid;
|
||||
state.emptyReason = emptyReason;
|
||||
state.hidden = new Set();
|
||||
renderGroups();
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function visibleSeries() {
|
||||
return state.grid.series.filter((entry) => !state.hidden.has(entry.key));
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const grid = state.grid;
|
||||
const card = $("#groups-card");
|
||||
if (!grid || grid.series.length < 2) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
card.hidden = false;
|
||||
$("#group-count").textContent = `${grid.series.length - state.hidden.size} of ${grid.series.length} shown`;
|
||||
$("#groups").innerHTML = grid.series
|
||||
.map(
|
||||
(entry) => `<label><input type="checkbox" data-key="${esc(entry.key)}"${state.hidden.has(entry.key) ? "" : " checked"}>
|
||||
<span class="name" title="${esc(entry.key)}">${esc(entry.key)}</span>
|
||||
<span class="total">${formatNumber(entry.total)}</span></label>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/* ---------- chart ---------- */
|
||||
|
||||
function bucketRange(grid, row) {
|
||||
if (row === grid.rows - 1) {
|
||||
return grid.buckets.length ? `> ${formatNumber(grid.buckets[grid.buckets.length - 1])}` : "overflow, the response carried no bucket bounds";
|
||||
}
|
||||
const upper = formatNumber(grid.buckets[row]);
|
||||
return row === 0 ? `<= ${upper}` : `(${formatNumber(grid.buckets[row - 1])}, ${upper}]`;
|
||||
}
|
||||
|
||||
function colorFor(count, max) {
|
||||
if (count <= 0) {
|
||||
return ZERO_FILL;
|
||||
}
|
||||
const fraction = max <= 0 ? 1 : state.colorScale === "log" ? Math.log1p(count) / Math.log1p(max) : count / max;
|
||||
return RAMP[Math.min(RAMP.length - 1, Math.max(0, Math.round(fraction * (RAMP.length - 1))))];
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const host = $("#chart");
|
||||
const legend = $("#legend");
|
||||
const grid = state.grid;
|
||||
legend.textContent = "";
|
||||
|
||||
if (!grid) {
|
||||
host.innerHTML = `<p class="empty">${esc(state.emptyReason)}</p>`;
|
||||
return;
|
||||
}
|
||||
if (!grid.timestamps.length) {
|
||||
host.innerHTML = '<p class="empty">The query returned no columns.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = grid.timestamps.length;
|
||||
const shown = visibleSeries();
|
||||
const matrix = grid.timestamps.map((ts) => {
|
||||
const column = new Array(grid.rows).fill(0);
|
||||
for (const entry of shown) {
|
||||
const counts = entry.byTs.get(ts);
|
||||
if (!counts) {
|
||||
continue;
|
||||
}
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
column[row] += counts[row] ?? 0;
|
||||
}
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
const max = Math.max(0, ...matrix.flat());
|
||||
// clientWidth carries the 12px padding on either side of #chart
|
||||
const available = host.clientWidth - 24 - PAD.left - PAD.right;
|
||||
const cellWidth = Math.max(3, available / columns);
|
||||
const cellHeight = Math.min(22, Math.max(4, MAX_CHART_HEIGHT / grid.rows));
|
||||
const plotWidth = cellWidth * columns;
|
||||
const plotHeight = cellHeight * grid.rows;
|
||||
const gap = cellWidth >= 7 && cellHeight >= 7 ? 1 : 0;
|
||||
const span = grid.timestamps[columns - 1] - grid.timestamps[0];
|
||||
|
||||
const cells = [];
|
||||
for (let column = 0; column < columns; column++) {
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
const x = PAD.left + column * cellWidth;
|
||||
const y = PAD.top + (grid.rows - 1 - row) * cellHeight;
|
||||
cells.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(cellWidth - gap).toFixed(2)}" height="${(cellHeight - gap).toFixed(2)}" fill="${colorFor(matrix[column][row], max)}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
const rowStride = Math.max(1, Math.ceil(13 / cellHeight));
|
||||
const rowLabels = [];
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
if (row % rowStride !== 0 && row !== grid.rows - 1) {
|
||||
continue;
|
||||
}
|
||||
const y = PAD.top + (grid.rows - 1 - row) * cellHeight + cellHeight / 2;
|
||||
const text = row === grid.rows - 1 ? "∞" : formatNumber(grid.buckets[row]);
|
||||
rowLabels.push(`<text class="axis-label" x="${PAD.left - 6}" y="${(y + 3.2).toFixed(2)}" text-anchor="end">${esc(text)}</text>`);
|
||||
}
|
||||
|
||||
const columnStride = Math.max(1, Math.ceil(58 / cellWidth));
|
||||
const columnLabels = [];
|
||||
for (let column = 0; column < columns; column += columnStride) {
|
||||
const ts = grid.timestamps[column];
|
||||
const x = PAD.left + column * cellWidth;
|
||||
columnLabels.push(`<text class="axis-label" x="${x.toFixed(2)}" y="${(PAD.top + plotHeight + 14).toFixed(2)}">${esc(formatTime(ts, span))}${grid.partial.has(ts) ? "*" : ""}</text>`);
|
||||
}
|
||||
|
||||
host.innerHTML = `<svg width="${PAD.left + plotWidth + PAD.right}" height="${PAD.top + plotHeight + PAD.bottom}">
|
||||
${cells.join("")}
|
||||
<line class="axis-line" x1="${PAD.left}" y1="${PAD.top + plotHeight + 0.5}" x2="${PAD.left + plotWidth}" y2="${PAD.top + plotHeight + 0.5}"/>
|
||||
${rowLabels.join("")}${columnLabels.join("")}
|
||||
<rect class="cursor-cell" hidden/>
|
||||
</svg>`;
|
||||
|
||||
// a heatmap cannot fill gaps, so a chart much shorter than the window asked
|
||||
// for means those columns hold no data at all rather than being hidden
|
||||
const asked = state.window ? state.window.end - state.window.start : span;
|
||||
const covered = `${formatTime(grid.timestamps[0], asked)}–${formatTime(grid.timestamps[columns - 1], asked)}`;
|
||||
const coverage =
|
||||
state.window && span < 0.9 * asked
|
||||
? `${covered}, the only columns with data in the ${formatTime(state.window.start, asked)}–${formatTime(state.window.end, asked)} requested`
|
||||
: covered;
|
||||
|
||||
legend.innerHTML = `<span>0</span>
|
||||
<div class="swatches"><div class="swatch" style="background:${ZERO_FILL};border:1px solid var(--line)"></div>${RAMP.map((color) => `<div class="swatch" style="background:${color}"></div>`).join("")}</div>
|
||||
<span>${formatNumber(max)} per cell</span>
|
||||
<span>· ${grid.rows} buckets × ${columns} columns · ${shown.length} of ${grid.series.length} series · ${esc(coverage)}${grid.partial.size ? " · * partial column" : ""}</span>`;
|
||||
|
||||
attachHover(host.querySelector("svg"), { grid, matrix, shown, columns, cellWidth, cellHeight, plotHeight, span, max });
|
||||
}
|
||||
|
||||
function attachHover(svg, view) {
|
||||
const tooltip = $("#tooltip");
|
||||
const cursor = svg.querySelector(".cursor-cell");
|
||||
|
||||
svg.addEventListener("mouseleave", () => {
|
||||
tooltip.hidden = true;
|
||||
cursor.setAttribute("hidden", "");
|
||||
});
|
||||
|
||||
svg.addEventListener("mousemove", (event) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const column = Math.floor((event.clientX - box.left - PAD.left) / view.cellWidth);
|
||||
const row = view.grid.rows - 1 - Math.floor((event.clientY - box.top - PAD.top) / view.cellHeight);
|
||||
if (column < 0 || column >= view.columns || row < 0 || row >= view.grid.rows) {
|
||||
tooltip.hidden = true;
|
||||
cursor.setAttribute("hidden", "");
|
||||
return;
|
||||
}
|
||||
|
||||
cursor.removeAttribute("hidden");
|
||||
cursor.setAttribute("x", PAD.left + column * view.cellWidth);
|
||||
cursor.setAttribute("y", PAD.top + (view.grid.rows - 1 - row) * view.cellHeight);
|
||||
cursor.setAttribute("width", view.cellWidth);
|
||||
cursor.setAttribute("height", view.cellHeight);
|
||||
|
||||
const ts = view.grid.timestamps[column];
|
||||
const breakdown = view.shown
|
||||
.map((entry) => ({ key: entry.key, count: entry.byTs.get(ts)?.[row] ?? 0 }))
|
||||
.filter((entry) => entry.count > 0)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
tooltip.innerHTML = [
|
||||
`<b>${esc(formatNumber(view.matrix[column][row]))}</b> in ${esc(bucketRange(view.grid, row))}`,
|
||||
`${esc(new Date(ts).toTimeString().slice(0, 8))}${view.grid.partial.has(ts) ? " (partial)" : ""}`,
|
||||
...breakdown.slice(0, 6).map((entry) => ` ${esc(entry.key)} ${esc(formatNumber(entry.count))}`),
|
||||
breakdown.length > 6 ? ` … ${breakdown.length - 6} more` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
tooltip.hidden = false;
|
||||
const width = tooltip.offsetWidth;
|
||||
tooltip.style.left = `${Math.min(event.clientX + 14, window.innerWidth - width - 8)}px`;
|
||||
tooltip.style.top = `${Math.min(event.clientY + 14, window.innerHeight - tooltip.offsetHeight - 8)}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- chrome ---------- */
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#banner");
|
||||
banner.textContent = message;
|
||||
banner.hidden = !message;
|
||||
}
|
||||
|
||||
function renderJSON(selector, text) {
|
||||
$(selector).textContent = text.length > MAX_JSON_CHARS ? `${text.slice(0, MAX_JSON_CHARS)}\n… truncated for display, Copy takes the whole thing` : text;
|
||||
}
|
||||
|
||||
function wire() {
|
||||
$("#modes").addEventListener("click", (event) => {
|
||||
if (event.target.dataset.mode) {
|
||||
setMode(event.target.dataset.mode);
|
||||
}
|
||||
});
|
||||
|
||||
$("#bucket-kinds").addEventListener("click", (event) => {
|
||||
if (event.target.dataset.kind) {
|
||||
setBucketKind(event.target.dataset.kind);
|
||||
}
|
||||
});
|
||||
|
||||
$("#scale").addEventListener("input", () => {
|
||||
$("#scale-hint").textContent = scaleHint(Number($("#scale").value));
|
||||
});
|
||||
|
||||
$("#add-row").addEventListener("click", () => {
|
||||
addRow();
|
||||
refreshPanes();
|
||||
});
|
||||
|
||||
$("#range").addEventListener("change", () => {
|
||||
attributesByMetric.clear();
|
||||
refreshGroupOptions();
|
||||
});
|
||||
|
||||
$("#group-input").addEventListener("change", (event) => {
|
||||
const key = event.target.value.trim();
|
||||
if (key && !state.groupBy.includes(key)) {
|
||||
state.groupBy.push(key);
|
||||
renderGroupChips();
|
||||
refreshGroupOptions();
|
||||
}
|
||||
event.target.value = "";
|
||||
});
|
||||
|
||||
$("#group-chips").addEventListener("click", (event) => {
|
||||
const key = event.target.dataset.key;
|
||||
if (key) {
|
||||
state.groupBy = state.groupBy.filter((candidate) => candidate !== key);
|
||||
renderGroupChips();
|
||||
refreshGroupOptions();
|
||||
}
|
||||
});
|
||||
|
||||
$("#form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
run();
|
||||
});
|
||||
|
||||
$("#color-scale").addEventListener("change", (event) => {
|
||||
state.colorScale = event.target.value;
|
||||
renderChart();
|
||||
});
|
||||
|
||||
$("#groups").addEventListener("change", (event) => {
|
||||
const key = event.target.dataset.key;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (event.target.checked) {
|
||||
state.hidden.delete(key);
|
||||
} else {
|
||||
state.hidden.add(key);
|
||||
}
|
||||
$("#group-count").textContent = `${state.grid.series.length - state.hidden.size} of ${state.grid.series.length} shown`;
|
||||
renderChart();
|
||||
});
|
||||
|
||||
$("#select-all").addEventListener("click", () => {
|
||||
state.hidden.clear();
|
||||
renderGroups();
|
||||
renderChart();
|
||||
});
|
||||
|
||||
$("#select-none").addEventListener("click", () => {
|
||||
state.hidden = new Set(state.grid.series.map((entry) => entry.key));
|
||||
renderGroups();
|
||||
renderChart();
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll("[data-copy]")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = button.dataset.copy === "request" ? state.requestText : state.responseText;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "Copied";
|
||||
} catch {
|
||||
button.textContent = "Copy failed";
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.textContent = "Copy";
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
let resizeTimer = 0;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(renderChart, 120);
|
||||
});
|
||||
}
|
||||
|
||||
wire();
|
||||
setMode("metric");
|
||||
renderGroupChips();
|
||||
setBucketKind("default");
|
||||
@@ -1,168 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatmap POC</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Heatmap POC</h1>
|
||||
<p class="sub"><code>POST /api/v5/query_range</code> with <code>requestType: "heatmap"</code></p>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<form id="form" class="card form">
|
||||
<div class="segmented" id="modes">
|
||||
<button type="button" data-mode="metric" class="on">Metric</button>
|
||||
<button type="button" data-mode="formula">Formula</button>
|
||||
<button type="button" data-mode="promql">PromQL</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<label>Time range
|
||||
<select id="range">
|
||||
<option value="15">Last 15 minutes</option>
|
||||
<option value="60" selected>Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
<option value="10080">Last 7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Step (seconds)
|
||||
<input id="step" type="number" min="1" step="1" value="60">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section id="builder-pane">
|
||||
<div id="rows"></div>
|
||||
<button type="button" id="add-row" class="ghost" hidden>+ Add metric</button>
|
||||
|
||||
<label id="formula-field" hidden>Formula
|
||||
<input id="formula" placeholder="A / B" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<div class="chips-field">
|
||||
<span class="chips-label">Group by</span>
|
||||
<div id="group-chips" class="chips"></div>
|
||||
<input id="group-input" list="group-options" placeholder="Add an attribute…" autocomplete="off">
|
||||
<datalist id="group-options"></datalist>
|
||||
<p class="hint" id="group-hint" hidden></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="promql-pane" hidden>
|
||||
<label>PromQL
|
||||
<textarea id="promql" rows="4" spellcheck="false" placeholder='sum by (le) (increase({__name__="http.server.duration.bucket"}[5m]))'></textarea>
|
||||
</label>
|
||||
<p class="hint">The bucket axis comes from the <code>le</code> labels the query returns, so bucket options are rejected here. A dotted metric name needs the <code>{__name__="…"}</code> form.</p>
|
||||
</section>
|
||||
|
||||
<fieldset id="bucket-pane">
|
||||
<legend>Bucket options</legend>
|
||||
<div class="segmented small" id="bucket-kinds">
|
||||
<button type="button" data-kind="default" class="on">Default</button>
|
||||
<button type="button" data-kind="log">Log</button>
|
||||
<button type="button" data-kind="linear">Linear</button>
|
||||
</div>
|
||||
<p class="hint" id="bucket-default-hint">Omitted from the request. The backend falls back to a log axis at scale 4.</p>
|
||||
<label id="scale-field" hidden>Scale
|
||||
<input id="scale" type="number" min="-4" max="4" step="1" value="4">
|
||||
<span class="hint" id="scale-hint"></span>
|
||||
</label>
|
||||
<div class="grid-2" id="linear-fields" hidden>
|
||||
<label>Max value
|
||||
<input id="max-value" type="number" min="0" step="any" value="1000">
|
||||
</label>
|
||||
<label>Number of buckets
|
||||
<input id="num-buckets" type="number" min="1" max="512" step="1" value="60">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="run-row">
|
||||
<button type="submit" id="run">Run query</button>
|
||||
<label class="inline"><input type="checkbox" id="no-cache" checked> Bypass cache</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="results">
|
||||
<p id="banner" hidden></p>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Heatmap</h2>
|
||||
<div class="head-tools">
|
||||
<label class="inline">Colour scale
|
||||
<select id="color-scale">
|
||||
<option value="linear" selected>Linear</option>
|
||||
<option value="log">Log</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart" class="chart"><p class="empty">Run a query to draw the heatmap.</p></div>
|
||||
<div id="legend" class="legend"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="groups-card" hidden>
|
||||
<div class="card-head">
|
||||
<h2>Groups <span id="group-count" class="count"></span></h2>
|
||||
<div class="head-tools">
|
||||
<button type="button" class="ghost" id="select-all">All</button>
|
||||
<button type="button" class="ghost" id="select-none">None</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="groups" class="groups"></div>
|
||||
</section>
|
||||
|
||||
<div class="grid-2 json-grid">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Request</h2>
|
||||
<button type="button" class="ghost" data-copy="request">Copy</button>
|
||||
</div>
|
||||
<pre id="request" class="json"></pre>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Response</h2>
|
||||
<button type="button" class="ghost" data-copy="response">Copy</button>
|
||||
</div>
|
||||
<pre id="response" class="json"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tooltip" class="tooltip" hidden></div>
|
||||
|
||||
<template id="row-template">
|
||||
<div class="metric-row">
|
||||
<span class="row-name"></span>
|
||||
<div class="row-body">
|
||||
<div class="metric-field">
|
||||
<input class="metric-input" placeholder="Search metrics…" autocomplete="off" spellcheck="false">
|
||||
<datalist></datalist>
|
||||
<span class="badge" hidden></span>
|
||||
</div>
|
||||
<div class="agg-fields" hidden>
|
||||
<label>Time
|
||||
<select class="time-agg"></select>
|
||||
</label>
|
||||
<label>Space
|
||||
<select class="space-agg"></select>
|
||||
</label>
|
||||
</div>
|
||||
<input class="filter-input" placeholder='Filter, e.g. service = "api"' autocomplete="off" spellcheck="false">
|
||||
<p class="row-note hint" hidden></p>
|
||||
</div>
|
||||
<button type="button" class="remove-row" title="Remove this metric">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,552 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--surface: #0b0d12;
|
||||
--card: #12151d;
|
||||
--card-head: #171b25;
|
||||
--line: #242a38;
|
||||
--line-soft: #1b2030;
|
||||
|
||||
--ink: #e6e9f0;
|
||||
--ink-soft: #a2abbd;
|
||||
--ink-faint: #6b7488;
|
||||
|
||||
--accent: #4a80c4;
|
||||
--accent-ink: #b9d8f5;
|
||||
--danger: #e0736b;
|
||||
--danger-bg: #2a1618;
|
||||
|
||||
--cell-zero: #0e1016;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* the display rules below would otherwise beat the UA's [hidden] rule, and SVG
|
||||
elements never honoured the attribute on their own */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card-head);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--ink-faint);
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ---------- form ---------- */
|
||||
|
||||
.form {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
label.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: auto;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
border-color: var(--line);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
button.ghost:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--ink-faint);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.segmented button.on {
|
||||
background: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.segmented button:hover {
|
||||
filter: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.segmented.small button {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.hint.warn {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.run-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.run-row button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- metric rows ---------- */
|
||||
|
||||
#rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
width: 20px;
|
||||
padding-top: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-ink);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
padding: 2px 6px;
|
||||
background: var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.bad {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.agg-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remove-row {
|
||||
padding: 2px 7px;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
border-color: transparent;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.remove-row:hover {
|
||||
color: var(--danger);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* ---------- group by chips ---------- */
|
||||
|
||||
.chips-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chips-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chips:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
background: var(--line);
|
||||
border-radius: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip button {
|
||||
padding: 0 3px;
|
||||
background: transparent;
|
||||
color: var(--ink-faint);
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chip button:hover {
|
||||
color: var(--danger);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* ---------- results ---------- */
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid #4a2427;
|
||||
border-radius: 8px;
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chart {
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
max-height: 620px;
|
||||
}
|
||||
|
||||
.chart svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 28px 0;
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
fill: var(--ink-faint);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.axis-line {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.cursor-cell {
|
||||
fill: none;
|
||||
stroke: var(--accent-ink);
|
||||
stroke-width: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px 12px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.legend .swatches {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.legend .swatch {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 2px 12px;
|
||||
padding: 10px 12px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.groups label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups .name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groups .total {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.json-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.json-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.json {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
max-width: 320px;
|
||||
padding: 7px 9px;
|
||||
background: #1c212e;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 45%);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tooltip b {
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -451,7 +451,7 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
// Merge values based on type
|
||||
var mergedValue any
|
||||
switch resultType {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
|
||||
// Raw and Scalar types are not cached, so no merge needed
|
||||
}
|
||||
@@ -476,36 +476,14 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
seriesMap := make(map[seriesKey]*qbtypes.TimeSeries, estimatedSeries)
|
||||
|
||||
decodedTimeSeriesData := make([]*qbtypes.TimeSeriesData, 0, len(buckets))
|
||||
|
||||
// Alias and Meta are taken from whichever cached bucket covers the latest
|
||||
// range, and the buckets do not arrive in StartMs order, so keep the winner
|
||||
// per AggregationBucket.Index alongside the StartMs that won it.
|
||||
aggregationIndexToLatest := map[int]*qbtypes.AggregationBucket{}
|
||||
aggregationIndexToLatestStartMs := map[int]uint64{}
|
||||
|
||||
for _, bucket := range buckets {
|
||||
var tsData *qbtypes.TimeSeriesData
|
||||
if err := json.Unmarshal(bucket.Value, &tsData); err != nil {
|
||||
bc.logger.ErrorContext(ctx, "failed to unmarshal time series data", errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
decodedTimeSeriesData = append(decodedTimeSeriesData, tsData)
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
if _, seen := aggregationIndexToLatest[aggBucket.Index]; !seen || bucket.StartMs >= aggregationIndexToLatestStartMs[aggBucket.Index] {
|
||||
aggregationIndexToLatest[aggBucket.Index] = aggBucket
|
||||
aggregationIndexToLatestStartMs[aggBucket.Index] = bucket.StartMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergedUpperBounds := qbtypes.MergeBucketUpperBounds(decodedTimeSeriesData...)
|
||||
|
||||
for _, tsData := range decodedTimeSeriesData {
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
// Create series key from labels
|
||||
key := seriesKey{
|
||||
@@ -578,15 +556,10 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
}
|
||||
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
result.Aggregations = append(result.Aggregations, &qbtypes.AggregationBucket{
|
||||
Index: index,
|
||||
Series: seriesList,
|
||||
}
|
||||
if latest, ok := aggregationIndexToLatest[index]; ok {
|
||||
aggBucket.Alias = latest.Alias
|
||||
aggBucket.Meta = latest.Meta
|
||||
}
|
||||
result.Aggregations = append(result.Aggregations, aggBucket)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -599,7 +572,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
// No aggregations at all means truly empty
|
||||
if len(tsData.Aggregations) == 0 {
|
||||
@@ -726,19 +699,14 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
// Trim time series data
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
|
||||
trimmedData := &qbtypes.TimeSeriesData{}
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
// Meta has to survive the trim: a heatmap's counts are
|
||||
// positional against Meta.Buckets, so a cached bucket that
|
||||
// lost its axis cannot be read back against anything.
|
||||
trimmedBucket := &qbtypes.AggregationBucket{
|
||||
Index: aggBucket.Index,
|
||||
Alias: aggBucket.Alias,
|
||||
Meta: aggBucket.Meta,
|
||||
}
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
@@ -798,7 +766,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
filteredData := &qbtypes.TimeSeriesData{
|
||||
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
|
||||
|
||||
@@ -92,10 +92,6 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
// A heatmap and a time series query can share every spec field and still
|
||||
// return different rows, so the request type has to separate their entries
|
||||
parts = append(parts, fmt.Sprintf("requestType=%s", q.kind.StringValue()))
|
||||
|
||||
// Add signal type
|
||||
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))
|
||||
|
||||
@@ -134,9 +130,6 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
}
|
||||
part += ":" + route
|
||||
}
|
||||
if a.HeatmapBucketing != nil {
|
||||
part += ":" + fingerprintHeatmapBucketing(*a.HeatmapBucketing)
|
||||
}
|
||||
aggParts = append(aggParts, part)
|
||||
}
|
||||
}
|
||||
@@ -192,16 +185,6 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// fingerprintHeatmapBucketing captures only what changes the rows ClickHouse
|
||||
// returns, which is why LogBucketsSpec.Scale is absent: coarsening it happens in
|
||||
// postprocessing, so every scale reads one cache entry.
|
||||
func fingerprintHeatmapBucketing(b qbtypes.HeatmapBucketing) string {
|
||||
if b.Kind == qbtypes.BucketsKindLinear {
|
||||
return fmt.Sprintf("%s:%v:%d", b.Kind.StringValue(), b.MaxValue, b.NumBuckets)
|
||||
}
|
||||
return b.Kind.StringValue()
|
||||
}
|
||||
|
||||
func fingerprintGroupByKey(gb qbtypes.GroupByKey) string {
|
||||
return fingerprintFieldKey(gb.TelemetryFieldKey)
|
||||
}
|
||||
@@ -429,7 +412,7 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
|
||||
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
|
||||
var value any
|
||||
switch kind {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
value = &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
value = &qbtypes.ScalarData{QueryName: queryName}
|
||||
@@ -482,9 +465,8 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
queryWindow := &qbtypes.TimeRange{From: q.fromMS, To: q.toMS}
|
||||
|
||||
kind := q.kind
|
||||
// all metric queries are time series then reduced if required, except
|
||||
// heatmaps, whose statement returns a row per bucket rather than per point
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics && kind != qbtypes.RequestTypeHeatmap {
|
||||
// all metric queries are time series then reduced if required
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics {
|
||||
kind = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -121,170 +120,6 @@ func TestBuilderQueryFingerprintQueryType(t *testing.T) {
|
||||
assert.Empty(t, ai.Fingerprint())
|
||||
}
|
||||
|
||||
func TestBuilderQueryFingerprintHeatmapBucketing(t *testing.T) {
|
||||
coarseLogScale := 1
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
left *builderQuery[qbtypes.MetricAggregation]
|
||||
right *builderQuery[qbtypes.MetricAggregation]
|
||||
expectedEqual bool
|
||||
}{
|
||||
{
|
||||
// fingerprintHeatmapBucketing leaves LogScale out, so the two are
|
||||
// indistinguishable here by design
|
||||
description: "a coarser logScale reads the same cache entry",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: true,
|
||||
},
|
||||
{
|
||||
description: "linear separates on maxValue",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 800, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear separates on numBuckets",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 40},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear and log are separate entries",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
if testCase.expectedEqual {
|
||||
assert.Equal(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
return
|
||||
}
|
||||
assert.NotEqual(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a coarser scale is carried but reads the same cache entry", func(t *testing.T) {
|
||||
finest := (&qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLog, Spec: qbtypes.LogBucketsSpec{}}).ToHeatmapBucketing()
|
||||
coarse := (&qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLog, Spec: qbtypes.LogBucketsSpec{Scale: &coarseLogScale}}).ToHeatmapBucketing()
|
||||
|
||||
assert.NotEqual(t, finest.LogScale, coarse.LogScale)
|
||||
assert.Equal(t, fingerprintHeatmapBucketing(finest), fingerprintHeatmapBucketing(coarse))
|
||||
})
|
||||
|
||||
t.Run("a histogram folds in no bucket options at all", func(t *testing.T) {
|
||||
// resolveHeatmapBucketing leaves histograms nil, so bucketOptions sent
|
||||
// alongside one must not fragment its cache
|
||||
histogram := &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.HistogramType,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
fingerprint := histogram.Fingerprint()
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLog.StringValue())
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLinear.StringValue())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeBucketsOrder(t *testing.T) {
|
||||
// Test that makeBuckets returns buckets in reverse chronological order by default
|
||||
// Using milliseconds as input - need > 1 hour range to get multiple buckets
|
||||
|
||||
@@ -31,11 +31,6 @@ var (
|
||||
// written clickhouse query. The column alias indcate which value is
|
||||
// to be considered as final result (or target).
|
||||
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
|
||||
|
||||
// userHeatmapBucketColumn is the alias a user written clickhouse query can
|
||||
// give its bucket upper bound column, alongside the HeatmapBucketColumn the
|
||||
// statement builder emits.
|
||||
userHeatmapBucketColumn = "bucket"
|
||||
)
|
||||
|
||||
// stripKeyAlias removes the __SELECT_KEY_<n>_ / __GROUP_BY_KEY_<n>_ prefix from a result
|
||||
@@ -88,8 +83,6 @@ func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.Ti
|
||||
payload, err = readAsTimeSeries(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
payload, err = readAsScalar(rows, queryName)
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
payload, err = readAsHeatmap(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeTrace, qbtypes.RequestTypeRawStream:
|
||||
payload, err = readAsRaw(rows, queryName)
|
||||
// TODO: add support for other request types
|
||||
@@ -119,6 +112,35 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
// Helper function to check if a timestamp represents a partial value
|
||||
isPartialValue := func(timestamp int64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Pre-allocate for labels based on column count
|
||||
lblValsCapacity := len(colNames) - 1 // -1 for timestamp
|
||||
if lblValsCapacity < 0 {
|
||||
@@ -249,7 +271,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Value: val,
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
Partial: isPartialValue(ts),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -293,120 +315,6 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isHeatmapBucketColumn(colName string) bool {
|
||||
name := stripKeyAlias(colName)
|
||||
return name == qbtypes.HeatmapBucketColumn || name == userHeatmapBucketColumn
|
||||
}
|
||||
|
||||
// readAsHeatmap folds one row per cell — (timestamp, group labels, bucket upper
|
||||
// bound, count) — into one series per group.
|
||||
func readAsHeatmap(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colNames := rows.Columns()
|
||||
|
||||
if !slices.ContainsFunc(colNames, isHeatmapBucketColumn) {
|
||||
// there is no heatmap bucket column so empty response is returned.
|
||||
return &qbtypes.TimeSeriesData{QueryName: queryName}, nil
|
||||
}
|
||||
|
||||
slots := make([]any, len(colTypes))
|
||||
for i, ct := range colTypes {
|
||||
slots[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(slots...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
ts int64
|
||||
upperBound float64
|
||||
count float64
|
||||
lblVals []string
|
||||
lblObjs []*qbtypes.Label
|
||||
)
|
||||
|
||||
for idx, ptr := range slots {
|
||||
name := stripKeyAlias(colNames[idx])
|
||||
value := derefValue(ptr)
|
||||
|
||||
if t, ok := value.(time.Time); ok {
|
||||
ts = t.UnixMilli()
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case qbtypes.HeatmapBucketColumn, userHeatmapBucketColumn:
|
||||
upperBound = numericAsFloat(value)
|
||||
default:
|
||||
if aggRe.MatchString(name) || slices.Contains(legacyReservedColumnTargetAliases, name) {
|
||||
count = numericAsFloat(value)
|
||||
continue
|
||||
}
|
||||
// a nullable label column comes back as a nil any, which would
|
||||
// otherwise key the series on the literal "<nil>"
|
||||
if value == nil {
|
||||
value = ""
|
||||
}
|
||||
lblVals = append(lblVals, fmt.Sprint(value))
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ts == 0 || !isValidBucketUpperBound(upperBound) || math.IsNaN(count) || math.IsInf(count, 0) {
|
||||
continue
|
||||
}
|
||||
sort.Strings(lblVals)
|
||||
labelsKey := strings.Join(lblVals, ",")
|
||||
|
||||
accumulator.addCell(labelsKey, lblObjs, ts, upperBound, count)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName), nil
|
||||
}
|
||||
|
||||
// isPartialValue reports whether the step interval starting at timestamp is only
|
||||
// partly covered by the query window, which happens when the window boundaries
|
||||
// are not step-aligned.
|
||||
func isPartialValue(timestamp int64, queryWindow *qbtypes.TimeRange, stepMs uint64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isNumericKind(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// heatmapColumn maps a bucket's upper bound to the count in it, holding one
|
||||
// timestamp's cells. Keyed rather than indexed by band because the axis is only
|
||||
// known once every cell has been seen.
|
||||
type heatmapColumn map[float64]float64
|
||||
|
||||
func isValidBucketUpperBound(upperBound float64) bool {
|
||||
return !math.IsNaN(upperBound) && !math.IsInf(upperBound, -1)
|
||||
}
|
||||
|
||||
// heatmapSeries accumulates one group's columns while the rows are read.
|
||||
type heatmapSeries struct {
|
||||
labels []*qbtypes.Label
|
||||
columnsByTimestamp map[int64]heatmapColumn
|
||||
}
|
||||
|
||||
// heatmapAccumulator collects cells from either reader and folds them into one
|
||||
// series per group.
|
||||
type heatmapAccumulator struct {
|
||||
seriesByKey map[string]*heatmapSeries
|
||||
seriesOrder []string
|
||||
upperBounds map[float64]struct{}
|
||||
}
|
||||
|
||||
func newHeatmapAccumulator() *heatmapAccumulator {
|
||||
return &heatmapAccumulator{
|
||||
seriesByKey: map[string]*heatmapSeries{},
|
||||
upperBounds: map[float64]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addCell files one cell under the group labelsKey identifies, keeping the
|
||||
// labels from the first cell seen for it.
|
||||
func (a *heatmapAccumulator) addCell(labelsKey string, lbls []*qbtypes.Label, ts int64, upperBound, count float64) {
|
||||
series, ok := a.seriesByKey[labelsKey]
|
||||
if !ok {
|
||||
series = &heatmapSeries{labels: lbls, columnsByTimestamp: map[int64]heatmapColumn{}}
|
||||
a.seriesByKey[labelsKey] = series
|
||||
a.seriesOrder = append(a.seriesOrder, labelsKey)
|
||||
}
|
||||
if series.columnsByTimestamp[ts] == nil {
|
||||
series.columnsByTimestamp[ts] = heatmapColumn{}
|
||||
}
|
||||
series.columnsByTimestamp[ts][upperBound] += count
|
||||
if !math.IsInf(upperBound, 1) {
|
||||
a.upperBounds[upperBound] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// foldSeries turns the collected cells into one series per group, in the order
|
||||
// the groups first appeared.
|
||||
func (a *heatmapAccumulator) foldSeries(queryWindow *qbtypes.TimeRange, stepMs uint64, queryName string) *qbtypes.TimeSeriesData {
|
||||
if len(a.seriesOrder) == 0 {
|
||||
return &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
}
|
||||
|
||||
upperBounds := make([]float64, 0, len(a.upperBounds))
|
||||
for upperBound := range a.upperBounds {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
// the index past the last upper bound is where the +Inf overflow lands
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds)+1)
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
upperBoundToIndex[math.Inf(1)] = len(upperBounds)
|
||||
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Buckets: upperBounds},
|
||||
Series: make([]*qbtypes.TimeSeries, 0, len(a.seriesOrder)),
|
||||
}
|
||||
|
||||
for _, labelsKey := range a.seriesOrder {
|
||||
accumulated := a.seriesByKey[labelsKey]
|
||||
|
||||
timestamps := make([]int64, 0, len(accumulated.columnsByTimestamp))
|
||||
for ts := range accumulated.columnsByTimestamp {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
slices.Sort(timestamps)
|
||||
|
||||
series := &qbtypes.TimeSeries{
|
||||
Labels: accumulated.labels,
|
||||
Values: make([]*qbtypes.TimeSeriesValue, 0, len(timestamps)),
|
||||
}
|
||||
for _, ts := range timestamps {
|
||||
values := make([]float64, len(upperBounds)+1)
|
||||
for upperBound, count := range accumulated.columnsByTimestamp[ts] {
|
||||
values[upperBoundToIndex[upperBound]] = count
|
||||
}
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Values: values,
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
})
|
||||
}
|
||||
bucket.Series = append(bucket.Series, series)
|
||||
}
|
||||
|
||||
return &qbtypes.TimeSeriesData{
|
||||
QueryName: queryName,
|
||||
Aggregations: []*qbtypes.AggregationBucket{bucket},
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
|
||||
// a log axis holds whichever bands the data reached, so a wide cached range
|
||||
// and a narrow fresh one routinely disagree on which bands exist
|
||||
cached := &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{1, 4, 16}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
fresh := []*qbtypes.Result{{
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000060000, Values: []float64{5, 6, 7}}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
|
||||
merged := (&querier{}).mergeTimeSeriesResults(cached, fresh)
|
||||
|
||||
require.Len(t, merged.Aggregations, 1)
|
||||
aggBucket := merged.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4, 16}, aggBucket.Meta.Buckets)
|
||||
|
||||
require.Len(t, aggBucket.Series, 1)
|
||||
require.Len(t, aggBucket.Series[0].Values, 2)
|
||||
// the cached 16 band survives even though the fresh range never reached it
|
||||
assert.Equal(t, []float64{1, 0, 2, 3, 4}, aggBucket.Series[0].Values[0].Values)
|
||||
// and the fresh 2 band survives even though the cached range never had it
|
||||
assert.Equal(t, []float64{0, 5, 6, 0, 7}, aggBucket.Series[0].Values[1].Values)
|
||||
}
|
||||
|
||||
func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
|
||||
cache := &bucketCache{logger: instrumentationtest.New().Logger()}
|
||||
|
||||
result := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeHeatmap,
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
|
||||
|
||||
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tsData.Aggregations, 1)
|
||||
|
||||
// the counts are positional against the axis, so a cached bucket that lost
|
||||
// Meta.Buckets would be realigned from an empty axis and collapse into the
|
||||
// overflow slot on the way back out
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4}, aggBucket.Meta.Buckets)
|
||||
assert.Equal(t, "By", aggBucket.Meta.Unit)
|
||||
assert.Equal(t, "__result_0", aggBucket.Alias)
|
||||
}
|
||||
|
||||
func TestRealignFromAnEmptyAxisCollapsesIntoTheOverflow(t *testing.T) {
|
||||
// pins the behaviour the trim bug exposed: with no axis to read the counts
|
||||
// against, everything lands in the overflow slot
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{7, 8, 9, 10}}},
|
||||
}},
|
||||
}
|
||||
|
||||
aggBucket.ReindexValuesToNewUpperBounds([]float64{1, 2, 4})
|
||||
|
||||
assert.Equal(t, []float64{0, 0, 0, 7}, aggBucket.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -195,16 +195,6 @@ func postProcessBuilderQuery[T any](
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveHeatmapBucketAxis brings the bucket axis to the resolution the caller
|
||||
// asked for. Downscaling runs first so AddHeatmapBucketsWithNoCounts adds them
|
||||
// at that resolution rather than the finer one ClickHouse bucketed at.
|
||||
func resolveHeatmapBucketAxis(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
if bucketing.Kind == qbtypes.BucketsKindLog {
|
||||
qbtypes.DownscaleHeatmapResolution(tsData, bucketing.LogScale)
|
||||
}
|
||||
qbtypes.AddHeatmapBucketsWithNoCounts(tsData, bucketing)
|
||||
}
|
||||
|
||||
// postProcessMetricQuery applies postprocessing to a metric query result.
|
||||
func postProcessMetricQuery(
|
||||
q *querier,
|
||||
@@ -226,12 +216,6 @@ func postProcessMetricQuery(
|
||||
}
|
||||
}
|
||||
|
||||
if req.RequestType == qbtypes.RequestTypeHeatmap && config.HeatmapBucketing != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, *config.HeatmapBucketing)
|
||||
}
|
||||
}
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -358,19 +342,6 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
// The queries a formula reads were run as time series, so what
|
||||
// arrives here is one value per group per timestamp.
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
bucketing := req.BucketOptions.ToHeatmapBucketing()
|
||||
bucketFormulaOutputAsHeatmap(tsData, bucketing)
|
||||
resolveHeatmapBucketAxis(tsData, bucketing)
|
||||
}
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
|
||||
@@ -439,89 +410,6 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return result
|
||||
}
|
||||
|
||||
func bucketFormulaOutputAsHeatmap(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
// A formula is one expression, so processTimeSeriesFormula gives it one
|
||||
// aggregation.
|
||||
if tsData == nil || len(tsData.Aggregations) == 0 || tsData.Aggregations[0] == nil {
|
||||
return
|
||||
}
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
|
||||
calculateUpperBound := calculateLogValueUpperBound
|
||||
if bucketing.Kind == qbtypes.BucketsKindLinear {
|
||||
calculateUpperBound = func(value float64) float64 {
|
||||
return calculateLinearValueUpperBound(bucketing, value)
|
||||
}
|
||||
}
|
||||
|
||||
// +Inf is the open-above overflow rather than an upper bound of its own, and
|
||||
// a NaN value has no bucket at all, so neither goes on the axis.
|
||||
upperBounds := []float64{}
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
if !math.IsNaN(upperBound) && !math.IsInf(upperBound, 0) {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
upperBounds = slices.Compact(upperBounds)
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds))
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
|
||||
overflowIndex := len(upperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
point.Values = make([]float64, overflowIndex+1)
|
||||
point.Value = 0
|
||||
switch {
|
||||
case math.IsNaN(upperBound):
|
||||
case math.IsInf(upperBound, 1):
|
||||
point.Values[overflowIndex] = 1
|
||||
default:
|
||||
point.Values[upperBoundToIndex[upperBound]] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggBucket.Meta.Buckets = upperBounds
|
||||
}
|
||||
|
||||
// calculateLinearValueUpperBound and calculateLogValueUpperBound are the Go side
|
||||
// of what renderLinearUpperBoundExpr and renderLogUpperBoundExpr emit, and have
|
||||
// to stay identical to them: a formula heatmap and a metric heatmap that
|
||||
// disagreed here would put their counts in different buckets.
|
||||
func calculateLinearValueUpperBound(bucketing qbtypes.HeatmapBucketing, value float64) float64 {
|
||||
if value > bucketing.MaxValue {
|
||||
return math.Inf(1)
|
||||
}
|
||||
numBuckets := float64(bucketing.NumBuckets)
|
||||
index := math.Min(math.Max(math.Ceil(value*numBuckets/bucketing.MaxValue), 1), numBuckets)
|
||||
return index * bucketing.MaxValue / numBuckets
|
||||
}
|
||||
|
||||
// Like renderLogUpperBoundExpr, this reads MaxLogScale rather than the requested
|
||||
// scale: ClickHouse buckets at the finest resolution and resolveHeatmapBucketAxis
|
||||
// folds the axis down afterwards.
|
||||
func calculateLogValueUpperBound(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value <= qbtypes.MinLogUpperBound {
|
||||
return qbtypes.MinLogUpperBound
|
||||
}
|
||||
if value > qbtypes.MaxLogUpperBound {
|
||||
return math.Inf(1)
|
||||
}
|
||||
bucketsPerDoubling := math.Exp2(qbtypes.MaxLogScale)
|
||||
return math.Exp2(math.Ceil(math.Log2(value)*bucketsPerDoubling) / bucketsPerDoubling)
|
||||
}
|
||||
|
||||
func (q *querier) processScalarFormula(
|
||||
ctx context.Context,
|
||||
results map[string]*qbtypes.Result,
|
||||
@@ -606,7 +494,7 @@ func (q *querier) processScalarFormula(
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: aggIdx,
|
||||
Alias: scalarData.Columns[colIdx].Name,
|
||||
Meta: qbtypes.AggregationMeta{Unit: scalarData.Columns[colIdx].Meta.Unit},
|
||||
Meta: scalarData.Columns[colIdx].Meta,
|
||||
Series: make([]*qbtypes.TimeSeries, 0),
|
||||
}
|
||||
|
||||
@@ -779,14 +667,13 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("__result_%d", agg.Index)
|
||||
}
|
||||
column := &qbtypes.ColumnDescriptor{
|
||||
columns = append(columns, &qbtypes.ColumnDescriptor{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
QueryName: queryName,
|
||||
AggregationIndex: int64(agg.Index),
|
||||
Meta: agg.Meta,
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
}
|
||||
column.Meta.Unit = agg.Meta.Unit
|
||||
columns = append(columns, column)
|
||||
})
|
||||
}
|
||||
|
||||
// Build rows.
|
||||
|
||||
@@ -50,7 +50,7 @@ func (q *querier) QueryRangePreview(
|
||||
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
|
||||
ps.Warnings = append(ps.Warnings, q.adjustStepInterval(env, req.Start, req.End)...)
|
||||
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End, req.RequestType, req.BucketOptions)
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End)
|
||||
if mErr != nil {
|
||||
// Report this query's error but keep previewing the rest.
|
||||
ps.Error = mErr
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// promHistogramBucketLabel is the label a classic histogram carries its
|
||||
// cumulative upper bound on.
|
||||
const promHistogramBucketLabel = "le"
|
||||
|
||||
// cumulativeColumn maps a bucket's upper bound to the cumulative count at it.
|
||||
// Differencing turns it into the per-band counts a heatmapColumn holds.
|
||||
type cumulativeColumn map[float64]float64
|
||||
|
||||
// promHeatmapGroup assembles one group across the several matrix series its `le`
|
||||
// values arrive as, since differencing needs all of them.
|
||||
type promHeatmapGroup struct {
|
||||
labels []*qbv5.Label
|
||||
labelsKey string
|
||||
cumulative map[int64]cumulativeColumn
|
||||
}
|
||||
|
||||
// foldMatrixAsHeatmap folds a matrix of one cumulative series per (group, `le`)
|
||||
// into one series per group whose points hold a count per band.
|
||||
func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, stepMs uint64, queryName string) *qbv5.TimeSeriesData {
|
||||
groups, groupOrder := collectCumulativeGroups(matrix)
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
for _, labelsKey := range groupOrder {
|
||||
groups[labelsKey].addDifferencedCells(accumulator)
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName)
|
||||
}
|
||||
|
||||
// collectCumulativeGroups reads the matrix into one group per label set. A series
|
||||
// without `le` has no band to sit in, so an expression that dropped the label
|
||||
// draws nothing.
|
||||
func collectCumulativeGroups(matrix promql.Matrix) (groups map[string]*promHeatmapGroup, groupOrder []string) {
|
||||
groups = map[string]*promHeatmapGroup{}
|
||||
|
||||
for _, promSeries := range matrix {
|
||||
upperBound, ok := extractBucketUpperBound(promSeries.Metric)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
lbls, labelsKey := extractHeatmapGroup(promSeries.Metric)
|
||||
group, ok := groups[labelsKey]
|
||||
if !ok {
|
||||
group = &promHeatmapGroup{labels: lbls, labelsKey: labelsKey, cumulative: map[int64]cumulativeColumn{}}
|
||||
groups[labelsKey] = group
|
||||
groupOrder = append(groupOrder, labelsKey)
|
||||
}
|
||||
|
||||
for _, point := range promSeries.Floats {
|
||||
// skipping widens the band above onto the next upper bound that has
|
||||
// a count, which is what lagInFrame does with an absent row
|
||||
if math.IsNaN(point.F) || math.IsInf(point.F, 0) {
|
||||
continue
|
||||
}
|
||||
if group.cumulative[point.T] == nil {
|
||||
group.cumulative[point.T] = cumulativeColumn{}
|
||||
}
|
||||
group.cumulative[point.T][upperBound] = point.F
|
||||
}
|
||||
}
|
||||
|
||||
return groups, groupOrder
|
||||
}
|
||||
|
||||
func extractBucketUpperBound(metric labels.Labels) (float64, bool) {
|
||||
raw := metric.Get(promHistogramBucketLabel)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
upperBound, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || !isValidBucketUpperBound(upperBound) {
|
||||
return 0, false
|
||||
}
|
||||
return upperBound, true
|
||||
}
|
||||
|
||||
// extractHeatmapGroup returns a series' group labels — everything but `le`.
|
||||
func extractHeatmapGroup(metric labels.Labels) ([]*qbv5.Label, string) {
|
||||
lbls := make([]*qbv5.Label, 0, metric.Len())
|
||||
pairs := make([]string, 0, metric.Len())
|
||||
|
||||
metric.Range(func(l labels.Label) {
|
||||
if l.Name == promHistogramBucketLabel || excludePromLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: l.Name},
|
||||
Value: l.Value,
|
||||
})
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", l.Name, l.Value))
|
||||
})
|
||||
|
||||
sort.Strings(pairs)
|
||||
return lbls, strings.Join(pairs, ",")
|
||||
}
|
||||
|
||||
// each cell is its upper bound's cumulative count minus the one below it.
|
||||
func (g *promHeatmapGroup) addDifferencedCells(accumulator *heatmapAccumulator) {
|
||||
for ts, cumulative := range g.cumulative {
|
||||
upperBounds := make([]float64, 0, len(cumulative))
|
||||
for upperBound := range cumulative {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
previous := float64(0)
|
||||
for _, upperBound := range upperBounds {
|
||||
accumulator.addCell(g.labelsKey, g.labels, ts, upperBound, math.Max(cumulative[upperBound]-previous, 0))
|
||||
previous = cumulative[upperBound]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The cache key is the fingerprint alone, so two request types over one
|
||||
// expression must not produce the same one — a time series payload served to a
|
||||
// heatmap request has no axis and reads back as a single collapsed band.
|
||||
func TestFingerprintSeparatesHeatmapFromTimeSeries(t *testing.T) {
|
||||
fingerprintFor := func(requestType qbv5.RequestType) string {
|
||||
q := &promqlQuery{
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
query: qbv5.PromQuery{Name: "A", Query: "sum by (le) (increase(signoz_latency_bucket[5m]))", Step: qbv5.Step{Duration: time.Minute}},
|
||||
tr: qbv5.TimeRange{From: 1710000000000, To: 1710003600000},
|
||||
requestType: requestType,
|
||||
}
|
||||
return q.Fingerprint()
|
||||
}
|
||||
|
||||
heatmap := fingerprintFor(qbv5.RequestTypeHeatmap)
|
||||
timeSeries := fingerprintFor(qbv5.RequestTypeTimeSeries)
|
||||
|
||||
assert.NotEmpty(t, heatmap, "a heatmap decomposes into time buckets like a time series")
|
||||
assert.NotEqual(t, timeSeries, heatmap)
|
||||
assert.Empty(t, fingerprintFor(qbv5.RequestTypeScalar), "a scalar result is its window's last point")
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapClampsADecreasingCumulativeCount(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 10}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: 4}},
|
||||
},
|
||||
}
|
||||
|
||||
data := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
// a cumulative count that went backwards would difference to -6
|
||||
assert.Equal(t, []float64{10, 0, 0}, data.Aggregations[0].Series[0].Values[0].Values)
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapWidensTheBandOverAMissingUpperBound(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 3}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: math.NaN()}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "20"),
|
||||
Floats: []promql.FPoint{{T: at, F: 30}},
|
||||
},
|
||||
}
|
||||
|
||||
data := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
aggregation := data.Aggregations[0]
|
||||
// 10 carried nothing to difference against, so it is not on the axis at all
|
||||
// and 20 differences against 5, holding what (5,10] and (10,20] would split
|
||||
assert.Equal(t, []float64{5, 20}, aggregation.Meta.Buckets)
|
||||
assert.Equal(t, []float64{3, 27, 0}, aggregation.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -155,10 +155,7 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
if q.opts.serve != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch q.requestType {
|
||||
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
|
||||
default:
|
||||
if q.requestType != qbv5.RequestTypeTimeSeries {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -169,8 +166,6 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
}
|
||||
parts := []string{
|
||||
"promql",
|
||||
// one expression returns a different shape per request type
|
||||
fmt.Sprintf("requestType=%s", q.requestType.StringValue()),
|
||||
query,
|
||||
q.query.Step.String(),
|
||||
}
|
||||
@@ -454,52 +449,25 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
}
|
||||
|
||||
// excludePromLabel hides only known SigNoz storage keys: label names are user
|
||||
// data and may legitimately start with "__" (e.g. __address__), so a blanket
|
||||
// dunder strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
func excludePromLabel(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
}
|
||||
|
||||
// collectExecStats snapshots the scan counters a query accumulated. Callers take
|
||||
// it at the point they are done with the matrix, so the duration covers the
|
||||
// shaping they did.
|
||||
func collectExecStats(began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) qbv5.ExecStats {
|
||||
statsMu.Lock()
|
||||
defer statsMu.Unlock()
|
||||
return qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
}
|
||||
|
||||
// toResult converts an evaluated matrix into the v5 result shape, attaching
|
||||
// the ClickHouse scan stats accumulated during evaluation.
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
if q.requestType == qbv5.RequestTypeHeatmap {
|
||||
return q.toResultForHeatmap(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
|
||||
// Hide only known SigNoz storage keys: label names are user data and may
|
||||
// legitimately start with "__" (e.g. __address__), so a blanket dunder
|
||||
// strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
excludeLabel := func(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
}
|
||||
return q.toResultForTimeSeriesAndScalar(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForHeatmap(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
return &qbv5.Result{
|
||||
Type: q.requestType,
|
||||
Value: foldMatrixAsHeatmap(matrix, &q.tr, uint64(q.query.Step.Milliseconds()), q.query.Name),
|
||||
Warnings: warnings,
|
||||
Stats: collectExecStats(began, statsMu, rowsScanned, bytesScanned),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForTimeSeriesAndScalar(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
var series []*qbv5.TimeSeries
|
||||
for _, v := range matrix {
|
||||
var s qbv5.TimeSeries
|
||||
lbls := make([]*qbv5.Label, 0, v.Metric.Len())
|
||||
v.Metric.Range(func(l labels.Label) {
|
||||
if excludePromLabel(l.Name) {
|
||||
if excludeLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
@@ -527,7 +495,13 @@ func (q *promqlQuery) toResultForTimeSeriesAndScalar(matrix promql.Matrix, warni
|
||||
series = append(series, &s)
|
||||
}
|
||||
|
||||
stats := collectExecStats(began, statsMu, rowsScanned, bytesScanned)
|
||||
statsMu.Lock()
|
||||
stats := qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
statsMu.Unlock()
|
||||
|
||||
tsData := &qbv5.TimeSeriesData{QueryName: q.query.Name}
|
||||
// No bucket at all when nothing survived: a bucket holding no series reads
|
||||
|
||||
@@ -156,7 +156,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
// We need to set if it is unspecified or adjust it if value is not within recommended range
|
||||
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
|
||||
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End, req.RequestType, req.BucketOptions)
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries {
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
|
||||
@@ -334,22 +334,15 @@ func (q *querier) buildQueries(
|
||||
if missingMetricQuerySet[spec.Name] {
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType == qbtypes.RequestTypeHeatmap && spec.Disabled {
|
||||
// A disabled query in a heatmap request feeds a formula, and the
|
||||
// formula converts time series into heatmap data, so its inputs
|
||||
// run as time series queries.
|
||||
requestType = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
var bq *builderQuery[qbtypes.MetricAggregation]
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
event.Source = telemetrytypes.SourceMeter.StringValue()
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
} else {
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
}
|
||||
|
||||
queries[spec.Name] = bq
|
||||
@@ -422,7 +415,7 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
// resolved: never-seen metrics and dormant metrics (seen but no data in
|
||||
// the query window).
|
||||
// - err: Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64, requestType qbtypes.RequestType, bucketOptions *qbtypes.BucketOptions) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -472,13 +465,6 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
spec.Aggregations[i].Type = foundMetricType
|
||||
}
|
||||
}
|
||||
// Only the enabled query is used to render the heatmap, so bucket
|
||||
// options are only applied to the enabled query.
|
||||
if requestType == qbtypes.RequestTypeHeatmap && !spec.Disabled {
|
||||
if err := spec.Aggregations[i].VerifyAndApplyBucketOptions(bucketOptions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
|
||||
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
|
||||
continue
|
||||
@@ -693,7 +679,7 @@ func (q *querier) run(
|
||||
if val, ok := result.Value.(*qbtypes.RawData); ok && val != nil {
|
||||
return len(val.Rows) != 0
|
||||
}
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
if val, ok := result.Value.(*qbtypes.TimeSeriesData); ok && val != nil {
|
||||
if len(val.Aggregations) != 0 {
|
||||
anyNonEmpty := false
|
||||
@@ -1014,7 +1000,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
|
||||
// Merge all fresh results including the first one
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
// Pass nil as cached value to ensure proper merging of all fresh results
|
||||
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
|
||||
}
|
||||
@@ -1037,7 +1023,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
}
|
||||
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
|
||||
}
|
||||
|
||||
@@ -1058,16 +1044,6 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBucketUpperBounds(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) map[int][]float64 {
|
||||
upperBoundSources := make([]*qbtypes.TimeSeriesData, 0, len(freshResults)+1)
|
||||
upperBoundSources = append(upperBoundSources, cachedValue)
|
||||
for _, result := range freshResults {
|
||||
freshTS, _ := result.Value.(*qbtypes.TimeSeriesData)
|
||||
upperBoundSources = append(upperBoundSources, freshTS)
|
||||
}
|
||||
return qbtypes.MergeBucketUpperBounds(upperBoundSources...)
|
||||
}
|
||||
|
||||
// mergeTimeSeriesResults merges time series data.
|
||||
func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) *qbtypes.TimeSeriesData {
|
||||
|
||||
@@ -1076,15 +1052,12 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
// Map to store aggregation bucket metadata
|
||||
bucketMetadata := make(map[int]*qbtypes.AggregationBucket)
|
||||
|
||||
mergedUpperBounds := mergeBucketUpperBounds(cachedValue, freshResults)
|
||||
|
||||
// Process cached data if available
|
||||
if cachedValue != nil && cachedValue.Aggregations != nil {
|
||||
for _, aggBucket := range cachedValue.Aggregations {
|
||||
if seriesMap[aggBucket.Index] == nil {
|
||||
seriesMap[aggBucket.Index] = make(map[string]*qbtypes.TimeSeries)
|
||||
}
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
if bucketMetadata[aggBucket.Index] == nil {
|
||||
bucketMetadata[aggBucket.Index] = aggBucket
|
||||
}
|
||||
@@ -1136,7 +1109,6 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
}
|
||||
|
||||
for _, aggBucket := range freshTS.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
for _, series := range aggBucket.Series {
|
||||
key := qbtypes.GetUniqueSeriesKey(series.Labels)
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *meterQueryStatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
|
||||
// final SELECT
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, qbtypes.RequestTypeTimeSeries, query)
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
}
|
||||
|
||||
func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
|
||||
@@ -4,13 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -117,7 +113,7 @@ func (b *StatementBuilder) Build(
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
_ qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
@@ -129,14 +125,13 @@ func (b *StatementBuilder) Build(
|
||||
|
||||
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
|
||||
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, variables)
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, query, keys, variables)
|
||||
}
|
||||
|
||||
func (b *StatementBuilder) buildPipelineStatement(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
@@ -149,7 +144,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
cteQuery := query
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
query.GroupBy = slices.DeleteFunc(slices.Clone(query.GroupBy), isHistogramBucket)
|
||||
cteQuery = rewriteQueryForHistogramCTE(requestType, query)
|
||||
cteQuery = histogramCTEQuery(query)
|
||||
}
|
||||
|
||||
agg := cteQuery.Aggregations[0]
|
||||
@@ -221,7 +216,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
}
|
||||
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, requestType, query)
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,29 +224,13 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
if reducedFragments == nil {
|
||||
return mainStmt, nil
|
||||
}
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, requestType, query)
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unionStatements(mainStmt, reducedStmt, query)
|
||||
}
|
||||
|
||||
func rewriteQueryForHistogramCTE(requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() && requestType != qbtypes.RequestTypeHeatmap {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
}
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func unionStatements(main, reduced *qbtypes.Statement, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) (*qbtypes.Statement, error) {
|
||||
orderBy := "ts"
|
||||
for i, g := range query.GroupBy {
|
||||
@@ -779,9 +758,11 @@ func (b *StatementBuilder) buildSpatialAggregationCTE(
|
||||
func (b *StatementBuilder) BuildFinalSelect(
|
||||
cteFragments []string,
|
||||
cteArgs [][]any,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
combined := querybuilder.CombineCTEs(cteFragments)
|
||||
|
||||
var args []any
|
||||
@@ -789,22 +770,6 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
args = append(args, a...)
|
||||
}
|
||||
|
||||
if requestType == qbtypes.RequestTypeHeatmap {
|
||||
return buildHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
return buildAggregationFinalSelect(combined, args, query)
|
||||
}
|
||||
|
||||
// buildAggregationFinalSelect reads __spatial_aggregation_cte as one value per
|
||||
// (group, timestamp), which is what every request type but heatmap wants.
|
||||
func buildAggregationFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
if metricType == metrictypes.HistogramType && spaceAgg.IsPercentile() {
|
||||
@@ -877,136 +842,24 @@ func buildAggregationFinalSelect(
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
histogramBucketKey = "le"
|
||||
|
||||
heatmapValueAlias = "__result_0"
|
||||
heatmapWindow = "__heatmap_window"
|
||||
)
|
||||
const histogramBucketKey = "le"
|
||||
|
||||
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
|
||||
|
||||
// buildHeatmapFinalSelect turns __spatial_aggregation_cte into one row per
|
||||
// heatmap cell: (ts, group labels..., bucket upper bound, count).
|
||||
func buildHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
return buildHistogramHeatmapFinalSelect(combined, args, query)
|
||||
func histogramCTEQuery(query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
}
|
||||
return buildValueHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
|
||||
// buildHistogramHeatmapFinalSelect differences the cumulative per-`le` counts in
|
||||
// __spatial_aggregation_cte into a count per band. The upper bound reported is the
|
||||
// `le` itself, so the `le=+Inf` row reaches the reader as an infinite upper bound
|
||||
// for it to fold into the overflow band.
|
||||
func buildHistogramHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
partitionBy := append(append([]string{}, groupAliases...), "ts")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(%s) AS %s", histogramBucketKey, qbtypes.HeatmapBucketColumn))
|
||||
// a partial scrape can break monotonicity across `le`, and a negative cell
|
||||
// count has no meaning
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"greatest(value - lagInFrame(value, 1, 0) OVER %s, 0) AS %s",
|
||||
heatmapWindow, heatmapValueAlias,
|
||||
))
|
||||
// sqlbuilder has no WINDOW clause; appending it to FROM lands it between FROM
|
||||
// and ORDER BY, since these statements carry no WHERE or GROUP BY
|
||||
sb.From(fmt.Sprintf(
|
||||
"__spatial_aggregation_cte WINDOW %s AS (PARTITION BY %s ORDER BY toFloat64(%s))",
|
||||
heatmapWindow, strings.Join(partitionBy, ", "), histogramBucketKey,
|
||||
))
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", fmt.Sprintf("toFloat64(%s)", histogramBucketKey))
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// buildValueHeatmapFinalSelect places each spatially aggregated value in a band
|
||||
// of the requested axis. __spatial_aggregation_cte holds one row per (group,
|
||||
// timestamp), so every cell counts exactly one.
|
||||
func buildValueHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
bucketing := query.Aggregations[0].HeatmapBucketing
|
||||
if bucketing == nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal,
|
||||
"heatmap over a %s metric reached the statement builder without a resolved bucket axis",
|
||||
query.Aggregations[0].Type.StringValue())
|
||||
}
|
||||
|
||||
upperBound, err := renderHeatmapUpperBoundExpr(*bucketing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS %s", upperBound, qbtypes.HeatmapBucketColumn))
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(1) AS %s", heatmapValueAlias))
|
||||
sb.From("__spatial_aggregation_cte")
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", qbtypes.HeatmapBucketColumn)
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// renderHeatmapUpperBoundExpr renders the upper bound of the band `value` falls in.
|
||||
func renderHeatmapUpperBoundExpr(bucketing qbtypes.HeatmapBucketing) (string, error) {
|
||||
switch bucketing.Kind {
|
||||
case qbtypes.BucketsKindLinear:
|
||||
return renderLinearUpperBoundExpr(bucketing), nil
|
||||
case qbtypes.BucketsKindLog:
|
||||
return renderLogUpperBoundExpr(), nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func renderLinearUpperBoundExpr(bucketing qbtypes.HeatmapBucketing) string {
|
||||
maxValue := formatFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
return fmt.Sprintf(
|
||||
"multiIf(value > %s, toFloat64('+Inf'), least(greatest(ceil(value * %s / %s), 1), %s) * %s / %s)",
|
||||
maxValue, numBuckets, maxValue, numBuckets, maxValue, numBuckets,
|
||||
)
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogUpperBoundExpr() string {
|
||||
bandsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := formatFloat(qbtypes.MinLogUpperBound)
|
||||
highest := formatFloat(qbtypes.MaxLogUpperBound)
|
||||
return fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
|
||||
lowest, lowest, highest, bandsPerDoubling, bandsPerDoubling,
|
||||
)
|
||||
}
|
||||
|
||||
// formatFloat renders a float64 as the shortest literal that reads back as the
|
||||
// same value, so an upper bound computed from it is identical on every row.
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
return query
|
||||
}
|
||||
|
||||
func GroupByColumnAlias(i int, name string) string {
|
||||
|
||||
@@ -284,199 +284,6 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_heatmap_sum",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationIncrease,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, toFloat64(le) AS __bucket, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947360000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_heatmap_percentile",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, toFloat64(le) AS __bucket, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947360000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_heatmap_log",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
Temporality: metrictypes.Unspecified,
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLog,
|
||||
LogScale: qbtypes.MaxLogScale,
|
||||
NumBuckets: qbtypes.DefaultNumBuckets,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket",
|
||||
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_heatmap_linear",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
Temporality: metrictypes.Unspecified,
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLinear,
|
||||
LogScale: qbtypes.MaxLogScale,
|
||||
MaxValue: 500,
|
||||
NumBuckets: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value > 500, toFloat64('+Inf'), least(greatest(ceil(value * 25 / 500), 1), 25) * 500 / 25) AS __bucket, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket",
|
||||
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// cumulative keeps CanShortCircuitDelta false, so the counts reach the
|
||||
// bucket differencing through the temporal CTE rather than the delta
|
||||
// fast path
|
||||
name: "test_histogram_heatmap_cumulative",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "http_server_duration_bucket",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, toFloat64(le) AS __bucket, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947300000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_sum_heatmap_cumulative",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_calls_total",
|
||||
Type: metrictypes.SumType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationIncrease,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLog,
|
||||
LogScale: qbtypes.MaxLogScale,
|
||||
NumBuckets: qbtypes.DefaultNumBuckets,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket",
|
||||
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "signoz_calls_total", uint64(1747947300000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_avg_sum",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
|
||||
@@ -524,96 +524,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateHeatmap
|
||||
// accepts in querybuildertypesv5: everything but a trace operator.
|
||||
func TestHeatmapPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
description: "a metrics builder query is allowed",
|
||||
queryPluginKind: "signoz/BuilderQuery",
|
||||
queryPluginSpec: `{"name": "A", "signal": "metrics", "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a promql query is allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a clickhouse query is allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a formula is allowed",
|
||||
queryPluginKind: "signoz/Formula",
|
||||
queryPluginSpec: `{"name": "F1", "expression": "A / B"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a composite query is allowed, since a formula needs its disabled inputs alongside it",
|
||||
queryPluginKind: "signoz/CompositeQuery",
|
||||
queryPluginSpec: `{"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "disabled": true, "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a trace operator is refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/HeatmapPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "heatmap",
|
||||
"spec": {
|
||||
"plugin": {"kind": %q, "spec": %s}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`, testCase.queryPluginKind, testCase.queryPluginSpec)
|
||||
|
||||
_, err := unmarshalDashboard([]byte(data))
|
||||
|
||||
if testCase.expectedAllowed {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not supported by panel kind")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -35,7 +35,6 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +65,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +228,6 @@ var (
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -253,7 +250,6 @@ var (
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -173,11 +173,10 @@ const (
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
@@ -238,56 +237,6 @@ type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type HeatmapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes HeatmapAxes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance"`
|
||||
}
|
||||
|
||||
// HeatmapAxes carries only the Y scale. The shared Axes type models a value
|
||||
// axis with soft bounds, where a heatmap's Y axis is the bucket boundaries the
|
||||
// response already fixed.
|
||||
type HeatmapAxes struct {
|
||||
YScale HeatmapYScale `json:"yScale"`
|
||||
}
|
||||
|
||||
type HeatmapChartAppearance struct {
|
||||
Colors HeatmapColors `json:"colors"`
|
||||
}
|
||||
|
||||
type HeatmapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
// MinCount and MaxCount clamp the colour scale; nil derives them from the
|
||||
// grid, 0 and the highest count in it.
|
||||
MinCount *float64 `json:"minCount"`
|
||||
MaxCount *float64 `json:"maxCount"`
|
||||
// Fill applies in opacity mode; empty means the selected group's legend colour.
|
||||
Fill string `json:"fill"`
|
||||
}
|
||||
|
||||
func (c *HeatmapColors) UnmarshalJSON(data []byte) error {
|
||||
type alias HeatmapColors
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap colors")
|
||||
}
|
||||
*c = HeatmapColors(tmp)
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
func (c HeatmapColors) validate() error {
|
||||
if c.MinCount != nil && c.MaxCount != nil && *c.MinCount > *c.MaxCount {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"heatmap colors.minCount %v is greater than colors.maxCount %v", *c.MinCount, *c.MaxCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel common types
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -760,168 +709,3 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorModePalette = HeatmapColorMode{valuer.NewString("palette")} // default
|
||||
HeatmapColorModeOpacity = HeatmapColorMode{valuer.NewString("opacity")}
|
||||
)
|
||||
|
||||
func (HeatmapColorMode) Enum() []any {
|
||||
return []any{HeatmapColorModePalette, HeatmapColorModeOpacity}
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return HeatmapColorModePalette.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *HeatmapColorMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color mode: must be a string, one of `palette` or `opacity`")
|
||||
}
|
||||
mode := HeatmapColorMode{valuer.NewString(v)}
|
||||
switch mode {
|
||||
case HeatmapColorModePalette, HeatmapColorModeOpacity:
|
||||
*m = mode
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color mode %q: must be `palette` or `opacity`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapPalette struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapPaletteIce = HeatmapPalette{valuer.NewString("ice")}
|
||||
HeatmapPaletteMoss = HeatmapPalette{valuer.NewString("moss")}
|
||||
HeatmapPaletteRust = HeatmapPalette{valuer.NewString("rust")}
|
||||
HeatmapPaletteGraphite = HeatmapPalette{valuer.NewString("graphite")}
|
||||
HeatmapPaletteEmber = HeatmapPalette{valuer.NewString("ember")}
|
||||
HeatmapPaletteLagoon = HeatmapPalette{valuer.NewString("lagoon")}
|
||||
HeatmapPaletteOrchid = HeatmapPalette{valuer.NewString("orchid")}
|
||||
HeatmapPaletteVerdant = HeatmapPalette{valuer.NewString("verdant")}
|
||||
HeatmapPaletteLava = HeatmapPalette{valuer.NewString("lava")} // default
|
||||
HeatmapPaletteBeacon = HeatmapPalette{valuer.NewString("beacon")}
|
||||
)
|
||||
|
||||
func (HeatmapPalette) Enum() []any {
|
||||
return []any{
|
||||
HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon,
|
||||
}
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) ValueOrDefault() string {
|
||||
if p.IsZero() {
|
||||
return HeatmapPaletteLava.StringValue()
|
||||
}
|
||||
return p.StringValue()
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (p *HeatmapPalette) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap palette: must be a string, one of `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`")
|
||||
}
|
||||
palette := HeatmapPalette{valuer.NewString(v)}
|
||||
switch palette {
|
||||
case HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon:
|
||||
*p = palette
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap palette %q: must be `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapYScaleAuto = HeatmapYScale{valuer.NewString("auto")} // default
|
||||
HeatmapYScaleLinear = HeatmapYScale{valuer.NewString("linear")}
|
||||
HeatmapYScaleLog = HeatmapYScale{valuer.NewString("log")}
|
||||
HeatmapYScaleSymlog = HeatmapYScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (HeatmapYScale) Enum() []any {
|
||||
return []any{HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog}
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapYScaleAuto.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap y scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
scale := HeatmapYScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap y scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorScaleLog = HeatmapColorScale{valuer.NewString("log")} // default
|
||||
HeatmapColorScaleSqrt = HeatmapColorScale{valuer.NewString("sqrt")}
|
||||
HeatmapColorScaleLinear = HeatmapColorScale{valuer.NewString("linear")}
|
||||
)
|
||||
|
||||
func (HeatmapColorScale) Enum() []any {
|
||||
return []any{HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear}
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapColorScaleLog.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapColorScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color scale: must be a string, one of `log`, `sqrt`, or `linear`")
|
||||
}
|
||||
scale := HeatmapColorScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color scale %q: must be `log`, `sqrt`, or `linear`", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +540,6 @@ type MetricAggregation struct {
|
||||
// reduce to operator for metric scalar requests
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero"`
|
||||
|
||||
HeatmapBucketing *HeatmapBucketing `json:"-"`
|
||||
|
||||
Reduced bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -556,10 +554,6 @@ func (m MetricAggregation) Copy() MetricAggregation {
|
||||
valueFilterCopy := *m.ValueFilter
|
||||
c.ValueFilter = &valueFilterCopy
|
||||
}
|
||||
if m.HeatmapBucketing != nil {
|
||||
bucketingCopy := *m.HeatmapBucketing
|
||||
c.HeatmapBucketing = &bucketingCopy
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
)
|
||||
|
||||
const (
|
||||
// HeatmapBucketColumn is the alias a heatmap statement gives the column holding
|
||||
// a row's bucket upper bound. Every other aggregation returns a single numeric
|
||||
// column the reader treats as the value; this name tells the two apart.
|
||||
HeatmapBucketColumn = "__bucket"
|
||||
|
||||
DefaultNumBuckets = 60
|
||||
|
||||
// MaxLogScale is the resolution ClickHouse buckets every log heatmap at:
|
||||
// 2^MaxLogScale bands per doubling. It is both the default and the finest
|
||||
// available, since a coarser LogBucketsSpec.Scale folds down from it.
|
||||
MaxLogScale = 4
|
||||
// MinLogScale is one band per 16x, the coarsest axis worth rendering.
|
||||
MinLogScale = -4
|
||||
|
||||
// A positive value approaching zero runs its band index off to -inf, so
|
||||
// without a clamp one near-zero sample would stretch the axis by thousands
|
||||
// of bands once AddHeatmapBucketsWithNoCounts spans it.
|
||||
MinLogBandIndex = -512 // 2^-32, about 2.3e-10
|
||||
MaxLogBandIndex = 1024 // 2^64, about 1.8e19
|
||||
)
|
||||
|
||||
// MinLogUpperBound and MaxLogUpperBound are the ends the log axis is clamped
|
||||
// to. They do not vary with the requested scale.
|
||||
var (
|
||||
MinLogUpperBound = math.Exp2(float64(MinLogBandIndex) / math.Exp2(MaxLogScale))
|
||||
MaxLogUpperBound = math.Exp2(float64(MaxLogBandIndex) / math.Exp2(MaxLogScale))
|
||||
)
|
||||
|
||||
// HeatmapBucketing is the bucket axis a heatmap statement builds in ClickHouse,
|
||||
// resolved from BucketOptions once the metric type is known. It stays nil for
|
||||
// histograms, whose upper bounds come from their own `le` labels.
|
||||
type HeatmapBucketing struct {
|
||||
Kind BucketsKind
|
||||
// LogScale is the resolution the caller asked for. ClickHouse always buckets
|
||||
// at MaxLogScale, and postprocessing folds the axis down to this.
|
||||
LogScale int
|
||||
// MaxValue and NumBuckets are linear only.
|
||||
MaxValue float64
|
||||
NumBuckets int
|
||||
}
|
||||
|
||||
// ToHeatmapBucketing fills in what the caller left unset. An absent
|
||||
// BucketOptions resolves to the finest log axis, the one kind that needs nothing
|
||||
// from the caller.
|
||||
func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
|
||||
resolved := HeatmapBucketing{
|
||||
Kind: BucketsKindLog,
|
||||
LogScale: MaxLogScale,
|
||||
NumBuckets: DefaultNumBuckets,
|
||||
}
|
||||
if b == nil {
|
||||
return resolved
|
||||
}
|
||||
|
||||
switch spec := b.Spec.(type) {
|
||||
case LinearBucketsSpec:
|
||||
resolved.Kind = BucketsKindLinear
|
||||
resolved.MaxValue = spec.MaxValue
|
||||
if spec.NumBuckets > 0 {
|
||||
resolved.NumBuckets = spec.NumBuckets
|
||||
}
|
||||
case LogBucketsSpec:
|
||||
if spec.Scale != nil {
|
||||
resolved.LogScale = *spec.Scale
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// This cannot be called in validateHeatmap cuz type is resolved in querier.go.
|
||||
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
|
||||
switch a.Type {
|
||||
case metrictypes.HistogramType:
|
||||
if bucketOptions != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"bucketOptions are not supported for histogram metrics: %q takes its bucket axis from its own `le` labels, so nothing in the spec would be applied", a.MetricName)
|
||||
}
|
||||
a.HeatmapBucketing = nil
|
||||
return nil
|
||||
// A summary carries no upper bounds of its own either, and its samples reach
|
||||
// the final select the same way a gauge's do, so it buckets identically.
|
||||
case metrictypes.GaugeType, metrictypes.SumType, metrictypes.SummaryType:
|
||||
bucketing := bucketOptions.ToHeatmapBucketing()
|
||||
a.HeatmapBucketing = &bucketing
|
||||
return nil
|
||||
case metrictypes.UnspecifiedType:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"heatmaps need a metric whose type is known: no type is recorded for %q, so its bucket axis cannot be chosen", a.MetricName)
|
||||
case metrictypes.ExpHistogramType:
|
||||
return errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for exponential histograms yet: %q keeps its bucket counts in a sketch column, which needs its own reader", a.MetricName)
|
||||
default:
|
||||
return errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for %s metrics", a.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func MergeBucketUpperBounds(tsData ...*TimeSeriesData) map[int][]float64 {
|
||||
upperBoundsByAggregation := map[int][]float64{}
|
||||
|
||||
for _, data := range tsData {
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
for _, aggBucket := range data.Aggregations {
|
||||
if len(aggBucket.Meta.Buckets) == 0 {
|
||||
continue
|
||||
}
|
||||
upperBoundsByAggregation[aggBucket.Index] = append(upperBoundsByAggregation[aggBucket.Index], aggBucket.Meta.Buckets...)
|
||||
}
|
||||
}
|
||||
|
||||
for index, upperBounds := range upperBoundsByAggregation {
|
||||
slices.Sort(upperBounds)
|
||||
upperBoundsByAggregation[index] = slices.Compact(upperBounds)
|
||||
}
|
||||
|
||||
return upperBoundsByAggregation
|
||||
}
|
||||
|
||||
// DownscaleHeatmapResolution folds the MaxLogScale axis ClickHouse buckets at
|
||||
// down to toScale, merging every 2^(MaxLogScale-toScale) adjacent bands into
|
||||
// one. The coarser upper bounds are a subset of the finer ones, so the fold is
|
||||
// exact.
|
||||
func DownscaleHeatmapResolution(tsData *TimeSeriesData, toScale int) {
|
||||
if tsData == nil || toScale >= MaxLogScale {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
downscaleHeatmapResolutionForAggregation(aggBucket, toScale)
|
||||
}
|
||||
}
|
||||
|
||||
func downscaleHeatmapResolutionForAggregation(aggBucket *AggregationBucket, toScale int) {
|
||||
if aggBucket == nil || len(aggBucket.Meta.Buckets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
factor := int(math.Exp2(float64(MaxLogScale - toScale)))
|
||||
|
||||
// Merging is by index in the exponential mapping, not by position in
|
||||
// Meta.Buckets, which lists only the upper bounds some series reached.
|
||||
coarseUpperBounds := make([]float64, 0, len(aggBucket.Meta.Buckets))
|
||||
upperBoundToCoarseIndex := make(map[float64]int, len(aggBucket.Meta.Buckets))
|
||||
mergedInto := make([]int, len(aggBucket.Meta.Buckets))
|
||||
for index, upperBound := range aggBucket.Meta.Buckets {
|
||||
coarsened := coarsenUpperBound(upperBound, toScale, factor)
|
||||
coarseIndex, ok := upperBoundToCoarseIndex[coarsened]
|
||||
if !ok {
|
||||
coarseIndex = len(coarseUpperBounds)
|
||||
coarseUpperBounds = append(coarseUpperBounds, coarsened)
|
||||
upperBoundToCoarseIndex[coarsened] = coarseIndex
|
||||
}
|
||||
mergedInto[index] = coarseIndex
|
||||
}
|
||||
|
||||
overflowIndex := len(coarseUpperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
coarseCounts := make([]float64, overflowIndex+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(mergedInto) {
|
||||
coarseCounts[overflowIndex] += count
|
||||
continue
|
||||
}
|
||||
coarseCounts[mergedInto[index]] += count
|
||||
}
|
||||
point.Values = coarseCounts
|
||||
}
|
||||
}
|
||||
aggBucket.Meta.Buckets = coarseUpperBounds
|
||||
}
|
||||
|
||||
// coarsenUpperBound moves an upper bound from the MaxLogScale exponential axis
|
||||
// onto the toScale one. The zero band has no exponent to rescale and stays put.
|
||||
func coarsenUpperBound(upperBound float64, toScale, factor int) float64 {
|
||||
if upperBound <= 0 || math.IsInf(upperBound, 0) || math.IsNaN(upperBound) {
|
||||
return upperBound
|
||||
}
|
||||
index := int(math.Round(math.Log2(upperBound) * math.Exp2(MaxLogScale)))
|
||||
merged := int(math.Ceil(float64(index) / float64(factor)))
|
||||
return math.Exp2(float64(merged) / math.Exp2(float64(toScale)))
|
||||
}
|
||||
|
||||
// AddHeatmapBucketsWithNoCounts spans the range from the lowest upper bound some
|
||||
// series reached to the highest. Meta.Buckets leaves the ones in between out
|
||||
// entirely, so without this a gap renders with its two sides touching.
|
||||
//
|
||||
// Only a value-derived axis can be spanned: its upper bounds come from an index
|
||||
// that is a pure function of the value, so the ones in between are known without
|
||||
// having seen them. Nothing says what sits between two `le` labels.
|
||||
func AddHeatmapBucketsWithNoCounts(tsData *TimeSeriesData, bucketing HeatmapBucketing) {
|
||||
if tsData == nil {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
addHeatmapBucketsWithNoCountsForAggregation(aggBucket, bucketing)
|
||||
}
|
||||
}
|
||||
|
||||
func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, bucketing HeatmapBucketing) {
|
||||
if aggBucket == nil || len(aggBucket.Meta.Buckets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// The zero bucket holds everything at or below zero. It has no index on either
|
||||
// axis and sits below every other upper bound, so it keeps index 0 and the
|
||||
// fill runs over the rest.
|
||||
offset := 0
|
||||
if aggBucket.Meta.Buckets[0] <= 0 {
|
||||
offset = 1
|
||||
}
|
||||
positive := aggBucket.Meta.Buckets[offset:]
|
||||
if len(positive) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Only finite upper bounds have an index, and the fill sizes a slice from
|
||||
// one. Nothing should put +Inf or NaN on the axis, but bail if it happens.
|
||||
indexes := make([]int, len(positive))
|
||||
for i, upperBound := range positive {
|
||||
if math.IsInf(upperBound, 0) || math.IsNaN(upperBound) {
|
||||
return
|
||||
}
|
||||
indexes[i] = bucketing.calculateIndexOfUpperBound(upperBound)
|
||||
}
|
||||
lowest, highest := slices.Min(indexes), slices.Max(indexes)
|
||||
|
||||
denseUpperBounds := append([]float64{}, aggBucket.Meta.Buckets[:offset]...)
|
||||
for index := lowest; index <= highest; index++ {
|
||||
denseUpperBounds = append(denseUpperBounds, bucketing.calculateUpperBoundAtIndex(index))
|
||||
}
|
||||
if len(denseUpperBounds) == len(aggBucket.Meta.Buckets) {
|
||||
return
|
||||
}
|
||||
|
||||
// Counts map through their index rather than by matching upper bounds, so a
|
||||
// regenerated upper bound differing from ClickHouse's in its last bit still
|
||||
// lands where it came from.
|
||||
shiftedTo := make([]int, len(aggBucket.Meta.Buckets))
|
||||
for i, index := range indexes {
|
||||
shiftedTo[i+offset] = index - lowest + offset
|
||||
}
|
||||
|
||||
overflowIndex := len(denseUpperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
denseCounts := make([]float64, overflowIndex+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(shiftedTo) {
|
||||
denseCounts[overflowIndex] += count
|
||||
continue
|
||||
}
|
||||
denseCounts[shiftedTo[index]] += count
|
||||
}
|
||||
point.Values = denseCounts
|
||||
}
|
||||
}
|
||||
aggBucket.Meta.Buckets = denseUpperBounds
|
||||
}
|
||||
|
||||
// calculateIndexOfUpperBound and calculateUpperBoundAtIndex are inverses over
|
||||
// the axis being returned, so they read h.LogScale rather than the MaxLogScale
|
||||
// ClickHouse bucketed at: k * maxValue / numBuckets on a linear axis,
|
||||
// 2^(k / 2^scale) on a log one.
|
||||
func (h HeatmapBucketing) calculateIndexOfUpperBound(upperBound float64) int {
|
||||
if h.Kind == BucketsKindLinear {
|
||||
return int(math.Round(upperBound * float64(h.NumBuckets) / h.MaxValue))
|
||||
}
|
||||
return int(math.Round(math.Log2(upperBound) * math.Exp2(float64(h.LogScale))))
|
||||
}
|
||||
|
||||
func (h HeatmapBucketing) calculateUpperBoundAtIndex(index int) float64 {
|
||||
if h.Kind == BucketsKindLinear {
|
||||
return float64(index) * h.MaxValue / float64(h.NumBuckets)
|
||||
}
|
||||
return math.Exp2(float64(index) / math.Exp2(float64(h.LogScale)))
|
||||
}
|
||||
@@ -397,8 +397,6 @@ type QueryRangeRequest struct {
|
||||
PromQLProvider string `json:"-"`
|
||||
|
||||
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
|
||||
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the QueryRangeRequest schema.
|
||||
@@ -736,130 +734,3 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
|
||||
|
||||
return canDefaultZero
|
||||
}
|
||||
|
||||
type BucketOptions struct {
|
||||
Kind BucketsKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
}
|
||||
|
||||
type BucketsKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
BucketsKindLinear = BucketsKind{valuer.NewString("linear")}
|
||||
BucketsKindLog = BucketsKind{valuer.NewString("log")}
|
||||
)
|
||||
|
||||
// Enum implements jsonschema.Enum.
|
||||
func (BucketsKind) Enum() []any {
|
||||
return []any{
|
||||
BucketsKindLinear,
|
||||
BucketsKindLog,
|
||||
}
|
||||
}
|
||||
|
||||
// LinearBucketsSpec divides (0, MaxValue] into NumBuckets equal bands.
|
||||
type LinearBucketsSpec struct {
|
||||
// Everything above MaxValue is counted in the trailing overflow band. Evenly
|
||||
// spaced upper bounds have no top to divide without it, so it is required.
|
||||
MaxValue float64 `json:"maxValue" required:"true"`
|
||||
NumBuckets int `json:"numBuckets,omitempty"`
|
||||
}
|
||||
|
||||
// LogBucketsSpec spaces upper bounds at 2^Scale bands per doubling, the mapping
|
||||
// an exponential histogram uses.
|
||||
type LogBucketsSpec struct {
|
||||
// ClickHouse always buckets at MaxLogScale and the surplus is folded away
|
||||
// afterwards, so every Scale reads the same cache entry. MaxLogScale applies
|
||||
// when unset.
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
}
|
||||
|
||||
func (b *BucketOptions) UnmarshalJSON(data []byte) error {
|
||||
var shadow struct {
|
||||
Kind BucketsKind `json:"kind"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(data), &shadow, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Kind = shadow.Kind
|
||||
|
||||
// An absent spec is a malformed pair rather than a request for defaults;
|
||||
// `"spec": {}` asks for those.
|
||||
if len(shadow.Spec) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"bucketOptions spec is required, use an empty object for the kind's defaults")
|
||||
}
|
||||
|
||||
switch shadow.Kind {
|
||||
case BucketsKindLinear:
|
||||
var spec LinearBucketsSpec
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(shadow.Spec), &spec, binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("linear buckets spec")); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
|
||||
case BucketsKindLog:
|
||||
var spec LogBucketsSpec
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(shadow.Spec), &spec, binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("log buckets spec")); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid bucketOptions kind: %s",
|
||||
shadow.Kind.StringValue(),
|
||||
).WithAdditional(
|
||||
"Valid bucket kinds are: linear, log",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketOptionsLinear and bucketOptionsLog are the OpenAPI schemas for the two
|
||||
// BucketOptions variants. They have to be named types: the reflector turns an
|
||||
// anonymous one into an inline subschema, leaving the discriminator mapping in
|
||||
// PrepareJSONSchema pointing at components that were never emitted. `kind` is
|
||||
// required:"true" on both so oapi-codegen renders the discriminator non-pointer.
|
||||
type bucketOptionsLinear struct {
|
||||
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
|
||||
Spec LinearBucketsSpec `json:"spec" required:"true" description:"The evenly spaced bucket specification."`
|
||||
}
|
||||
|
||||
type bucketOptionsLog struct {
|
||||
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
|
||||
Spec LogBucketsSpec `json:"spec" required:"true" description:"The logarithmic bucket specification."`
|
||||
}
|
||||
|
||||
var _ jsonschema.OneOfExposer = BucketOptions{}
|
||||
|
||||
func (BucketOptions) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
bucketOptionsLinear{},
|
||||
bucketOptionsLog{},
|
||||
}
|
||||
}
|
||||
|
||||
var _ jsonschema.Preparer = BucketOptions{}
|
||||
|
||||
// PrepareJSONSchema marks the options as a `kind`-discriminated union;
|
||||
// signoz.attachDiscriminators promotes it and strips the base properties.
|
||||
func (BucketOptions) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
if s.ExtraProperties == nil {
|
||||
s.ExtraProperties = map[string]any{}
|
||||
}
|
||||
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
|
||||
"propertyName": "kind",
|
||||
"mapping": map[string]string{
|
||||
BucketsKindLinear.StringValue(): "#/components/schemas/Querybuildertypesv5BucketOptionsLinear",
|
||||
BucketsKindLog.StringValue(): "#/components/schemas/Querybuildertypesv5BucketOptionsLog",
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
v := RequestType{valuer.NewString(s)}
|
||||
switch v {
|
||||
case RequestTypeScalar, RequestTypeTimeSeries, RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeDistribution, RequestTypeHeatmap:
|
||||
case RequestTypeScalar, RequestTypeTimeSeries, RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeDistribution:
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,6 @@ var (
|
||||
RequestTypeTrace = RequestType{valuer.NewString("trace")}
|
||||
// []Bucket (struct{Lower,Upper,Count float64}), example: histogram.
|
||||
RequestTypeDistribution = RequestType{valuer.NewString("distribution")}
|
||||
// TimeSeriesData carrying one count per histogram bucket at each timestamp,
|
||||
// with the shared bucket upper bounds on the aggregation's meta.
|
||||
RequestTypeHeatmap = RequestType{valuer.NewString("heatmap")}
|
||||
)
|
||||
|
||||
// IsAggregation returns true for request types that produce aggregated results
|
||||
@@ -52,7 +49,7 @@ var (
|
||||
// For non-aggregation types (raw, raw_stream, trace), those fields are ignored
|
||||
// and don't need to be validated.
|
||||
func (r RequestType) IsAggregation() bool {
|
||||
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution || r == RequestTypeHeatmap
|
||||
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution
|
||||
}
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
|
||||
@@ -63,7 +60,6 @@ func (RequestType) Enum() []any {
|
||||
RequestTypeRaw,
|
||||
RequestTypeRawStream,
|
||||
RequestTypeTrace,
|
||||
RequestTypeHeatmap,
|
||||
// RequestTypeDistribution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +138,12 @@ type TimeSeriesData struct {
|
||||
}
|
||||
|
||||
type AggregationBucket struct {
|
||||
Index int `json:"index"` // or string Alias
|
||||
Alias string `json:"alias"`
|
||||
Meta AggregationMeta `json:"meta,omitempty"`
|
||||
Series []*TimeSeries `json:"series"` // no extra nesting
|
||||
Index int `json:"index"` // or string Alias
|
||||
Alias string `json:"alias"`
|
||||
Meta struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
} `json:"meta,omitempty"`
|
||||
Series []*TimeSeries `json:"series"` // no extra nesting
|
||||
|
||||
PredictedSeries []*TimeSeries `json:"predictedSeries,omitempty"`
|
||||
UpperBoundSeries []*TimeSeries `json:"upperBoundSeries,omitempty"`
|
||||
@@ -149,54 +151,6 @@ type AggregationBucket struct {
|
||||
AnomalyScores []*TimeSeries `json:"anomalyScores,omitempty"`
|
||||
}
|
||||
|
||||
// ReindexValuesToNewUpperBounds moves each count to the index its upper bound
|
||||
// holds in onto, a superset of Meta.Buckets. No count changes, only its position
|
||||
// in Values.
|
||||
func (a *AggregationBucket) ReindexValuesToNewUpperBounds(onto []float64) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
from := a.Meta.Buckets
|
||||
if len(onto) == 0 || slices.Equal(from, onto) {
|
||||
return
|
||||
}
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(onto))
|
||||
for index, upperBound := range onto {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
|
||||
for _, series := range a.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
reindexed := make([]float64, len(onto)+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(from) {
|
||||
reindexed[len(onto)] = count
|
||||
break
|
||||
}
|
||||
if newIndex, ok := upperBoundToIndex[from[index]]; ok {
|
||||
reindexed[newIndex] = count
|
||||
}
|
||||
}
|
||||
point.Values = reindexed
|
||||
}
|
||||
}
|
||||
|
||||
a.Meta.Buckets = onto
|
||||
}
|
||||
|
||||
type AggregationMeta struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// Buckets holds ascending upper bounds shared by every series in the
|
||||
// AggregationBucket, set only for heatmap results. Each point's Values holds
|
||||
// len(Buckets)+1 counts: one per bound, then the open-above overflow.
|
||||
Buckets []float64 `json:"buckets,omitempty"`
|
||||
}
|
||||
|
||||
type TimeSeries struct {
|
||||
Labels []*Label `json:"labels,omitempty"`
|
||||
Values []*TimeSeriesValue `json:"values"`
|
||||
@@ -300,9 +254,13 @@ type TimeSeriesValue struct {
|
||||
// on the client side, these partial values are rendered differently.
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
|
||||
// Values holds one count per histogram bucket for heatmap results, in the
|
||||
// order of the aggregation's Meta.Buckets. Value is unused in that case.
|
||||
// for the heatmap type chart
|
||||
Values []float64 `json:"values,omitempty"`
|
||||
Bucket *Bucket `json:"bucket,omitempty"`
|
||||
}
|
||||
|
||||
type Bucket struct {
|
||||
Step float64 `json:"step"`
|
||||
}
|
||||
|
||||
type ColumnType struct {
|
||||
|
||||
@@ -127,7 +127,7 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
|
||||
// For single-point series, return that value directly
|
||||
if len(series.Values) == 1 {
|
||||
value := calculatePointValue(series.Values[0])
|
||||
value := series.Values[0].Value
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0.0
|
||||
}
|
||||
@@ -139,11 +139,10 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
var count float64
|
||||
|
||||
for _, point := range series.Values {
|
||||
value := calculatePointValue(point)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
if math.IsNaN(point.Value) || math.IsInf(point.Value, 0) {
|
||||
continue
|
||||
}
|
||||
sum += value
|
||||
sum += point.Value
|
||||
count++
|
||||
}
|
||||
|
||||
@@ -155,25 +154,6 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
return sum / count
|
||||
}
|
||||
|
||||
// calculatePointValue returns what a point contributes to its series' rank.
|
||||
// Heatmap points carry one count per bucket in Values and leave Value at zero,
|
||||
// so they rank on the total across buckets.
|
||||
func calculatePointValue(point *TimeSeriesValue) float64 {
|
||||
if len(point.Values) == 0 {
|
||||
return point.Value
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, value := range point.Values {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
continue
|
||||
}
|
||||
total += value
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// convertValueToString converts various types to string for comparison.
|
||||
func convertValueToString(value any) string {
|
||||
switch v := value.(type) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplySeriesLimit(t *testing.T) {
|
||||
@@ -234,81 +232,3 @@ func TestApplySeriesLimit(t *testing.T) {
|
||||
assert.Equal(t, 40.0, result[2].Values[0].Value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplySeriesLimitRanksHeatmapSeriesByBucketTotals(t *testing.T) {
|
||||
// A reshaped heatmap point leaves Value at zero and holds one count per
|
||||
// bucket in Values, so ranking has to sum the buckets to see any difference.
|
||||
series := []*TimeSeries{
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "quiet",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{1, 2, 0}},
|
||||
{Timestamp: 1060, Values: []float64{0, 1, 0}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "busy",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{40, 60, 5}},
|
||||
{Timestamp: 1060, Values: []float64{30, 70, 5}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "middling",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{5, 5, 0}},
|
||||
{Timestamp: 1060, Values: []float64{4, 6, 0}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplySeriesLimit(series, nil, 2)
|
||||
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "busy", result[0].Labels[0].Value)
|
||||
assert.Equal(t, "middling", result[1].Labels[0].Value)
|
||||
}
|
||||
|
||||
func TestCalculatePointValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
point *TimeSeriesValue
|
||||
expectedValue float64
|
||||
}{
|
||||
{
|
||||
description: "a plain time series point ranks on its single value",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Value: 7},
|
||||
expectedValue: 7,
|
||||
},
|
||||
{
|
||||
description: "a heatmap point ranks on the total across its buckets",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Values: []float64{1, 12, 14, 3}},
|
||||
expectedValue: 30,
|
||||
},
|
||||
{
|
||||
description: "non-finite bucket counts are skipped",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Values: []float64{2, math.NaN(), math.Inf(1), 3}},
|
||||
expectedValue: 5,
|
||||
},
|
||||
{
|
||||
description: "an empty bucket list falls back to the single value",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Value: 4, Values: []float64{}},
|
||||
expectedValue: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.expectedValue, calculatePointValue(testCase.point))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -66,8 +65,6 @@ func wrapValidationError(cause error, contextIdentifier string, errorFormat stri
|
||||
const (
|
||||
// Maximum limit for query results.
|
||||
MaxQueryLimit = 10000
|
||||
|
||||
MaxNumBuckets = 512
|
||||
)
|
||||
|
||||
// ValidationOption is a functional option for configuring validation behaviour.
|
||||
@@ -584,7 +581,7 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
@@ -592,14 +589,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
"Valid request types are: raw, timeseries, scalar",
|
||||
)
|
||||
}
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// raw/trace request types don't support metric queries;
|
||||
// metrics are always aggregated and there is no raw form.
|
||||
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
|
||||
@@ -637,15 +630,11 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
}
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar")
|
||||
}
|
||||
|
||||
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
|
||||
@@ -849,135 +838,9 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateHeatmap() error {
|
||||
if r.RequestType != RequestTypeHeatmap {
|
||||
if r.BucketOptions != nil {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"bucketOptions are only supported for heatmap requests, got %s",
|
||||
r.RequestType,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.FormatOptions != nil && r.FormatOptions.FillGaps {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"fillGaps is not supported for heatmap requests: an absent column means collection stopped, which a zero-filled column would hide")
|
||||
}
|
||||
|
||||
if err := r.BucketOptions.validateBucketOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enabled := 0
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
switch spec := envelope.Spec.(type) {
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case QueryBuilderFormula:
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case ClickHouseQuery:
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case PromQuery:
|
||||
if r.BucketOptions != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"bucketOptions are not supported for promql heatmap requests: the bucket axis comes from the `le` labels the query returns, so nothing in the spec would be applied")
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
// An AI query decodes to the traces spec, so it lands here too. Admitting
|
||||
// either signal means capping Aggregations at one: each carries its own
|
||||
// Meta.Buckets, and a heatmap renders against a single bucket axis.
|
||||
case QueryBuilderQuery[LogAggregation], QueryBuilderQuery[TraceAggregation]:
|
||||
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for the logs and traces signals yet")
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"heatmap requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", envelope.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
if enabled != 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"heatmap requests need exactly one enabled query, got %d", enabled)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BucketOptions) validateBucketOptions() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch spec := b.Spec.(type) {
|
||||
case LinearBucketsSpec:
|
||||
if math.IsNaN(spec.MaxValue) || math.IsInf(spec.MaxValue, 0) || spec.MaxValue <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"linear buckets need a finite maxValue greater than 0, got %v", spec.MaxValue)
|
||||
}
|
||||
if spec.NumBuckets < 0 || spec.NumBuckets > MaxNumBuckets {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"numBuckets must be between 1 and %d, got %d", MaxNumBuckets, spec.NumBuckets)
|
||||
}
|
||||
|
||||
case LogBucketsSpec:
|
||||
if spec.Scale != nil && (*spec.Scale < MinLogScale || *spec.Scale > MaxLogScale) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"scale must be between %d and %d, got %d", MinLogScale, MaxLogScale, *spec.Scale)
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid bucketOptions kind: %s",
|
||||
b.Kind.StringValue(),
|
||||
).WithAdditional(
|
||||
"Valid bucket kinds are: linear, log",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHeatmapQuery refuses the per-query settings that cannot mean anything
|
||||
// on a heatmap. It runs on disabled queries too: a disabled query is a formula
|
||||
// input, so whatever it does still reaches the cells.
|
||||
func validateHeatmapQuery(functions []Function, having *Having) error {
|
||||
if len(functions) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"functions are not supported for heatmap requests: a heatmap point is a count per bucket, not a single value")
|
||||
}
|
||||
|
||||
if having != nil && having.Expression != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"having is not supported for heatmap requests: it filters individual cells, which breaks the cumulative differencing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetValidationOptions(requestType RequestType) []ValidationOption {
|
||||
switch requestType {
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap:
|
||||
case RequestTypeTimeSeries:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
|
||||
case RequestTypeScalar:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}
|
||||
|
||||
36
tests/fixtures/querier.py
vendored
36
tests/fixtures/querier.py
vendored
@@ -33,7 +33,6 @@ class RequestType:
|
||||
TIME_SERIES = "time_series"
|
||||
SCALAR = "scalar"
|
||||
TABLE = "table"
|
||||
HEATMAP = "heatmap"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -174,7 +173,6 @@ def make_query_request(
|
||||
request_type: str = RequestType.TIME_SERIES,
|
||||
format_options: dict | None = None,
|
||||
variables: dict | None = None,
|
||||
bucket_options: dict | None = None,
|
||||
no_cache: bool = True,
|
||||
timeout: int = QUERY_TIMEOUT,
|
||||
headers: dict | None = None,
|
||||
@@ -193,8 +191,6 @@ def make_query_request(
|
||||
}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
if bucket_options is not None:
|
||||
payload["bucketOptions"] = bucket_options
|
||||
|
||||
return requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
@@ -363,20 +359,6 @@ def build_formula_query(
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
def build_log_bucket_options(scale: int | None = None) -> dict:
|
||||
spec: dict[str, Any] = {}
|
||||
if scale is not None:
|
||||
spec["scale"] = scale
|
||||
return {"kind": "log", "spec": spec}
|
||||
|
||||
|
||||
def build_linear_bucket_options(max_value: float, num_buckets: int | None = None) -> dict:
|
||||
spec: dict[str, Any] = {"maxValue": max_value}
|
||||
if num_buckets is not None:
|
||||
spec["numBuckets"] = num_buckets
|
||||
return {"kind": "linear", "spec": spec}
|
||||
|
||||
|
||||
def build_function(name: str, *args: Any) -> dict:
|
||||
func: dict[str, Any] = {"name": name}
|
||||
if args:
|
||||
@@ -411,24 +393,6 @@ def get_all_series(response_json: dict, query_name: str) -> list[dict]:
|
||||
return aggregations[0].get("series", [])
|
||||
|
||||
|
||||
def get_heatmap_buckets(response_json: dict, query_name: str) -> list[float]:
|
||||
"""The ascending bucket upper bounds a heatmap result's counts are positional against.
|
||||
Each point holds one more count than there are bounds: the trailing one is the open-above overflow."""
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
result = find_named_result(results, query_name)
|
||||
if not result:
|
||||
return []
|
||||
aggregations = result.get("aggregations", [])
|
||||
if not aggregations:
|
||||
return []
|
||||
return aggregations[0].get("meta", {}).get("buckets", [])
|
||||
|
||||
|
||||
def get_heatmap_columns(response_json: dict, query_name: str) -> list[dict]:
|
||||
"""A heatmap result's points for its single series, oldest first."""
|
||||
return sorted(get_series_values(response_json, query_name), key=lambda point: point["timestamp"])
|
||||
|
||||
|
||||
def get_scalar_value(response_json: dict, query_name: str) -> float | None:
|
||||
values = get_series_values(response_json, query_name)
|
||||
if values:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 10, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 20, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 30, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 40, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 60, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 70, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 80, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 11, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 23, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 33, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 44, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 61, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 72, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 82, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 13, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 25, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 39, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 51, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 62, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 73, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 85, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
@@ -1,883 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_identical_query_response,
|
||||
build_builder_query,
|
||||
build_formula_query,
|
||||
build_function,
|
||||
build_linear_bucket_options,
|
||||
build_log_bucket_options,
|
||||
get_all_series,
|
||||
get_error_message,
|
||||
get_heatmap_buckets,
|
||||
get_heatmap_columns,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
HISTOGRAM_FILE = get_testdata_file_path("histogram_data_1h.jsonl")
|
||||
HISTOGRAM_COUNTERS_FILE = get_testdata_file_path("heatmap_histogram_3m.jsonl")
|
||||
MINUTE_MS = 60_000
|
||||
|
||||
# the request-level rules below are checked before the metric is resolved, so
|
||||
# they need no data behind the query
|
||||
MISSING_METRIC = "test_heatmap_metric_that_is_never_written"
|
||||
|
||||
|
||||
def test_gauge_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
minutes = 3
|
||||
start_ms = int((now - timedelta(minutes=minutes + 1)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_gauge"
|
||||
|
||||
value_by_host = {f"host-{host:02d}": (200, 400, 800)[host // 8] + host for host in range(24)}
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"host": host},
|
||||
timestamp=now - timedelta(minutes=minutes - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for host, value in value_by_host.items()
|
||||
for minute in range(minutes)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", group_by=["host"])],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_log_bucket_options(0),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a group by gives one series per host, and all of them are counted against
|
||||
# this one axis
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([256.0, 512.0, 1024.0])
|
||||
|
||||
columns_by_host = {
|
||||
host: sorted(series["values"], key=lambda column: column["timestamp"])
|
||||
for host, series in index_series_by_label(get_all_series(data, "A"), "host").items()
|
||||
}
|
||||
assert len(columns_by_host) == len(value_by_host)
|
||||
|
||||
# a column holds one count per bucket plus a trailing one for the overflow
|
||||
for host, columns in columns_by_host.items():
|
||||
occupied = int(host.removeprefix("host-")) // 8
|
||||
assert [column["values"] for column in columns] == [[1 if slot == occupied else 0 for slot in range(4)]] * minutes
|
||||
|
||||
# summed across the hosts, a column is the spread of the 24 of them
|
||||
for minute in range(minutes):
|
||||
assert [sum(columns[minute]["values"][slot] for columns in columns_by_host.values()) for slot in range(4)] == [8, 8, 8, 0]
|
||||
|
||||
|
||||
def test_sum_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
minutes = 3
|
||||
start_ms = int((now - timedelta(minutes=minutes + 1)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_sum"
|
||||
|
||||
value_by_endpoint = {f"/endpoint-{endpoint:02d}": (100, 800)[endpoint // 8] + endpoint for endpoint in range(16)}
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"endpoint": endpoint},
|
||||
timestamp=now - timedelta(minutes=minutes - minute),
|
||||
value=value,
|
||||
temporality="Cumulative",
|
||||
type_="Sum",
|
||||
)
|
||||
for endpoint, value in value_by_endpoint.items()
|
||||
for minute in range(minutes)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", temporality="cumulative", group_by=["endpoint"])],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_log_bucket_options(0),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([128.0, 256.0, 512.0, 1024.0])
|
||||
|
||||
columns_by_endpoint = {
|
||||
endpoint: sorted(series["values"], key=lambda column: column["timestamp"])
|
||||
for endpoint, series in index_series_by_label(get_all_series(data, "A"), "endpoint").items()
|
||||
}
|
||||
assert len(columns_by_endpoint) == len(value_by_endpoint)
|
||||
|
||||
for endpoint, columns in columns_by_endpoint.items():
|
||||
occupied = 0 if int(endpoint.removeprefix("/endpoint-")) < 8 else 3
|
||||
assert [column["values"] for column in columns] == [[1 if slot == occupied else 0 for slot in range(5)]] * minutes
|
||||
|
||||
for minute in range(minutes):
|
||||
assert [sum(columns[minute]["values"][slot] for columns in columns_by_endpoint.values()) for slot in range(5)] == [8, 0, 0, 8, 0]
|
||||
|
||||
|
||||
def test_histogram_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_histogram"
|
||||
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
HISTOGRAM_FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "increase", "p50", group_by=["le"], filter_expression='endpoint = "/health"')],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a histogram's axis is its recorded `le` bounds exactly, since nothing is
|
||||
# known about what sits between two of them; `le=+Inf` has no finite bound
|
||||
# and is counted in the trailing overflow slot
|
||||
assert get_heatmap_buckets(data, "A") == [1000, 1500, 2000, 4000, 5000, 6000, 8000]
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert columns
|
||||
for column in columns:
|
||||
assert len(column["values"]) == 8
|
||||
assert all(count >= 0 for count in column["values"])
|
||||
assert any(sum(column["values"]) > 0 for column in columns)
|
||||
|
||||
|
||||
def test_linear_buckets(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_linear"
|
||||
|
||||
# 100 wide buckets: 100 lands on the first, 250 on the third, and 1500 is
|
||||
# past maxValue so it counts in the overflow
|
||||
values = [100, 250, 1500]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_linear_bucket_options(1000, 10),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# 200 is on the axis although nothing reached it, so the gap between 100 and
|
||||
# 300 renders as a gap
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([100.0, 200.0, 300.0])
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert [column["values"] for column in columns] == [
|
||||
[1, 0, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1],
|
||||
]
|
||||
|
||||
def test_zero_bucket(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_zero_bucket"
|
||||
|
||||
values = [0, 256, 1024]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_log_bucket_options(0),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a log axis cannot place a value at or below zero, so those share a bucket
|
||||
# of their own beneath the rest, and 512 is spanned above it
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([0.0, 256.0, 512.0, 1024.0])
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert [column["values"] for column in columns] == [
|
||||
[1, 0, 0, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
]
|
||||
|
||||
|
||||
def test_single_bucket(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_single_bucket"
|
||||
|
||||
values = [256, 256, 256]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# a single bucket leaves no gap to span
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == [256.0]
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [[1, 0], [1, 0], [1, 0]]
|
||||
|
||||
|
||||
# the values seeded below are 256 and 512, so each axis here runs from the bucket
|
||||
# holding 256 to the one holding 512 at that option's resolution: 16 log buckets
|
||||
# to the 2x, one bucket per 16x, or a linear bucket every maxValue/numBuckets
|
||||
@pytest.mark.parametrize(
|
||||
"bucket_options, expected_buckets",
|
||||
[
|
||||
(None, [256 * 2 ** (step / 16) for step in range(17)]),
|
||||
({"kind": "log", "spec": {}}, [256 * 2 ** (step / 16) for step in range(17)]),
|
||||
(build_log_bucket_options(4), [256 * 2 ** (step / 16) for step in range(17)]),
|
||||
(build_log_bucket_options(-4), [2**16]),
|
||||
(build_linear_bucket_options(1024), [step * 1024 / 60 for step in range(15, 31)]),
|
||||
(build_linear_bucket_options(1024, 512), [step * 1024 / 512 for step in range(128, 257)]),
|
||||
],
|
||||
ids=["absent", "log_defaults", "the_finest_scale", "the_coarsest_scale", "linear_without_num_buckets", "the_most_buckets"],
|
||||
)
|
||||
def test_bucket_option_limits(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
bucket_options: dict | None,
|
||||
expected_buckets: list[float],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_bucket_option_limits"
|
||||
|
||||
values = [256, 512]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=bucket_options,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx(expected_buckets)
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert len(columns) == len(values)
|
||||
for column in columns:
|
||||
assert len(column["values"]) == len(expected_buckets) + 1
|
||||
assert sum(column["values"]) == 1
|
||||
|
||||
|
||||
def test_formula_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_formula"
|
||||
|
||||
values = [100, 260, 295, 512, 1500]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
from_metric = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert from_metric.status_code == HTTPStatus.OK, from_metric.text
|
||||
|
||||
# a formula over the same query has to land its counts on the same axis
|
||||
from_formula = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
build_builder_query("A", metric_name, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A"),
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert from_formula.status_code == HTTPStatus.OK, from_formula.text
|
||||
|
||||
assert get_heatmap_buckets(from_formula.json(), "F1") == pytest.approx(get_heatmap_buckets(from_metric.json(), "A"))
|
||||
assert [column["values"] for column in get_heatmap_columns(from_formula.json(), "F1")] == [column["values"] for column in get_heatmap_columns(from_metric.json(), "A")]
|
||||
|
||||
|
||||
def test_promql_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - MINUTE_MS
|
||||
|
||||
# the file's three columns are one minute apart, and the first sits a minute
|
||||
# before the query window so the earliest step has something to increase over.
|
||||
# Every counter in it stays above its own rise across a window, below which
|
||||
# increase clips its back-extrapolation at the counter's zero point.
|
||||
insert_metrics(Metrics.load_from_file(HISTOGRAM_COUNTERS_FILE, base_time=datetime.fromtimestamp((start_ms - MINUTE_MS) / 1000, tz=UTC)))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": "sum by (le) (increase(heatmap_request_duration_bucket[2m]))", "step": 60}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == [1, 2, 4]
|
||||
|
||||
series = get_all_series(data, "A")
|
||||
assert len(series) == 1
|
||||
# `le` is what the bucket axis is read off, so it is never a group label
|
||||
assert series[0].get("labels") in (None, [])
|
||||
|
||||
# what the query returns per `le` is still cumulative across `le`, so each
|
||||
# count here is its own minus the one below it, and `le=+Inf` has no finite
|
||||
# bound to sit on and lands in the trailing slot. increase over a 2m window of
|
||||
# minutely samples extrapolates one minute's rise to two, hence the scaling.
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [[2, 6, 2, 2], [6, 0, 8, 4]]
|
||||
|
||||
|
||||
def test_promql_heatmap_without_le(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
metric = f"promql_heatmap_gauge_{uuid4().hex[:8]}"
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - MINUTE_MS
|
||||
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric,
|
||||
labels={"service": "api"},
|
||||
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=UTC),
|
||||
value=42.0,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for ts_ms in range(start_ms, end_ms + 1, MINUTE_MS)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": metric, "step": 60}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# the bucket axis of a promql heatmap comes from `le`, and a series without
|
||||
# it has no bucket to sit in
|
||||
assert get_heatmap_buckets(response.json(), "A") == []
|
||||
assert get_heatmap_columns(response.json(), "A") == []
|
||||
|
||||
|
||||
def test_clickhouse_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start = now - timedelta(minutes=2)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# a clickhouse statement names its bucket upper bound column `bucket`
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int(start.timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": (f"SELECT toDateTime({int(start.timestamp())}) AS ts, toFloat64(10) AS bucket, toFloat64(3) AS `__result_0` UNION ALL SELECT toDateTime({int(start.timestamp())}) AS ts, toFloat64(20) AS bucket, toFloat64(7) AS `__result_0`"),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == [10, 20]
|
||||
# a clickhouse heatmap takes no bucketOptions, so the counts land exactly
|
||||
# where the statement put them, plus the overflow slot
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [[3, 7, 0]]
|
||||
|
||||
|
||||
def test_cached_heatmap_matches_uncached(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
metric_name = "test_heatmap_cache"
|
||||
|
||||
# the first half sits 16x below the second, so the cached range and the fresh
|
||||
# one reach disjoint parts of the axis and neither may lose its counts
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=60 - minute),
|
||||
value=256 if minute < 15 else 4096,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(30)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = [build_builder_query("A", metric_name, "max", "max")]
|
||||
wide_start_ms = int((now - timedelta(minutes=60)).timestamp() * 1000)
|
||||
wide_end_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
warmup = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
wide_start_ms,
|
||||
int((now - timedelta(minutes=45)).timestamp() * 1000),
|
||||
query,
|
||||
request_type=RequestType.HEATMAP,
|
||||
no_cache=False,
|
||||
)
|
||||
assert warmup.status_code == HTTPStatus.OK, warmup.text
|
||||
|
||||
from_cache = make_query_request(signoz, token, wide_start_ms, wide_end_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
|
||||
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
|
||||
|
||||
uncached = make_query_request(signoz, token, wide_start_ms, wide_end_ms, query, request_type=RequestType.HEATMAP, no_cache=True)
|
||||
assert uncached.status_code == HTTPStatus.OK, uncached.text
|
||||
|
||||
assert_identical_query_response(from_cache, uncached)
|
||||
|
||||
# 256 and 4096 are 16x apart, which the axis covers at 16 buckets per 2x, and
|
||||
# every column holds the one value its minute recorded
|
||||
assert len(get_heatmap_buckets(uncached.json(), "A")) == 65
|
||||
assert [sum(column["values"]) for column in get_heatmap_columns(uncached.json(), "A")] == [1] * 30
|
||||
|
||||
|
||||
def test_histogram_rejects_bucket_options(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_histogram_with_bucket_options"
|
||||
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
HISTOGRAM_FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "increase", "p50")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_log_bucket_options(2),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "bucketOptions are not supported for histogram metrics" in get_error_message(response.json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_type",
|
||||
[RequestType.TIME_SERIES, RequestType.SCALAR, RequestType.RAW],
|
||||
ids=["time_series", "scalar", "raw"],
|
||||
)
|
||||
def test_bucket_options_outside_a_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
request_type: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_builder_query("A", MISSING_METRIC, "max", "max")],
|
||||
request_type=request_type,
|
||||
bucket_options=build_log_bucket_options(2),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "bucketOptions are only supported for heatmap requests" in get_error_message(response.json())
|
||||
|
||||
|
||||
def test_fill_gaps_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_builder_query("A", MISSING_METRIC, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
format_options={"formatTableResultForUI": False, "fillGaps": True},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "fillGaps is not supported for heatmap requests" in get_error_message(response.json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"queries",
|
||||
[
|
||||
[
|
||||
build_builder_query("A", MISSING_METRIC, "max", "max"),
|
||||
build_builder_query("B", MISSING_METRIC, "min", "min"),
|
||||
],
|
||||
[build_builder_query("A", MISSING_METRIC, "max", "max", disabled=True)],
|
||||
[
|
||||
build_builder_query("A", MISSING_METRIC, "max", "max"),
|
||||
build_builder_query("B", MISSING_METRIC, "min", "min", disabled=True),
|
||||
build_formula_query("F1", "B"),
|
||||
],
|
||||
[],
|
||||
],
|
||||
ids=["two_enabled_queries", "only_a_disabled_query", "a_formula_beside_an_enabled_query", "no_queries"],
|
||||
)
|
||||
def test_wrong_number_of_enabled_queries(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
queries: list[dict],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
queries,
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"queries, expected_message",
|
||||
[
|
||||
(
|
||||
[build_builder_query("A", MISSING_METRIC, "max", "max", functions=[build_function("absolute")])],
|
||||
"functions are not supported for heatmap requests",
|
||||
),
|
||||
(
|
||||
[
|
||||
build_builder_query("A", MISSING_METRIC, "max", "max", disabled=True, functions=[build_function("absolute")]),
|
||||
build_formula_query("F1", "A"),
|
||||
],
|
||||
"functions are not supported for heatmap requests",
|
||||
),
|
||||
(
|
||||
[
|
||||
build_builder_query("A", MISSING_METRIC, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A", functions=[build_function("absolute")]),
|
||||
],
|
||||
"functions are not supported for heatmap requests",
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": MISSING_METRIC, "timeAggregation": "max", "spaceAggregation": "max"}],
|
||||
"stepInterval": 60,
|
||||
"having": {"expression": "value > 1"},
|
||||
},
|
||||
}
|
||||
],
|
||||
"having is not supported for heatmap requests",
|
||||
),
|
||||
],
|
||||
ids=["functions_on_the_query", "functions_on_a_disabled_formula_input", "functions_on_the_formula", "having_on_the_query"],
|
||||
)
|
||||
def test_functions_and_having_are_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
queries: list[dict],
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
queries,
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert expected_message in get_error_message(response.json())
|
||||
|
||||
|
||||
def test_promql_rejects_bucket_options(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[{"type": "promql", "spec": {"name": "A", "query": MISSING_METRIC}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=build_log_bucket_options(2),
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "bucketOptions are not supported for promql heatmap requests" in get_error_message(response.json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bucket_options, expected_message",
|
||||
[
|
||||
({"kind": "quadratic", "spec": {}}, "invalid bucketOptions kind"),
|
||||
({"kind": "log"}, "bucketOptions spec is required"),
|
||||
({"kind": "linear"}, "bucketOptions spec is required"),
|
||||
({"kind": "linear", "spec": {"maxValue": 1000, "scale": 2}}, 'unknown field "scale" in linear buckets spec'),
|
||||
({"kind": "log", "spec": {"scale": 5}}, "scale must be between -4 and 4"),
|
||||
({"kind": "log", "spec": {"scale": -5}}, "scale must be between -4 and 4"),
|
||||
({"kind": "linear", "spec": {"maxValue": 0}}, "linear buckets need a finite maxValue greater than 0"),
|
||||
({"kind": "linear", "spec": {"maxValue": -10}}, "linear buckets need a finite maxValue greater than 0"),
|
||||
({"kind": "linear", "spec": {"maxValue": 1000, "numBuckets": 513}}, "numBuckets must be between 1 and 512"),
|
||||
],
|
||||
ids=[
|
||||
"unknown_kind",
|
||||
"log_without_a_spec",
|
||||
"linear_without_a_spec",
|
||||
"scale_under_the_linear_kind",
|
||||
"scale_above_the_maximum",
|
||||
"scale_below_the_minimum",
|
||||
"zero_max_value",
|
||||
"negative_max_value",
|
||||
"too_many_buckets",
|
||||
],
|
||||
)
|
||||
def test_malformed_bucket_options(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
bucket_options: dict,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_builder_query("A", MISSING_METRIC, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
bucket_options=bucket_options,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert expected_message in get_error_message(response.json())
|
||||
Reference in New Issue
Block a user