Skip to content

Commit 79efcf6

Browse files
hatemosphereclaude
andcommitted
fix: log panel flickering in Grafana 12.3+ with newLogsPanel virtualization
Grafana 12.3 introduced a virtualized log list (newLogsPanel, GA) that caches row heights by LogRowModel.uid. The uid is derived from the frame's "id" field: uid = "${refId}_${idField.values[row]}". Three issues caused flickering: 1. No "id" field in frames → all rows shared uid "A_null" → height cache oscillated between different row heights → infinite resetAfterIndex loop (13k+ calls/sec = visible flickering). Fix: generate unique id per row, matching ES behavior. 2. sortPropNames ignored shouldSortLogMessageField → logMessageField sorted alphabetically instead of first → Grafana picked wrong body field → wrong height measurement. Fix: match ES datasource behavior. 3. processResponse.ts injected synthetic $qw_message field, adding an extra field to frames. Fix: remove injection, Go backend now handles field ordering. Also fix data links spread bug (comma operator). Additionally migrated from deprecated getDataProvider() to getSupplementaryRequest() API, matching ES/Loki datasources. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5b0a6ae commit 79efcf6

File tree

3 files changed

+38
-232
lines changed

3 files changed

+38
-232
lines changed

pkg/quickwit/response_parser.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,17 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields
148148
}
149149
}
150150

151+
// Always set a unique id per row. Grafana's virtualized log panel uses
152+
// LogRowModel.uid (derived from the "id" field) as a cache key for
153+
// row height measurements. Without unique ids, rows sharing the same
154+
// cache key cause an infinite resetAfterIndex loop. The source index
155+
// may have an "id" field with non-unique values, so always overwrite.
156+
doc["id"] = fmt.Sprintf("%d", hitIdx)
157+
151158
docs[hitIdx] = doc
152159
}
153160

161+
propNames["id"] = true
154162
sortedPropNames := sortPropNames(propNames, configuredFields, true)
155163
fields := processDocsToDataFrameFields(docs, sortedPropNames, configuredFields)
156164

@@ -1074,18 +1082,25 @@ func flatten(target map[string]interface{}) map[string]interface{} {
10741082
// if shouldSortLogMessageField is true, and rest of propNames are ordered alphabetically
10751083
func sortPropNames(propNames map[string]bool, configuredFields es.ConfiguredFields, shouldSortLogMessageField bool) []string {
10761084
hasTimeField := false
1085+
hasLogMessageField := false
10771086

10781087
var sortedPropNames []string
10791088
for k := range propNames {
10801089
if configuredFields.TimeField != "" && k == configuredFields.TimeField {
10811090
hasTimeField = true
1091+
} else if shouldSortLogMessageField && configuredFields.LogMessageField != "" && k == configuredFields.LogMessageField {
1092+
hasLogMessageField = true
10821093
} else {
10831094
sortedPropNames = append(sortedPropNames, k)
10841095
}
10851096
}
10861097

10871098
sort.Strings(sortedPropNames)
10881099

1100+
if hasLogMessageField {
1101+
sortedPropNames = append([]string{configuredFields.LogMessageField}, sortedPropNames...)
1102+
}
1103+
10891104
if hasTimeField {
10901105
sortedPropNames = append([]string{configuredFields.TimeField}, sortedPropNames...)
10911106
}
@@ -1100,6 +1115,9 @@ func findTheFirstNonNilDocValueForPropName(docs []map[string]interface{}, propNa
11001115
return doc[propName]
11011116
}
11021117
}
1118+
if len(docs) == 0 {
1119+
return nil
1120+
}
11031121
return docs[0][propName]
11041122
}
11051123

src/datasource/processResponse.ts

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DataFrame, DataLink, DataQueryRequest, DataQueryResponse, Field, FieldType } from "@grafana/data";
1+
import { DataFrame, DataLink, DataQueryRequest, DataQueryResponse } from "@grafana/data";
22
import { getDataSourceSrv } from "@grafana/runtime";
33
import { BaseQuickwitDataSource } from './base';
44
import { DataLinkConfig, ElasticsearchQuery } from "../types";
@@ -16,48 +16,16 @@ export function getQueryResponseProcessor(datasource: BaseQuickwitDataSource, re
1616
}
1717
};
1818
}
19-
function getCustomFieldName(fieldname: string) { return `$qw_${fieldname}`; }
19+
2020
export function processLogsDataFrame(datasource: BaseQuickwitDataSource, dataFrame: DataFrame) {
21-
// Ignore log volume dataframe, no need to add links or a displayed message field.
21+
// Ignore log volume dataframe, no need to add links.
2222
if (!dataFrame.refId || dataFrame.refId.startsWith('log-volume')) {
2323
return;
2424
}
2525
// Skip empty dataframes
2626
if (dataFrame.length===0 || dataFrame.fields.length === 0) {
2727
return;
2828
}
29-
if (datasource.logMessageField) {
30-
const messageFields = datasource.logMessageField.split(',');
31-
let field_idx_list = [];
32-
for (const messageField of messageFields) {
33-
const field_idx = dataFrame.fields.findIndex((field) => field.name === messageField);
34-
if (field_idx !== -1) {
35-
field_idx_list.push(field_idx);
36-
}
37-
}
38-
const displayedMessages = Array(dataFrame.length);
39-
for (let idx = 0; idx < dataFrame.length; idx++) {
40-
let displayedMessage = "";
41-
// If we have only one field, we assume the field name is obvious for the user and we don't need to show it.
42-
if (field_idx_list.length === 1) {
43-
displayedMessage = `${dataFrame.fields[field_idx_list[0]].values[idx]}`;
44-
} else {
45-
for (const field_idx of field_idx_list) {
46-
displayedMessage += ` ${dataFrame.fields[field_idx].name}=${dataFrame.fields[field_idx].values[idx]}`;
47-
}
48-
}
49-
displayedMessages[idx] = displayedMessage.trim();
50-
}
51-
52-
const newField: Field = {
53-
name: getCustomFieldName('message'),
54-
type: FieldType.string,
55-
config: {},
56-
values: displayedMessages,
57-
};
58-
const [timestamp, ...rest] = dataFrame.fields;
59-
dataFrame.fields = [timestamp, newField, ...rest];
60-
}
6129

6230
if (!datasource.dataLinks.length) {
6331
return;
@@ -71,9 +39,10 @@ export function processLogsDataFrame(datasource: BaseQuickwitDataSource, dataFra
7139
}
7240

7341
field.config = field.config || {};
74-
field.config.links = [...(field.config.links || [], linksToApply.map(generateDataLink))];
42+
field.config.links = [...(field.config.links || []), ...linksToApply.map(generateDataLink)];
7543
}
7644
}
45+
7746
function generateDataLink(linkConfig: DataLinkConfig): DataLink {
7847
const dataSourceSrv = getDataSourceSrv();
7948

Lines changed: 15 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,28 @@
11
import {
2-
DataFrame,
32
DataQueryRequest,
4-
DataQueryResponse,
5-
DataSourceApi,
6-
DataSourceJsonData,
73
DataSourceWithSupplementaryQueriesSupport,
8-
FieldColorModeId,
9-
FieldType,
10-
LoadingState,
11-
LogLevel,
12-
LogsVolumeCustomMetaData,
13-
LogsVolumeType,
144
SupplementaryQueryType,
155
} from '@grafana/data';
16-
import { BarAlignment, DataQuery, GraphDrawStyle, StackingMode } from "@grafana/schema";
17-
import { colors } from "@grafana/ui";
18-
import { getIntervalInfo } from '@/utils/time';
19-
import { cloneDeep, groupBy } from "lodash";
20-
import { Observable, isObservable, from } from 'rxjs';
6+
import { cloneDeep } from "lodash";
217
import { BucketAggregation, ElasticsearchQuery } from '@/types';
228
import { BaseQuickwitDataSourceConstructor } from './base';
239

2410
export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-';
2511

2612
export function withSupplementaryQueries<T extends BaseQuickwitDataSourceConstructor> ( Base: T ){
2713
return class DSWithSupplementaryQueries extends Base implements DataSourceWithSupplementaryQueriesSupport<ElasticsearchQuery> {
14+
2815
/**
29-
* Returns an observable that will be used to fetch supplementary data based on the provided
30-
* supplementary query type and original request.
16+
* Returns a DataQueryRequest for the supplementary query type.
17+
* Grafana's Explore layer handles the Observable lifecycle.
3118
*/
32-
getDataProvider(
19+
getSupplementaryRequest(
3320
type: SupplementaryQueryType,
3421
request: DataQueryRequest<ElasticsearchQuery>
35-
): Observable<DataQueryResponse> | undefined {
36-
if (!this.getSupportedSupplementaryQueryTypes().includes(type)) {
37-
return undefined;
38-
}
22+
): DataQueryRequest<ElasticsearchQuery> | undefined {
3923
switch (type) {
4024
case SupplementaryQueryType.LogsVolume:
41-
return this.getLogsVolumeDataProvider(request);
25+
return this.getLogsVolumeRequest(request);
4226
default:
4327
return undefined;
4428
}
@@ -55,18 +39,15 @@ export function withSupplementaryQueries<T extends BaseQuickwitDataSourceConstru
5539
* Returns a supplementary query to be used to fetch supplementary data based on the provided type and original query.
5640
* If provided query is not suitable for provided supplementary query type, undefined should be returned.
5741
*/
58-
// FIXME: options should be of type SupplementaryQueryOptions but this type is not public.
59-
getSupplementaryQuery(options: any, query: ElasticsearchQuery): ElasticsearchQuery | undefined {
42+
getSupplementaryQuery(options: { type: SupplementaryQueryType }, query: ElasticsearchQuery): ElasticsearchQuery | undefined {
6043
if (!this.getSupportedSupplementaryQueryTypes().includes(options.type)) {
6144
return undefined;
6245
}
6346

64-
let isQuerySuitable = false;
65-
6647
switch (options.type) {
67-
case SupplementaryQueryType.LogsVolume:
48+
case SupplementaryQueryType.LogsVolume: {
6849
// it has to be a logs-producing range-query
69-
isQuerySuitable = !!(query.metrics?.length === 1 && query.metrics[0].type === 'logs');
50+
const isQuerySuitable = !!(query.metrics?.length === 1 && query.metrics[0].type === 'logs');
7051
if (!isQuerySuitable) {
7152
return undefined;
7253
}
@@ -103,13 +84,16 @@ export function withSupplementaryQueries<T extends BaseQuickwitDataSourceConstru
10384
metrics: [{ type: 'count', id: '1' }],
10485
bucketAggs,
10586
};
87+
}
10688

10789
default:
10890
return undefined;
10991
}
11092
}
11193

112-
getLogsVolumeDataProvider(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> | undefined {
94+
private getLogsVolumeRequest(
95+
request: DataQueryRequest<ElasticsearchQuery>
96+
): DataQueryRequest<ElasticsearchQuery> | undefined {
11397
const logsVolumeRequest = cloneDeep(request);
11498
const targets = logsVolumeRequest.targets
11599
.map((target) => this.getSupplementaryQuery({ type: SupplementaryQueryType.LogsVolume }, target))
@@ -119,172 +103,7 @@ export function withSupplementaryQueries<T extends BaseQuickwitDataSourceConstru
119103
return undefined;
120104
}
121105

122-
return queryLogsVolume(
123-
this,
124-
{ ...logsVolumeRequest, targets },
125-
{
126-
range: request.range,
127-
targets: request.targets,
128-
extractLevel: (dataFrame: any) => getLogLevelFromKey(dataFrame || ''),
129-
}
130-
);
131-
}
132-
};
133-
}
134-
135-
// Copy/pasted from grafana/data as it is deprecated there.
136-
function getLogLevelFromKey(dataframe: DataFrame): LogLevel {
137-
const name = dataframe.fields[1].config.displayNameFromDS || ``;
138-
const level = (LogLevel as any)[name.toString().toLowerCase()];
139-
if (level) {
140-
return level;
141-
}
142-
return LogLevel.unknown;
143-
}
144-
145-
/**
146-
* Creates an observable, which makes requests to get logs volume and aggregates results.
147-
*/
148-
149-
export function queryLogsVolume<TQuery extends DataQuery, TOptions extends DataSourceJsonData>(
150-
datasource: DataSourceApi<TQuery, TOptions>,
151-
logsVolumeRequest: DataQueryRequest<TQuery>,
152-
options: any
153-
): Observable<DataQueryResponse> {
154-
const timespan = options.range.to.valueOf() - options.range.from.valueOf();
155-
const intervalInfo = getIntervalInfo(timespan, 400);
156-
157-
logsVolumeRequest.interval = intervalInfo.interval;
158-
logsVolumeRequest.scopedVars.__interval = { value: intervalInfo.interval, text: intervalInfo.interval };
159-
160-
if (intervalInfo.intervalMs !== undefined) {
161-
logsVolumeRequest.intervalMs = intervalInfo.intervalMs;
162-
logsVolumeRequest.scopedVars.__interval_ms = { value: intervalInfo.intervalMs, text: intervalInfo.intervalMs };
106+
return { ...logsVolumeRequest, targets };
163107
}
164-
165-
logsVolumeRequest.hideFromInspector = true;
166-
167-
return new Observable((observer) => {
168-
let logsVolumeData: DataFrame[] = [];
169-
observer.next({
170-
state: LoadingState.Loading,
171-
error: undefined,
172-
data: [],
173-
});
174-
175-
const queryResponse = datasource.query(logsVolumeRequest);
176-
const queryObservable = isObservable(queryResponse) ? queryResponse : from(queryResponse);
177-
178-
const subscription = queryObservable.subscribe({
179-
complete: () => {
180-
observer.complete();
181-
},
182-
next: (dataQueryResponse: DataQueryResponse) => {
183-
const { error } = dataQueryResponse;
184-
if (error !== undefined) {
185-
observer.next({
186-
state: LoadingState.Error,
187-
error,
188-
data: [],
189-
});
190-
observer.error(error);
191-
} else {
192-
const framesByRefId = groupBy(dataQueryResponse.data, 'refId');
193-
logsVolumeData = dataQueryResponse.data.map((dataFrame) => {
194-
let sourceRefId = dataFrame.refId || '';
195-
if (sourceRefId.startsWith('log-volume-')) {
196-
sourceRefId = sourceRefId.substr('log-volume-'.length);
197-
}
198-
199-
const logsVolumeCustomMetaData: LogsVolumeCustomMetaData = {
200-
logsVolumeType: LogsVolumeType.FullRange,
201-
absoluteRange: { from: options.range.from.valueOf(), to: options.range.to.valueOf() },
202-
datasourceName: datasource.name,
203-
sourceQuery: options.targets.find((dataQuery: any) => dataQuery.refId === sourceRefId)!,
204-
};
205-
206-
dataFrame.meta = {
207-
...dataFrame.meta,
208-
custom: {
209-
...dataFrame.meta?.custom,
210-
...logsVolumeCustomMetaData,
211-
},
212-
};
213-
return updateLogsVolumeConfig(dataFrame, options.extractLevel, framesByRefId[dataFrame.refId].length === 1);
214-
});
215-
216-
observer.next({
217-
state: dataQueryResponse.state,
218-
error: undefined,
219-
data: logsVolumeData,
220-
});
221-
}
222-
},
223-
error: (error: any) => {
224-
observer.next({
225-
state: LoadingState.Error,
226-
error: error,
227-
data: [],
228-
});
229-
observer.error(error);
230-
},
231-
});
232-
return () => {
233-
subscription?.unsubscribe();
234-
};
235-
});
236-
}
237-
const updateLogsVolumeConfig = (
238-
dataFrame: DataFrame,
239-
extractLevel: (dataFrame: DataFrame) => LogLevel,
240-
oneLevelDetected: boolean
241-
): DataFrame => {
242-
dataFrame.fields = dataFrame.fields.map((field) => {
243-
if (field.type === FieldType.number) {
244-
field.config = {
245-
...field.config,
246-
...getLogVolumeFieldConfig(extractLevel(dataFrame), oneLevelDetected),
247-
};
248-
}
249-
return field;
250-
});
251-
return dataFrame;
252-
};
253-
const LogLevelColor = {
254-
[LogLevel.critical]: colors[7],
255-
[LogLevel.warning]: colors[1],
256-
[LogLevel.error]: colors[4],
257-
[LogLevel.info]: colors[0],
258-
[LogLevel.debug]: colors[5],
259-
[LogLevel.trace]: colors[2],
260-
[LogLevel.unknown]: '#8e8e8e' // or '#bdc4cd',
261-
};
262-
/**
263-
* Returns field configuration used to render logs volume bars
264-
*/
265-
function getLogVolumeFieldConfig(level: LogLevel, oneLevelDetected: boolean) {
266-
const name = oneLevelDetected && level === LogLevel.unknown ? 'logs' : level;
267-
const color = LogLevelColor[level];
268-
return {
269-
displayNameFromDS: name,
270-
color: {
271-
mode: FieldColorModeId.Fixed,
272-
fixedColor: color,
273-
},
274-
custom: {
275-
drawStyle: GraphDrawStyle.Bars,
276-
barAlignment: BarAlignment.Center,
277-
lineColor: color,
278-
pointColor: color,
279-
fillColor: color,
280-
lineWidth: 1,
281-
fillOpacity: 100,
282-
stacking: {
283-
mode: StackingMode.Normal,
284-
group: 'A',
285-
},
286-
},
287108
};
288109
}
289-
290-

0 commit comments

Comments
 (0)