Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions src/datasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('buildDataFrames', () => {
name: 'Time',
type: FieldType.time,
config: {},
values: ['2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z'],
values: [new Date('2026-01-01T00:00:00Z').getTime(), new Date('2026-01-01T00:01:00Z').getTime()],
},
{
name: 'Value',
Expand Down Expand Up @@ -78,7 +78,7 @@ describe('buildDataFrames', () => {
name: 'Time',
type: FieldType.time,
config: {},
values: ['2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z'],
values: [new Date('2026-01-01T00:00:00Z').getTime(), new Date('2026-01-01T00:01:00Z').getTime()],
},
{
name: 'Value',
Expand All @@ -98,7 +98,7 @@ describe('buildDataFrames', () => {
name: 'Time',
type: FieldType.time,
config: {},
values: ['2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z'],
values: [new Date('2026-01-01T00:00:00Z').getTime(), new Date('2026-01-01T00:01:00Z').getTime()],
},
{
name: 'Value',
Expand Down Expand Up @@ -177,10 +177,59 @@ describe('buildDataFrames', () => {

const frames = buildDataFrames(tables, target, {}, {});

expect(frames[0].fields[0].values).toEqual(['2026-01-01T00:01:00Z', '2026-01-01T00:02:00Z']);
expect(frames[0].fields[0].values).toEqual([
new Date('2026-01-01T00:01:00Z').getTime(),
new Date('2026-01-01T00:02:00Z').getTime(),
]);
expect(frames[0].fields[1].values).toEqual([100, 200]);
});

it('converts distribution metrics into wide heatmap-rows frame', () => {
const tables = [
makeTable('http_service:request_latency_histogram', [
{
fields: { route: { type: 'string', value: '/api/v1' } },
points: {
timestamps: ['2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', '2026-01-01T00:02:00Z'],
values: [
{
metric_type: 'delta',
values: {
type: 'integer_distribution',
values: [
{ bins: [10, 100, 1000], counts: [0, 0, 0], squared_mean: 0, sum_of_samples: 0 },
{ bins: [10, 100, 1000], counts: [5, 20, 3], squared_mean: 0, sum_of_samples: 0 },
{ bins: [10, 100, 1000], counts: [8, 30, 7], squared_mean: 0, sum_of_samples: 0 },
],
},
},
],
},
},
]),
];

// Delta: 0th distribution is skipped. Wide frame with one field per bin.
// Timestamps are converted to epoch ms for heatmap compatibility.
const frames = buildDataFrames(tables, target, {}, {});

const timestamps = [new Date('2026-01-01T00:01:00Z').getTime(), new Date('2026-01-01T00:02:00Z').getTime()];
expect(frames).toEqual([
{
name: 'http_service:request_latency_histogram',
refId: 'A',
length: 2,
meta: { type: 'heatmap-rows' },
fields: [
{ name: 'Time', type: FieldType.time, config: {}, values: timestamps },
{ name: '10', type: FieldType.number, config: {}, values: [5, 8] },
{ name: '100', type: FieldType.number, config: {}, values: [20, 30] },
{ name: '1000', type: FieldType.number, config: {}, values: [3, 7] },
],
},
]);
});

it('throws when value dimensions do not match series names', () => {
const tables = [
makeTable('only_one_name', [
Expand Down
111 changes: 87 additions & 24 deletions src/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@ import {
DataSourceInstanceSettings,
createDataFrame,
FieldType,
DataFrameType,
MetricFindValue,
DataFrame,
} from '@grafana/data';

import type { OxqlQueryResult, OxqlTable, Silo, Project, TimeseriesQuery, TimeseriesSchema } from '@oxide/api';
import type {
Distributiondouble,
Distributionint64,
OxqlQueryResult,
OxqlTable,
Silo,
Project,
TimeseriesQuery,
TimeseriesSchema,
} from '@oxide/api';
import { OxqlQuery, OxqlOptions, OxqlVariableQuery, DEFAULT_QUERY, processResponseBody } from './types';
import { OxqlVariableSupport } from './variableSupport';
import { lastValueFrom } from 'rxjs';
Expand Down Expand Up @@ -316,32 +326,50 @@ export function buildDataFrames(
}`
);
}
// OxQL timestamps arrive as ISO strings; convert to epoch ms once
// and reuse across all value dimensions in this timeseries.
const timestamps = series.points.timestamps.map((t) => new Date(t as unknown as string).getTime());

for (const [idx, value] of series.points.values.entries()) {
// OxQL transparently converts cumulative metrics to deltas. However, the 0th value of
// each resulting delta series represents the cumulative total of the series from its start
// time to the timestamp of the 0th point. Because it's on a very different scale than the
// following values, we omit it here. Note that series resets also use cumulative values,
// but because they span a short time window, we don't omit them.
let dataValues = value.values.values;
let timestamps = series.points.timestamps;
let slicedTimestamps = timestamps;
let values = value.values.values;

// OxQL transparently converts cumulative metrics to deltas. The
// 0th value of each resulting delta series represents the
// cumulative total from the series start time, so it's on a very
// different scale — skip it.
if (value.metricType === 'delta') {
dataValues = dataValues.slice(1);
timestamps = timestamps.slice(1);
values = values.slice(1);
slicedTimestamps = timestamps.slice(1);
}

const valType = value.values.type;
if (valType === 'integer_distribution' || valType === 'double_distribution') {
// Distribution support in OxQL is minimal as of this writing.
// We can't align or aggregate histograms natively, and
// aggregating histograms is out of scope for the plugin. For
// working with histograms in Grafana, prefer to return a single
// distribution so that we can render a heatmap properly.
// Otherwise, Grafana may try to overlay multiple histograms in
// place, which isn't interpretable.
const dists = values as Array<Distribution | null>;
frames.push(buildDistributionFrame(seriesNames[idx], target.refId, slicedTimestamps, dists));
} else {
frames.push(
createDataFrame({
name: seriesNames[idx],
refId: target.refId,
fields: [
{ name: 'Time', values: slicedTimestamps, type: FieldType.time },
{
name: 'Value',
values: values,
labels: labels,
},
],
})
);
}
frames.push(
createDataFrame({
name: seriesNames[idx],
refId: target.refId,
fields: [
{ name: 'Time', values: timestamps, type: FieldType.time },
{
name: 'Value',
values: dataValues,
labels: labels,
},
],
})
);
}
}
}
Expand All @@ -355,6 +383,41 @@ function renderLegend(template: string, vars: Record<string, string>): string {
});
}

type Distribution = Distributiondouble | Distributionint64;

/**
* Convert distributions to a heatmap-rows data frame. Each bin becomes a
* field, labelled by the lower edge of the bin.
*/
function buildDistributionFrame(
name: string,
refId: string,
timestamps: number[],
dists: Array<Distribution | null>
): DataFrame {
// Oximeter histogram bin edges are defined by the metric schema, so all
// distributions within a timeseries share the same bins. We use the first
// non-null distribution to extract the bin edges for the heatmap fields.
const first = dists.find((d): d is Distribution => d !== null);
if (!first) {
return createDataFrame({ name, refId, fields: [{ name: 'Time', values: timestamps, type: FieldType.time }] });
}

const binFields = first.bins.map((edge, idx) => ({
name: String(edge),
type: FieldType.number,
config: {},
values: dists.map((dist) => (dist ? dist.counts[idx] : null)),
}));

return createDataFrame({
name,
refId,
meta: { type: DataFrameType.HeatmapRows },
fields: [{ name: 'Time', values: timestamps, type: FieldType.time }, ...binFields],
});
}

/**
* OxQL field types that require quoted literals in filter expressions.
* Strings, UUIDs, and IP addresses are quoted; integers and booleans
Expand Down
Loading