diff --git a/src/datasource.test.ts b/src/datasource.test.ts index 11247a6..adbe87e 100644 --- a/src/datasource.test.ts +++ b/src/datasource.test.ts @@ -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', @@ -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', @@ -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', @@ -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', [ diff --git a/src/datasource.ts b/src/datasource.ts index 2f81dfb..951f2d9 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -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'; @@ -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; + 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, - }, - ], - }) - ); } } } @@ -355,6 +383,41 @@ function renderLegend(template: string, vars: Record): 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 +): 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