Logs panel: Add meta field to show total hits; add total hits to ElasticSearch plugin response (#104117)

* feat: Show total amount of hits in Elastic Search query

* Add test with multiple series.
This commit is contained in:
Florian Verdonck
2025-04-28 15:32:28 +02:00
committed by GitHub
parent ab25b911ac
commit 2c7c2088d9
7 changed files with 91 additions and 10 deletions

View File

@ -344,11 +344,19 @@ func processHits(dec *json.Decoder, sr *SearchResponse) error {
return err return err
} }
if tok == "hits" { switch tok {
case "hits":
if err := streamHitsArray(dec, sr); err != nil { if err := streamHitsArray(dec, sr); err != nil {
return err return err
} }
} else { case "total":
var total *SearchResponseHitsTotal
err := dec.Decode(&total)
if err != nil {
return err
}
sr.Hits.Total = total
default:
// ignore these fields as they are not used in the current implementation // ignore these fields as they are not used in the current implementation
err := skipUnknownField(dec) err := skipUnknownField(dec)
if err != nil { if err != nil {

View File

@ -44,9 +44,15 @@ func (r *SearchRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(root) return json.Marshal(root)
} }
type SearchResponseHitsTotal struct {
Value int `json:"value"`
Relation string `json:"relation"`
}
// SearchResponseHits represents search response hits // SearchResponseHits represents search response hits
type SearchResponseHits struct { type SearchResponseHits struct {
Hits []map[string]interface{} Hits []map[string]interface{}
Total *SearchResponseHitsTotal `json:"total"`
} }
// SearchResponse represents a search response // SearchResponse represents a search response

View File

@ -208,7 +208,12 @@ func processLogsResponse(res *es.SearchResponse, target *Query, configuredFields
frames := data.Frames{} frames := data.Frames{}
frame := data.NewFrame("", fields...) frame := data.NewFrame("", fields...)
setPreferredVisType(frame, data.VisTypeLogs) setPreferredVisType(frame, data.VisTypeLogs)
setLogsCustomMeta(frame, searchWords, stringToIntWithDefaultValue(target.Metrics[0].Settings.Get("limit").MustString(), defaultSize))
var total int
if res.Hits.Total != nil {
total = res.Hits.Total.Value
}
setLogsCustomMeta(frame, searchWords, stringToIntWithDefaultValue(target.Metrics[0].Settings.Get("limit").MustString(), defaultSize), total)
frames = append(frames, frame) frames = append(frames, frame)
queryRes.Frames = frames queryRes.Frames = frames
@ -1192,7 +1197,7 @@ func setPreferredVisType(frame *data.Frame, visType data.VisType) {
frame.Meta.PreferredVisualization = visType frame.Meta.PreferredVisualization = visType
} }
func setLogsCustomMeta(frame *data.Frame, searchWords map[string]bool, limit int) { func setLogsCustomMeta(frame *data.Frame, searchWords map[string]bool, limit int, total int) {
i := 0 i := 0
searchWordsList := make([]string, len(searchWords)) searchWordsList := make([]string, len(searchWords))
for searchWord := range searchWords { for searchWord := range searchWords {
@ -1212,6 +1217,7 @@ func setLogsCustomMeta(frame *data.Frame, searchWords map[string]bool, limit int
frame.Meta.Custom = map[string]interface{}{ frame.Meta.Custom = map[string]interface{}{
"searchWords": searchWordsList, "searchWords": searchWordsList,
"limit": limit, "limit": limit,
"total": total,
} }
} }

View File

@ -46,6 +46,7 @@ func TestProcessLogsResponse(t *testing.T) {
{ {
"aggregations": {}, "aggregations": {},
"hits": { "hits": {
"total": { "value": 2 },
"hits": [ "hits": [
{ {
"_id": "fdsfs", "_id": "fdsfs",
@ -107,7 +108,7 @@ func TestProcessLogsResponse(t *testing.T) {
logsFrame := frames[0] logsFrame := frames[0]
meta := logsFrame.Meta meta := logsFrame.Meta
require.Equal(t, map[string]any{"searchWords": []string{"hello", "message"}, "limit": 500}, meta.Custom) require.Equal(t, map[string]any{"searchWords": []string{"hello", "message"}, "limit": 500, "total": 2}, meta.Custom)
require.Equal(t, data.VisTypeLogs, string(meta.PreferredVisualization)) require.Equal(t, data.VisTypeLogs, string(meta.PreferredVisualization))
logsFieldMap := make(map[string]*data.Field) logsFieldMap := make(map[string]*data.Field)
@ -431,6 +432,7 @@ func TestProcessLogsResponse(t *testing.T) {
require.Equal(t, map[string]any{ require.Equal(t, map[string]any{
"searchWords": []string{"hello", "message"}, "searchWords": []string{"hello", "message"},
"limit": 500, "limit": 500,
"total": 109,
}, customMeta) }, customMeta)
}) })
} }
@ -703,7 +705,7 @@ func TestProcessRawDocumentResponse(t *testing.T) {
"responses": [ "responses": [
{ {
"hits": { "hits": {
"total": 100, "total": { "value": 100 },
"hits": [ "hits": [
{ {
"_id": "1", "_id": "1",
@ -3239,7 +3241,7 @@ func TestParseResponse(t *testing.T) {
}, },
{ {
"hits": { "hits": {
"total": 2, "total": { "value": 2 },
"hits": [ "hits": [
{ {
"_id": "5", "_id": "5",

View File

@ -10,7 +10,8 @@
// "searchWords": [ // "searchWords": [
// "hello", // "hello",
// "message" // "message"
// ] // ],
// "total": 81
// }, // },
// "preferredVisualisationType": "logs" // "preferredVisualisationType": "logs"
// } // }
@ -45,7 +46,8 @@
"searchWords": [ "searchWords": [
"hello", "hello",
"message" "message"
] ],
"total": 81
}, },
"preferredVisualisationType": "logs" "preferredVisualisationType": "logs"
}, },

View File

@ -34,6 +34,7 @@ import {
filterLogLevels, filterLogLevels,
getSeriesProperties, getSeriesProperties,
LIMIT_LABEL, LIMIT_LABEL,
TOTAL_LABEL,
logRowToSingleRowDataFrame, logRowToSingleRowDataFrame,
logSeriesToLogsModel, logSeriesToLogsModel,
queryLogsSample, queryLogsSample,
@ -492,6 +493,52 @@ describe('dataFrameToLogsModel', () => {
}); });
}); });
it('given one series with total as custom meta property should return correct total', () => {
const series: DataFrame[] = [
createDataFrame({
fields: [],
meta: {
custom: {
total: 9999,
},
},
}),
];
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.meta![0]).toMatchObject({
label: TOTAL_LABEL,
value: 9999,
kind: LogsMetaKind.Number,
});
});
it('given multiple series with total as custom meta property should return correct total', () => {
const series: DataFrame[] = [
createDataFrame({
fields: [],
meta: {
custom: {
total: 4,
},
},
}),
createDataFrame({
fields: [],
meta: {
custom: {
total: 5,
},
},
}),
];
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.meta![0]).toMatchObject({
label: TOTAL_LABEL,
value: 9,
kind: LogsMetaKind.Number,
});
});
it('should return the expected meta when the line limit is reached', () => { it('should return the expected meta when the line limit is reached', () => {
const series: DataFrame[] = getTestDataFrame(); const series: DataFrame[] = getTestDataFrame();
series[0].meta = { series[0].meta = {

View File

@ -51,6 +51,7 @@ import { createLogRowsMap, getLogLevel, getLogLevelFromKey, sortInAscendingOrder
export const LIMIT_LABEL = 'Line limit'; export const LIMIT_LABEL = 'Line limit';
export const COMMON_LABELS = 'Common labels'; export const COMMON_LABELS = 'Common labels';
export const TOTAL_LABEL = 'Total lines';
export const LogLevelColor = { export const LogLevelColor = {
[LogLevel.critical]: colors[7], [LogLevel.critical]: colors[7],
@ -492,6 +493,15 @@ export function logSeriesToLogsModel(
}); });
} }
const totalValue = logSeries.reduce((acc, series) => (acc += series.meta?.custom?.total), 0);
if (totalValue > 0) {
meta.push({
label: TOTAL_LABEL,
value: totalValue,
kind: LogsMetaKind.Number,
});
}
let totalBytes = 0; let totalBytes = 0;
const queriesVisited: { [refId: string]: boolean } = {}; const queriesVisited: { [refId: string]: boolean } = {};
// To add just 1 error message // To add just 1 error message