mirror of
https://github.com/grafana/grafana.git
synced 2025-09-16 19:13:08 +08:00

* processing: detect json logs to apply custom grammar * LogLineDetails: improve label column width * wip * Remove log * grammar: remove unused grammar * grammar: fix log grammar * LogLineDetails: improve margins * LogLineDetails: further fine tune width * Update tests * processing: more tests
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data';
|
|
import { config } from '@grafana/runtime';
|
|
|
|
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
|
import { createLogLine, createLogRow } from '../mocks/logRow';
|
|
|
|
import { LogListFontSize } from './LogList';
|
|
import { LogListModel, preProcessLogs } from './processing';
|
|
import { LogLineVirtualization } from './virtualization';
|
|
|
|
describe('preProcessLogs', () => {
|
|
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
|
|
let processedLogs: LogListModel[];
|
|
const fontSizes: LogListFontSize[] = ['default', 'small'];
|
|
|
|
beforeEach(() => {
|
|
const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({
|
|
href: '/link',
|
|
title: 'link',
|
|
target: '_blank',
|
|
origin: field,
|
|
}));
|
|
logFmtLog = createLogRow({
|
|
uid: '1',
|
|
timeEpochMs: 3,
|
|
labels: { level: 'warn', logger: 'interceptor' },
|
|
entry: `logger=interceptor t=2025-03-18T08:58:34.820119602Z level=warn msg="calling resource store as the service without id token or marking it as the service identity" subject=:0 uid=43eb4c92-18a0-4060-be96-37af854f0830`,
|
|
logLevel: LogLevel.warning,
|
|
rowIndex: 0,
|
|
dataFrame: toDataFrame({
|
|
refId: 'A',
|
|
fields: [
|
|
{ name: 'Time', type: FieldType.time, values: [3, 2, 1] },
|
|
{
|
|
name: 'Line',
|
|
type: FieldType.string,
|
|
values: ['log message 1', 'log message 2', 'log message 3'],
|
|
},
|
|
{
|
|
name: 'labels',
|
|
type: FieldType.other,
|
|
values: [
|
|
{ level: 'warn', logger: 'interceptor' },
|
|
{ method: 'POST', status: '200' },
|
|
{ kind: 'Event', stage: 'ResponseComplete' },
|
|
],
|
|
},
|
|
{
|
|
name: 'link',
|
|
type: FieldType.string,
|
|
config: {
|
|
links: [
|
|
{
|
|
title: 'link1',
|
|
url: 'https://example.com',
|
|
},
|
|
],
|
|
},
|
|
values: ['link'],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
nginxLog = createLogRow({
|
|
uid: '2',
|
|
timeEpochMs: 2,
|
|
labels: { method: 'POST', status: '200' },
|
|
entry: `35.191.12.195 - accounts.google.com:test@grafana.com [18/Mar/2025:08:58:38 +0000] 200 "POST /grafana/api/ds/query?ds_type=prometheus&requestId=SQR461 HTTP/1.1" 59460 "https://test.example.com/?orgId=1" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "95.91.240.90, 34.107.247.24"`,
|
|
logLevel: LogLevel.critical,
|
|
});
|
|
jsonLog = createLogRow({
|
|
uid: '3',
|
|
timeEpochMs: 1,
|
|
labels: { kind: 'Event', stage: 'ResponseComplete' },
|
|
entry: `{"kind":"Event","apiVersion":"audit.k8s.io/v1","level":"Request","auditID":"2052d577-3391-4fe7-9fe2-4d6c8cbe398f","stage":"ResponseComplete","requestURI":"/api/v1/test","verb":"list","user":{"username":"system:apiserver","uid":"6f35feec-4522-4f21-8289-668e336967b5","groups":["system:authenticated","system:masters"]},"sourceIPs":["::1"],"userAgent":"kube-apiserver/v1.31.5 (linux/amd64) kubernetes/test","objectRef":{"resource":"resourcequotas","namespace":"test","apiVersion":"v1"},"responseStatus":{"metadata":{},"code":200},"requestReceivedTimestamp":"2025-03-18T08:58:34.940093Z"}`,
|
|
logLevel: LogLevel.error,
|
|
});
|
|
processedLogs = preProcessLogs([logFmtLog, nginxLog, jsonLog], {
|
|
escape: false,
|
|
getFieldLinks,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: true,
|
|
});
|
|
});
|
|
|
|
describe('LogListModel', () => {
|
|
test('Extends a LogRowModel', () => {
|
|
const logRowModel = createLogRow({
|
|
uid: '2',
|
|
datasourceUid: 'test',
|
|
timeEpochMs: 2,
|
|
labels: { method: 'POST', status: '200' },
|
|
entry: `35.191.12.195 - accounts.google.com:test@grafana.com [18/Mar/2025:08:58:38 +0000] 200 "POST /grafana/api/ds/query?ds_type=prometheus&requestId=SQR461 HTTP/1.1" 59460 "https://test.example.com/?orgId=1" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "95.91.240.90, 34.107.247.24"`,
|
|
logLevel: LogLevel.critical,
|
|
});
|
|
const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ', wrapLogMessage: true });
|
|
expect(logListModel).toMatchObject(logRowModel);
|
|
});
|
|
|
|
test('Unwrapped log lines strip new lines', () => {
|
|
const logListModel = createLogLine(
|
|
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: false, // unwrapped
|
|
}
|
|
);
|
|
expect(logListModel.getDisplayedFieldValue('place')).toBe('luna');
|
|
expect(logListModel.body).toBe('log message 1');
|
|
});
|
|
|
|
test('Wrapped log lines do not modify new lines', () => {
|
|
const logListModel = createLogLine(
|
|
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: true, // wrapped
|
|
}
|
|
);
|
|
expect(logListModel.getDisplayedFieldValue('place')).toBe(logListModel.labels['place']);
|
|
expect(logListModel.body).toBe(logListModel.raw);
|
|
});
|
|
|
|
test('Strips ansi colors for measurement', () => {
|
|
const logListModel = createLogLine(
|
|
{ entry: `log \u001B[31mmessage\u001B[0m 1` },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: true,
|
|
}
|
|
);
|
|
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, false)).toBe(
|
|
`log \u001B[31mmessage\u001B[0m 1`
|
|
);
|
|
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, true)).toBe('log message 1');
|
|
});
|
|
|
|
test('Does not modify unwrapped JSON', () => {
|
|
const entry = '{"key": "value", "otherKey": "otherValue"}';
|
|
const logListModel = createLogLine(
|
|
{ entry },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: false, // unwrapped
|
|
}
|
|
);
|
|
expect(logListModel.entry).toBe(entry);
|
|
expect(logListModel.body).toBe(entry);
|
|
});
|
|
|
|
test('Prettifies wrapped JSON', () => {
|
|
const entry = '{"key": "value", "otherKey": "otherValue"}';
|
|
const logListModel = createLogLine(
|
|
{ entry },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: true, // wrapped
|
|
}
|
|
);
|
|
expect(logListModel.entry).toBe(entry);
|
|
expect(logListModel.body).not.toBe(entry);
|
|
});
|
|
|
|
test('Uses lossless parsing', () => {
|
|
const entry = '{"number": 90071992547409911}';
|
|
const logListModel = createLogLine(
|
|
{ entry },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: false, // unwrapped
|
|
}
|
|
);
|
|
expect(logListModel.entry).toBe(entry);
|
|
expect(logListModel.body).toContain('90071992547409911');
|
|
});
|
|
|
|
test.each([
|
|
'{"timestamp":"2025-08-19T12:34:56Z","level":"INFO","message":"User logged in","user_id":1234}',
|
|
'{"time":"2025-08-19T12:35:10Z","level":"ERROR","service":"payment","error":"Insufficient funds","transaction_id":"tx-98765"}',
|
|
'{"ts":1692444912,"lvl":"WARN","component":"auth","msg":"Token expired","session_id":"abcd1234"}',
|
|
'{"@timestamp":"2025-08-19T12:36:00Z","severity":"DEBUG","event":"cache_hit","key":"user_profile:1234","duration_ms":3}',
|
|
'{}',
|
|
])('Detects JSON logs', (entry: string) => {
|
|
const logListModel = createLogLine(
|
|
{ entry },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: false,
|
|
}
|
|
);
|
|
expect(logListModel.body).toBeDefined(); // Triggers parsing
|
|
expect(logListModel.isJSON).toBe(true);
|
|
});
|
|
|
|
test.each(['1', '"1"', 'true', 'null', 'false', 'not json', '"nope"'])('Detects non-JSON logs', (entry: string) => {
|
|
const logListModel = createLogLine(
|
|
{ entry },
|
|
{
|
|
escape: false,
|
|
order: LogsSortOrder.Descending,
|
|
timeZone: 'browser',
|
|
wrapLogMessage: false,
|
|
}
|
|
);
|
|
expect(logListModel.body).toBeDefined(); // Triggers parsing
|
|
expect(logListModel.isJSON).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('Orders logs', () => {
|
|
expect(processedLogs[0].uid).toBe('1');
|
|
expect(processedLogs[1].uid).toBe('2');
|
|
expect(processedLogs[2].uid).toBe('3');
|
|
});
|
|
|
|
test('Sets the display level level', () => {
|
|
expect(processedLogs[0].displayLevel).toBe('warn');
|
|
expect(processedLogs[1].displayLevel).toBe('crit');
|
|
expect(processedLogs[2].displayLevel).toBe('error');
|
|
});
|
|
|
|
test('Sets the log fields links', () => {
|
|
expect(processedLogs[0].fields).toEqual([
|
|
{
|
|
fieldIndex: 3,
|
|
keys: ['link'],
|
|
links: {
|
|
href: '/link',
|
|
origin: {
|
|
config: {
|
|
links: [
|
|
{
|
|
title: 'link1',
|
|
url: 'https://example.com',
|
|
},
|
|
],
|
|
},
|
|
index: 3,
|
|
name: 'link',
|
|
type: 'string',
|
|
values: ['link'],
|
|
},
|
|
target: '_blank',
|
|
title: 'link',
|
|
},
|
|
values: ['link'],
|
|
},
|
|
]);
|
|
expect(processedLogs[1].fields).toEqual([]);
|
|
expect(processedLogs[2].fields).toEqual([]);
|
|
});
|
|
|
|
test('Highlights tokens in log lines', () => {
|
|
expect(processedLogs[0].highlightedBody).toContain('log-token-label');
|
|
expect(processedLogs[0].highlightedBody).toContain('log-token-key');
|
|
expect(processedLogs[0].highlightedBody).toContain('log-token-string');
|
|
expect(processedLogs[0].highlightedBody).toContain('log-token-uuid');
|
|
expect(processedLogs[0].highlightedBody).not.toContain('log-token-method');
|
|
expect(processedLogs[0].highlightedBody).not.toContain('log-token-json-key');
|
|
|
|
expect(processedLogs[1].highlightedBody).toContain('log-token-method');
|
|
expect(processedLogs[1].highlightedBody).toContain('log-token-key');
|
|
expect(processedLogs[1].highlightedBody).toContain('log-token-string');
|
|
expect(processedLogs[1].highlightedBody).not.toContain('log-token-json-key');
|
|
|
|
expect(processedLogs[2].highlightedBody).toContain('log-token-json-key');
|
|
expect(processedLogs[2].highlightedBody).toContain('log-token-string');
|
|
expect(processedLogs[2].highlightedBody).not.toContain('log-token-method');
|
|
});
|
|
|
|
test('Returns displayed field values', () => {
|
|
expect(processedLogs[0].getDisplayedFieldValue('logger')).toBe('interceptor');
|
|
expect(processedLogs[1].getDisplayedFieldValue('method')).toBe('POST');
|
|
expect(processedLogs[2].getDisplayedFieldValue('kind')).toBe('Event');
|
|
expect(processedLogs[0].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[0].body);
|
|
expect(processedLogs[1].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[1].body);
|
|
expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body);
|
|
});
|
|
|
|
describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => {
|
|
let longLog: LogListModel, entry: string, container: HTMLDivElement, virtualization: LogLineVirtualization;
|
|
beforeEach(() => {
|
|
virtualization = new LogLineVirtualization(createTheme(), fontSize);
|
|
container = document.createElement('div');
|
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
|
|
entry = new Array(2 * virtualization.getTruncationLength(null)).fill('e').join('');
|
|
longLog = createLogLine(
|
|
{ entry, labels: { field: 'value' } },
|
|
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
|
|
);
|
|
});
|
|
|
|
test('Long lines that are not truncated are not modified', () => {
|
|
expect(longLog.body).toBe(entry);
|
|
expect(longLog.highlightedBody).toBe(entry);
|
|
});
|
|
|
|
test('Sets the collapsed state based on the container size', () => {
|
|
// Make container half of the size
|
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100);
|
|
|
|
expect(longLog.collapsed).toBeUndefined();
|
|
|
|
longLog.updateCollapsedState([], container);
|
|
|
|
expect(longLog.collapsed).toBe(true);
|
|
expect(longLog.body).not.toBe(entry);
|
|
expect(entry).toContain(longLog.body);
|
|
});
|
|
|
|
test('Sets the collapsed state based on the new lines count', () => {
|
|
const entry = new Array(virtualization.getTruncationLineCount()).fill('test\n').join('');
|
|
const multilineLog = createLogLine(
|
|
{ entry, labels: { field: 'value' } },
|
|
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
|
|
);
|
|
|
|
expect(multilineLog.collapsed).toBeUndefined();
|
|
|
|
multilineLog.updateCollapsedState([], container);
|
|
|
|
expect(multilineLog.collapsed).toBe(true);
|
|
expect(entry).toContain(multilineLog.body);
|
|
});
|
|
|
|
test('Correctly counts new lines', () => {
|
|
const entry = new Array(virtualization.getTruncationLineCount() - 1).fill('test\n').join('');
|
|
const multilineLog = createLogLine(
|
|
{ entry, labels: { field: 'value' } },
|
|
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
|
|
);
|
|
|
|
expect(multilineLog.collapsed).toBeUndefined();
|
|
|
|
multilineLog.updateCollapsedState([], container);
|
|
|
|
expect(multilineLog.collapsed).toBeUndefined();
|
|
});
|
|
|
|
test('Considers the displayed fields to set the collapsed state', () => {
|
|
// Make container half of the size
|
|
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100);
|
|
|
|
expect(longLog.collapsed).toBeUndefined();
|
|
|
|
// Log line body is not included in the displayed fields, so it fits in the container
|
|
longLog.updateCollapsedState(['field'], container);
|
|
|
|
expect(longLog.collapsed).toBeUndefined();
|
|
});
|
|
|
|
test('Considers new lines in displayed fields to set the collapsed state', () => {
|
|
const entry = new Array(virtualization.getTruncationLineCount() - 1).fill('test\n').join('');
|
|
const field = 'test\n';
|
|
const multilineLog = createLogLine(
|
|
{ entry, labels: { field } },
|
|
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
|
|
);
|
|
|
|
expect(multilineLog.collapsed).toBeUndefined();
|
|
|
|
multilineLog.updateCollapsedState(['field', LOG_LINE_BODY_FIELD_NAME], container);
|
|
|
|
expect(multilineLog.collapsed).toBe(true);
|
|
expect(entry).toContain(multilineLog.body);
|
|
});
|
|
|
|
test('Updates the body based on the collapsed state', () => {
|
|
expect(longLog.collapsed).toBeUndefined();
|
|
expect(longLog.body).toBe(entry);
|
|
|
|
longLog.setCollapsedState(true);
|
|
|
|
expect(longLog.collapsed).toBe(true);
|
|
expect(longLog.body).not.toBe(entry);
|
|
expect(entry).toContain(longLog.body);
|
|
|
|
longLog.setCollapsedState(false);
|
|
|
|
expect(longLog.collapsed).toBe(false);
|
|
expect(longLog.body).toBe(entry);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OTel logs', () => {
|
|
let originalOtelLogsFormatting = config.featureToggles.otelLogsFormatting;
|
|
afterAll(() => {
|
|
config.featureToggles.otelLogsFormatting = originalOtelLogsFormatting;
|
|
});
|
|
|
|
test('Requires a feature flag', () => {
|
|
const log = createLogLine({
|
|
labels: {
|
|
severity_number: '1',
|
|
telemetry_sdk_language: 'php',
|
|
scope_name: 'scope',
|
|
aws_ignore: 'ignored',
|
|
key: 'value',
|
|
otel: 'otel',
|
|
},
|
|
entry: `place="luna" 1ms 3 KB`,
|
|
});
|
|
expect(log.otelLanguage).toBeDefined();
|
|
expect(log.body).toEqual(`place="luna" 1ms 3 KB`);
|
|
});
|
|
|
|
test('Augments OTel log lines', () => {
|
|
config.featureToggles.otelLogsFormatting = true;
|
|
const log = createLogLine({
|
|
labels: {
|
|
severity_number: '1',
|
|
telemetry_sdk_language: 'php',
|
|
scope_name: 'scope',
|
|
aws_ignore: 'ignored',
|
|
key: 'value',
|
|
otel: 'otel',
|
|
},
|
|
entry: `place="luna" 1ms 3 KB`,
|
|
});
|
|
expect(log.otelLanguage).toBeDefined();
|
|
expect(log.body).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
|
|
});
|
|
});
|