2025-04-01 10:38:02 +09:00

320 lines
9.2 KiB
TypeScript

import { capitalize } from 'lodash';
import { map, Observable, scan, takeWhile } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
DataFrame,
dataFrameFromJSON,
DataQueryRequest,
DataQueryResponse,
DataSourceInstanceSettings,
FieldCache,
FieldType,
LiveChannelScope,
LoadingState,
MutableDataFrame,
sortDataFrame,
ThresholdsConfig,
ThresholdsMode,
} from '@grafana/data';
import { cloneQueryResponse, combineResponses } from '@grafana/o11y-ds-frontend';
import { getGrafanaLiveSrv } from '@grafana/runtime';
import { SearchStreamingState } from './dataquery.gen';
import { DEFAULT_SPSS, TempoDatasource } from './datasource';
import { formatTraceQLResponse } from './resultTransformer';
import { SearchMetrics, TempoJsonData, TempoQuery } from './types';
import { stepToNanos } from './utils';
function getLiveStreamKey(): string {
return uuidv4();
}
export function doTempoSearchStreaming(
query: TempoQuery,
ds: TempoDatasource,
options: DataQueryRequest<TempoQuery>,
instanceSettings: DataSourceInstanceSettings<TempoJsonData>
): Observable<DataQueryResponse> {
const range = options.range;
let frames: DataFrame[] | undefined = undefined;
let state: LoadingState = LoadingState.NotStarted;
const requestTime = performance.now();
return getGrafanaLiveSrv()
.getStream<MutableDataFrame>({
scope: LiveChannelScope.DataSource,
namespace: ds.uid,
path: `search/${getLiveStreamKey()}`,
data: {
...query,
SpansPerSpanSet: query.spss ?? DEFAULT_SPSS,
timeRange: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
})
.pipe(
takeWhile((evt) => {
if ('message' in evt && evt?.message) {
const frameState: SearchStreamingState = evt.message.data.values[2][0];
if (frameState === SearchStreamingState.Done || frameState === SearchStreamingState.Error) {
return false;
}
}
return true;
}, true)
)
.pipe(
map((evt) => {
if ('message' in evt && evt?.message) {
const currentTime = performance.now();
const elapsedTime = currentTime - requestTime;
const messageFrame = dataFrameFromJSON(evt.message);
const fieldCache = new FieldCache(messageFrame);
const traces = fieldCache.getFieldByName('result')?.values[0];
const metrics = fieldCache.getFieldByName('metrics')?.values[0];
const frameState = fieldCache.getFieldByName('state')?.values[0];
const error = fieldCache.getFieldByName('error')?.values[0];
switch (frameState) {
case SearchStreamingState.Done:
state = LoadingState.Done;
break;
case SearchStreamingState.Streaming:
state = LoadingState.Streaming;
break;
case SearchStreamingState.Error:
throw new Error(error);
}
// The order of the frames is important. The metrics frame should always be the last frame.
// This is because the metrics frame is used to display the progress of the streaming query
// and we would like to display the results first.
frames = [
...formatTraceQLResponse(traces, instanceSettings, query.tableType),
metricsDataFrame(metrics, frameState, elapsedTime),
];
}
return {
data: frames || [],
state,
};
})
);
}
export function doTempoMetricsStreaming(
query: TempoQuery,
ds: TempoDatasource,
options: DataQueryRequest<TempoQuery>
): Observable<DataQueryResponse> {
const range = options.range;
const key = getLiveStreamKey();
let state: LoadingState = LoadingState.NotStarted;
const step = stepToNanos(query.step);
return getGrafanaLiveSrv()
.getStream<MutableDataFrame>({
scope: LiveChannelScope.DataSource,
namespace: ds.uid,
path: `metrics/${key}`,
data: {
...query,
step,
timeRange: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
})
.pipe(
takeWhile((evt) => {
if ('message' in evt && evt?.message) {
const frameState: SearchStreamingState = evt.message.data.values[2][0];
if (frameState === SearchStreamingState.Done || frameState === SearchStreamingState.Error) {
return false;
}
}
return true;
}, true),
map((evt) => {
let newResult: DataQueryResponse = { data: [], state: LoadingState.NotStarted };
if ('message' in evt && evt?.message) {
const messageFrame = dataFrameFromJSON(evt.message);
const fieldCache = new FieldCache(messageFrame);
const data = fieldCache.getFieldByName('result')?.values[0];
const frameState = fieldCache.getFieldByName('state')?.values[0];
const error = fieldCache.getFieldByName('error')?.values[0];
switch (frameState) {
case SearchStreamingState.Done:
state = LoadingState.Done;
break;
case SearchStreamingState.Streaming:
state = LoadingState.Streaming;
break;
case SearchStreamingState.Error:
throw new Error(error);
}
newResult = {
data: data?.map(dataFrameFromJSON) ?? [],
state,
};
}
return newResult;
}),
// Merge results on acc
scan((acc, curr) => {
if (!curr) {
return acc;
}
if (!acc) {
return cloneQueryResponse(curr);
}
return mergeFrames(acc, curr);
})
);
}
function mergeFrames(acc: DataQueryResponse, newResult: DataQueryResponse): DataQueryResponse {
const result = combineResponses(cloneQueryResponse(acc), newResult);
// Remove duplicate time field values for all frames
result.data = result.data.map((frame: DataFrame) => {
let newFrame = frame;
const timeFieldIndex = frame.fields.findIndex((f) => f.type === FieldType.time);
if (timeFieldIndex >= 0) {
removeDuplicateTimeFieldValues(frame, timeFieldIndex);
newFrame = sortDataFrame(frame, timeFieldIndex);
}
return newFrame;
});
result.state = newResult.state;
return result;
}
/**
* Remove duplicate time field values from the DataFrame. This is necessary because Tempo sends partial results to Grafana
* that we append to an existing DataFrame. This can result in duplicate values for the same timestamp so this function removes
* older values and keeps the latest value.
* @param accFrame
* @param timeFieldIndex
*/
function removeDuplicateTimeFieldValues(accFrame: DataFrame, timeFieldIndex: number) {
const duplicatesMap = accFrame.fields[timeFieldIndex].values.reduce((acc: Record<number, number[]>, value, index) => {
if (acc[value]) {
acc[value].push(index);
} else {
acc[value] = [index];
}
return acc;
}, {});
const indexesToRemove = Object.values(duplicatesMap)
.filter((indexes) => indexes.length > 1)
.map((indexes) => indexes.slice(1))
.flat();
accFrame.fields.forEach((field) => {
field.values = field.values.filter((_, index) => !indexesToRemove.includes(index));
});
}
function metricsDataFrame(metrics: SearchMetrics, state: SearchStreamingState, elapsedTime: number) {
const progressThresholds: ThresholdsConfig = {
steps: [
{
color: 'blue',
value: -Infinity,
},
{
color: 'green',
value: 75,
},
],
mode: ThresholdsMode.Absolute,
};
const frame: DataFrame = {
refId: 'streaming-progress',
name: 'Streaming Progress',
length: 1,
fields: [
{
name: 'state',
type: FieldType.string,
values: [capitalize(state.toString())],
config: {
displayNameFromDS: 'State',
},
},
{
name: 'elapsedTime',
type: FieldType.number,
values: [elapsedTime],
config: {
unit: 'ms',
displayNameFromDS: 'Elapsed Time',
},
},
{
name: 'totalBlocks',
type: FieldType.number,
values: [metrics.totalBlocks],
config: {
displayNameFromDS: 'Total Blocks',
},
},
{
name: 'completedJobs',
type: FieldType.number,
values: [metrics.completedJobs],
config: {
displayNameFromDS: 'Completed Jobs',
},
},
{
name: 'totalJobs',
type: FieldType.number,
values: [metrics.totalJobs],
config: {
displayNameFromDS: 'Total Jobs',
},
},
{
name: 'progress',
type: FieldType.number,
values: [
state === SearchStreamingState.Done ? 100 : ((metrics.completedJobs || 0) / (metrics.totalJobs || 1)) * 100,
],
config: {
displayNameFromDS: 'Progress',
unit: 'percent',
min: 0,
max: 100,
custom: {
cellOptions: {
type: 'gauge',
mode: 'gradient',
},
},
thresholds: progressThresholds,
},
},
],
meta: {
preferredVisualisationType: 'table',
},
};
return frame;
}