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

Commit 1c13e5c

Browse files
authored
Merge pull request #183 from xrl/165-log-message-fallback
Show useful log message by default for OTEL logs
2 parents 51b1f4d + 3f49924 commit 1c13e5c

2 files changed

Lines changed: 320 additions & 27 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { DataFrame, Field, FieldType } from '@grafana/data';
2+
import { processLogsDataFrame } from './processResponse';
3+
4+
function makeField(name: string, type: FieldType, values: any[]): Field {
5+
return { name, type, config: {}, values };
6+
}
7+
8+
function makeDataFrame(fields: Field[], refId = 'A'): DataFrame {
9+
return {
10+
refId,
11+
fields,
12+
length: fields[0]?.values.length ?? 0,
13+
};
14+
}
15+
16+
function makeDatasource(overrides: { logMessageField?: string; dataLinks?: any[] } = {}) {
17+
return {
18+
logMessageField: overrides.logMessageField ?? '',
19+
dataLinks: overrides.dataLinks ?? [],
20+
} as any;
21+
}
22+
23+
describe('processLogsDataFrame', () => {
24+
describe('with logMessageField configured', () => {
25+
it('uses configured field value', () => {
26+
const ds = makeDatasource({ logMessageField: 'line' });
27+
const df = makeDataFrame([
28+
makeField('timestamp', FieldType.time, [1000, 2000]),
29+
makeField('line', FieldType.string, ['hello world', 'goodbye world']),
30+
makeField('level', FieldType.string, ['info', 'error']),
31+
]);
32+
33+
processLogsDataFrame(ds, df);
34+
35+
expect(df.fields[1].name).toBe('$qw_message');
36+
expect(df.fields[1].values).toEqual(['hello world', 'goodbye world']);
37+
});
38+
39+
it('joins multiple configured fields with key=value format', () => {
40+
const ds = makeDatasource({ logMessageField: 'method,path,status' });
41+
const df = makeDataFrame([
42+
makeField('timestamp', FieldType.time, [1000]),
43+
makeField('method', FieldType.string, ['GET']),
44+
makeField('path', FieldType.string, ['/blog']),
45+
makeField('status', FieldType.string, ['200']),
46+
]);
47+
48+
processLogsDataFrame(ds, df);
49+
50+
expect(df.fields[1].name).toBe('$qw_message');
51+
expect(df.fields[1].values[0]).toBe('method=GET path=/blog status=200');
52+
});
53+
54+
it('falls back when configured field does not exist', () => {
55+
const ds = makeDatasource({ logMessageField: 'nonexistent' });
56+
const df = makeDataFrame([
57+
makeField('timestamp', FieldType.time, [1000]),
58+
makeField('body.message', FieldType.string, ['the real message']),
59+
]);
60+
61+
processLogsDataFrame(ds, df);
62+
63+
expect(df.fields[1].name).toBe('$qw_message');
64+
expect(df.fields[1].values[0]).toBe('the real message');
65+
});
66+
67+
it('falls back when configured field value is empty for a row', () => {
68+
const ds = makeDatasource({ logMessageField: 'line' });
69+
const df = makeDataFrame([
70+
makeField('timestamp', FieldType.time, [1000, 2000]),
71+
makeField('line', FieldType.string, ['has content', '']),
72+
makeField('body.message', FieldType.string, ['', 'fallback message']),
73+
]);
74+
75+
processLogsDataFrame(ds, df);
76+
77+
expect(df.fields[1].name).toBe('$qw_message');
78+
expect(df.fields[1].values[0]).toBe('has content');
79+
expect(df.fields[1].values[1]).toBe('fallback message');
80+
});
81+
});
82+
83+
describe('OTEL fallback (no logMessageField)', () => {
84+
it('picks body.message when present', () => {
85+
const ds = makeDatasource();
86+
const df = makeDataFrame([
87+
makeField('timestamp', FieldType.time, [1000]),
88+
makeField('body.message', FieldType.string, ['GET /assets/app.js HTTP/1.1 200']),
89+
makeField('body.stream', FieldType.string, ['stdout']),
90+
]);
91+
92+
processLogsDataFrame(ds, df);
93+
94+
expect(df.fields[1].name).toBe('$qw_message');
95+
expect(df.fields[1].values[0]).toBe('GET /assets/app.js HTTP/1.1 200');
96+
});
97+
98+
it('picks attributes.message when body.message is absent', () => {
99+
const ds = makeDatasource();
100+
const df = makeDataFrame([
101+
makeField('timestamp', FieldType.time, [1000]),
102+
makeField('attributes.message', FieldType.string, ['SSO user already exists']),
103+
makeField('attributes.severity', FieldType.string, ['INFO']),
104+
]);
105+
106+
processLogsDataFrame(ds, df);
107+
108+
expect(df.fields[1].name).toBe('$qw_message');
109+
expect(df.fields[1].values[0]).toBe('SSO user already exists');
110+
});
111+
112+
it('prefers body.message over attributes.message', () => {
113+
const ds = makeDatasource();
114+
const df = makeDataFrame([
115+
makeField('timestamp', FieldType.time, [1000]),
116+
makeField('body.message', FieldType.string, ['from body']),
117+
makeField('attributes.message', FieldType.string, ['from attributes']),
118+
]);
119+
120+
processLogsDataFrame(ds, df);
121+
122+
expect(df.fields[1].name).toBe('$qw_message');
123+
expect(df.fields[1].values[0]).toBe('from body');
124+
});
125+
126+
it('builds key=value summary when no well-known fields exist', () => {
127+
const ds = makeDatasource();
128+
const df = makeDataFrame([
129+
makeField('timestamp', FieldType.time, [1000]),
130+
makeField('attributes.method', FieldType.string, ['GET']),
131+
makeField('attributes.path', FieldType.string, ['/blog']),
132+
makeField('attributes.status', FieldType.number, [200]),
133+
]);
134+
135+
processLogsDataFrame(ds, df);
136+
137+
expect(df.fields[1].name).toBe('$qw_message');
138+
expect(df.fields[1].values[0]).toBe('method=GET path=/blog status=200');
139+
});
140+
141+
it('strips attributes. prefix in key=value summary', () => {
142+
const ds = makeDatasource();
143+
const df = makeDataFrame([
144+
makeField('timestamp', FieldType.time, [1000]),
145+
makeField('attributes.controller', FieldType.string, ['BlogController']),
146+
]);
147+
148+
processLogsDataFrame(ds, df);
149+
150+
expect(df.fields[1].values[0]).toBe('controller=BlogController');
151+
});
152+
153+
it('skips metadata fields in key=value summary', () => {
154+
const ds = makeDatasource();
155+
const df = makeDataFrame([
156+
makeField('timestamp', FieldType.time, [1000]),
157+
makeField('attributes.method', FieldType.string, ['GET']),
158+
makeField('attributes.pod_name', FieldType.string, ['rx-production-abc123']),
159+
makeField('attributes.node_labels.arch', FieldType.string, ['amd64']),
160+
makeField('sort', FieldType.other, [[1684398201000]]),
161+
makeField('severity_text', FieldType.string, ['INFO']),
162+
makeField('body.stream', FieldType.string, ['stdout']),
163+
]);
164+
165+
processLogsDataFrame(ds, df);
166+
167+
expect(df.fields[1].name).toBe('$qw_message');
168+
expect(df.fields[1].values[0]).toBe('method=GET');
169+
});
170+
171+
it('handles mixed log types per row', () => {
172+
const ds = makeDatasource();
173+
const df = makeDataFrame([
174+
makeField('timestamp', FieldType.time, [1000, 2000, 3000]),
175+
makeField('attributes.message', FieldType.string, ['SSO login', '', '']),
176+
makeField('attributes.method', FieldType.string, ['', 'GET', '']),
177+
makeField('attributes.path', FieldType.string, ['', '/blog', '']),
178+
makeField('body.message', FieldType.string, ['', '', 'raw nginx log line']),
179+
]);
180+
181+
processLogsDataFrame(ds, df);
182+
183+
expect(df.fields[1].name).toBe('$qw_message');
184+
expect(df.fields[1].values[0]).toBe('SSO login');
185+
expect(df.fields[1].values[1]).toBe('method=GET path=/blog');
186+
expect(df.fields[1].values[2]).toBe('raw nginx log line');
187+
});
188+
});
189+
190+
describe('edge cases', () => {
191+
it('skips empty dataframes', () => {
192+
const ds = makeDatasource({ logMessageField: 'line' });
193+
const df = makeDataFrame([]);
194+
195+
processLogsDataFrame(ds, df);
196+
197+
expect(df.fields.length).toBe(0);
198+
});
199+
200+
it('skips log-volume dataframes', () => {
201+
const ds = makeDatasource({ logMessageField: 'line' });
202+
const df = makeDataFrame(
203+
[
204+
makeField('timestamp', FieldType.time, [1000]),
205+
makeField('line', FieldType.string, ['hello']),
206+
],
207+
'log-volume-A'
208+
);
209+
210+
processLogsDataFrame(ds, df);
211+
212+
const fieldNames = df.fields.map((f) => f.name);
213+
expect(fieldNames).not.toContain('$qw_message');
214+
});
215+
216+
it('skips dataframes with no refId', () => {
217+
const ds = makeDatasource({ logMessageField: 'line' });
218+
const df: DataFrame = {
219+
refId: undefined,
220+
fields: [
221+
makeField('timestamp', FieldType.time, [1000]),
222+
makeField('line', FieldType.string, ['hello']),
223+
],
224+
length: 1,
225+
};
226+
227+
processLogsDataFrame(ds, df);
228+
229+
const fieldNames = df.fields.map((f) => f.name);
230+
expect(fieldNames).not.toContain('$qw_message');
231+
});
232+
});
233+
});

src/datasource/processResponse.ts

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,58 @@ export function getQueryResponseProcessor(datasource: BaseQuickwitDataSource, re
1717
};
1818
}
1919
function getCustomFieldName(fieldname: string) { return `$qw_${fieldname}`; }
20+
21+
const OTEL_MESSAGE_FIELDS = ['body.message', 'attributes.message'];
22+
23+
const SKIP_FIELD_PREFIXES = [
24+
'attributes.pod_', 'attributes.node_labels.', 'attributes.namespace_labels.',
25+
'attributes.container_image', 'attributes.pod_owner',
26+
];
27+
const SKIP_FIELD_NAMES = new Set([
28+
'sort', 'severity_text', 'body.stream',
29+
]);
30+
31+
function isMetadataField(name: string, timeField: string): boolean {
32+
if (name === timeField || SKIP_FIELD_NAMES.has(name)) {
33+
return true;
34+
}
35+
return SKIP_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix));
36+
}
37+
38+
function stripPrefix(name: string): string {
39+
if (name.startsWith('attributes.')) {
40+
return name.slice('attributes.'.length);
41+
}
42+
if (name.startsWith('body.')) {
43+
return name.slice('body.'.length);
44+
}
45+
return name;
46+
}
47+
48+
function buildFallbackMessage(dataFrame: DataFrame, rowIdx: number, timeFieldName: string): string {
49+
for (const candidate of OTEL_MESSAGE_FIELDS) {
50+
const field = dataFrame.fields.find((f) => f.name === candidate);
51+
if (field) {
52+
const val = field.values[rowIdx];
53+
if (val != null && val !== '') {
54+
return String(val);
55+
}
56+
}
57+
}
58+
59+
const parts: string[] = [];
60+
for (const field of dataFrame.fields) {
61+
if (isMetadataField(field.name, timeFieldName) || field.type === FieldType.time) {
62+
continue;
63+
}
64+
const val = field.values[rowIdx];
65+
if (val != null && val !== '') {
66+
parts.push(`${stripPrefix(field.name)}=${val}`);
67+
}
68+
}
69+
return parts.join(' ');
70+
}
71+
2072
export function processLogsDataFrame(datasource: BaseQuickwitDataSource, dataFrame: DataFrame) {
2173
// Ignore log volume dataframe, no need to add links or a displayed message field.
2274
if (!dataFrame.refId || dataFrame.refId.startsWith('log-volume')) {
@@ -26,39 +78,47 @@ export function processLogsDataFrame(datasource: BaseQuickwitDataSource, dataFra
2678
if (dataFrame.length===0 || dataFrame.fields.length === 0) {
2779
return;
2880
}
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-
}
81+
82+
const configuredFields = datasource.logMessageField ? datasource.logMessageField.split(',') : [];
83+
const field_idx_list: number[] = [];
84+
for (const messageField of configuredFields) {
85+
const field_idx = dataFrame.fields.findIndex((field) => field.name === messageField);
86+
if (field_idx !== -1) {
87+
field_idx_list.push(field_idx);
3788
}
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-
}
89+
}
90+
91+
const timeFieldName = dataFrame.fields.find((f) => f.type === FieldType.time)?.name ?? '';
92+
const displayedMessages = Array(dataFrame.length);
93+
94+
for (let idx = 0; idx < dataFrame.length; idx++) {
95+
let displayedMessage = "";
96+
97+
if (field_idx_list.length === 1) {
98+
displayedMessage = `${dataFrame.fields[field_idx_list[0]].values[idx] ?? ''}`;
99+
} else if (field_idx_list.length > 1) {
100+
for (const field_idx of field_idx_list) {
101+
displayedMessage += ` ${dataFrame.fields[field_idx].name}=${dataFrame.fields[field_idx].values[idx]}`;
48102
}
49-
displayedMessages[idx] = displayedMessage.trim();
103+
displayedMessage = displayedMessage.trim();
50104
}
51105

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];
106+
if (!displayedMessage) {
107+
displayedMessage = buildFallbackMessage(dataFrame, idx, timeFieldName);
108+
}
109+
110+
displayedMessages[idx] = displayedMessage;
60111
}
61112

113+
const newField: Field = {
114+
name: getCustomFieldName('message'),
115+
type: FieldType.string,
116+
config: {},
117+
values: displayedMessages,
118+
};
119+
const [timestamp, ...rest] = dataFrame.fields;
120+
dataFrame.fields = [timestamp, newField, ...rest];
121+
62122
if (!datasource.dataLinks.length) {
63123
return;
64124
}

0 commit comments

Comments
 (0)