Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit c269f24

Browse files
authored
Merge pull request #174 from hatemosphere/fix/log-panel-flickering
fix: log panel flickering in Grafana 12.3+ with newLogsPanel virtualization
2 parents 5ed484d + 7ca50de commit c269f24

3 files changed

Lines changed: 60 additions & 204 deletions

File tree

pkg/quickwit/response_parser.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,27 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields
160160
}
161161
}
162162

163+
// Always set a unique id per row. Grafana's virtualized log panel uses
164+
// LogRowModel.uid (derived from the "id" field) as a cache key for
165+
// row height measurements. Without unique ids, rows sharing the same
166+
// cache key cause an infinite resetAfterIndex loop. Prefer the hit's
167+
// own _index/_id when present (matches built-in ES datasource), fall
168+
// back to the row index otherwise.
169+
hitIndex, _ := hit["_index"].(string)
170+
hitID, _ := hit["_id"].(string)
171+
switch {
172+
case hitIndex != "" && hitID != "":
173+
doc["id"] = hitIndex + "#" + hitID
174+
case hitID != "":
175+
doc["id"] = hitID
176+
default:
177+
doc["id"] = strconv.Itoa(hitIdx)
178+
}
179+
163180
docs[hitIdx] = doc
164181
}
165182

183+
propNames["id"] = true
166184
sortedPropNames := sortPropNames(propNames, configuredFields, true)
167185
fields := processDocsToDataFrameFields(docs, sortedPropNames, configuredFields)
168186

@@ -1086,18 +1104,25 @@ func flatten(target map[string]interface{}) map[string]interface{} {
10861104
// if shouldSortLogMessageField is true, and rest of propNames are ordered alphabetically
10871105
func sortPropNames(propNames map[string]bool, configuredFields es.ConfiguredFields, shouldSortLogMessageField bool) []string {
10881106
hasTimeField := false
1107+
hasLogMessageField := false
10891108

10901109
var sortedPropNames []string
10911110
for k := range propNames {
10921111
if configuredFields.TimeField != "" && k == configuredFields.TimeField {
10931112
hasTimeField = true
1113+
} else if shouldSortLogMessageField && configuredFields.LogMessageField != "" && k == configuredFields.LogMessageField {
1114+
hasLogMessageField = true
10941115
} else {
10951116
sortedPropNames = append(sortedPropNames, k)
10961117
}
10971118
}
10981119

10991120
sort.Strings(sortedPropNames)
11001121

1122+
if hasLogMessageField {
1123+
sortedPropNames = append([]string{configuredFields.LogMessageField}, sortedPropNames...)
1124+
}
1125+
11011126
if hasTimeField {
11021127
sortedPropNames = append([]string{configuredFields.TimeField}, sortedPropNames...)
11031128
}
@@ -1112,6 +1137,9 @@ func findTheFirstNonNilDocValueForPropName(docs []map[string]interface{}, propNa
11121137
return doc[propName]
11131138
}
11141139
}
1140+
if len(docs) == 0 {
1141+
return nil
1142+
}
11151143
return docs[0][propName]
11161144
}
11171145

pkg/quickwit/response_parser_test.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -253,22 +253,31 @@ func TestProcessLogsResponse(t *testing.T) {
253253
require.Len(t, dataframes, 1)
254254
frame := dataframes[0]
255255

256-
require.Equal(t, 11, len(frame.Fields))
256+
require.Equal(t, 12, len(frame.Fields))
257257
// Fields have the correct length
258258
require.Equal(t, 2, frame.Fields[0].Len())
259+
260+
fieldMap := make(map[string]*data.Field)
261+
for _, f := range frame.Fields {
262+
fieldMap[f.Name] = f
263+
}
264+
259265
// First field is timeField
266+
require.Equal(t, "@timestamp", frame.Fields[0].Name)
260267
require.Equal(t, data.FieldTypeNullableTime, frame.Fields[0].Type())
268+
// Second field is logMessageField
269+
require.Equal(t, "line", frame.Fields[1].Name)
261270
require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type())
262-
require.Equal(t, "line", frame.Fields[4].Name)
263-
// Correctly uses string types
264-
require.Equal(t, data.FieldTypeNullableString, frame.Fields[1].Type())
271+
// Synthetic id field added for log row uid stability
272+
require.Contains(t, fieldMap, "id")
273+
require.Equal(t, data.FieldTypeNullableString, fieldMap["id"].Type())
265274
// Correctly detects float64 types
266-
require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[2].Type())
275+
require.Equal(t, data.FieldTypeNullableFloat64, fieldMap["float"].Type())
267276
// Correctly flattens fields
268-
require.Equal(t, "nested.field.double_nested", frame.Fields[7].Name)
269-
require.Equal(t, data.FieldTypeNullableString, frame.Fields[7].Type())
277+
require.Contains(t, fieldMap, "nested.field.double_nested")
278+
require.Equal(t, data.FieldTypeNullableString, fieldMap["nested.field.double_nested"].Type())
270279
// Correctly detects type even if first value is null
271-
require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[9].Type())
280+
require.Equal(t, data.FieldTypeNullableJSON, fieldMap["shapes"].Type())
272281
})
273282
}
274283

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
}
@@ -104,13 +85,16 @@ export function withSupplementaryQueries<T extends BaseQuickwitDataSourceConstru
10485
bucketAggs,
10586
filters: query.filters,
10687
};
88+
}
10789

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

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

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

0 commit comments

Comments
 (0)