Log Line: Add custom highlight renderer (#110335)

* LogLine: implement custom highlight renderer

* Log line: fully replace highlighted body with highlight tokens

* processing: update test

* Add unit test

* Add deeply nested JSON test

* processing: update clone function

* Scroll to log line: find by uid

* LogLineDetailsLog: check if the log contains ansi

* Prettier
This commit is contained in:
Matias Chomicki
2025-08-29 20:33:21 +02:00
committed by GitHub
parent d63e1ce04d
commit e30931c219
7 changed files with 324 additions and 31 deletions
@@ -0,0 +1,208 @@
import { render } from '@testing-library/react';
import { LogsSortOrder } from '@grafana/data';
import { createLogLine } from '../mocks/logRow';
import { HighlightedLogRenderer } from './HighlightedLogRenderer';
describe('HighlightedLogRenderer', () => {
test.each([
[false, false],
[true, false],
[false, true],
[true, true],
])('Serializes JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => {
const log = createLogLine(
{
entry: `{
"_entry": "log text [149843146]",
"counter": "11203",
"float": "12.53",
"wave": 0.8090169943751789,
"label": "val3",
"level": "info",
"array": ["1", 2, { "test": "test" }],
}`,
},
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage,
prettifyJSON,
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});
test.each([
[false, false],
[true, false],
[false, true],
[true, true],
])('Serializes deeply nested JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => {
const log = createLogLine(
{
entry: `{
"id": "user_12345",
"profile": {
"name": {
"first": "Alice",
"last": "Example"
},
"contact": {
"email": "alice@example.com",
"phone": "+1-111-1234",
"addresses": [
{
"type": "home",
"location": {
"street": "123 Maple St",
"city": "Springfield",
"geo": {
"lat": 40.7128,
"lng": -74.0060,
"timezone": {
"id": "America/New_York",
"offset": -5
}
}
}
},
{
"type": "work",
"location": {
"street": "456 Oak Ave",
"city": "Metropolis",
"geo": {
"lat": 37.7749,
"lng": -122.4194,
"timezone": {
"id": "America/Los_Angeles",
"offset": -8
}
}
}
}
]
}
},
"account": {
"createdAt": "2023-11-10T08:30:00Z",
"lastLogin": "2025-08-29T15:12:00Z",
"settings": {
"notifications": {
"email": true,
"sms": false,
"categories": [
{
"name": "security",
"enabled": true
},
{
"name": "marketing",
"enabled": false
}
]
},
"theme": {
"mode": "dark",
"colors": {
"background": "#1e1e1e",
"text": "#ffffff",
"highlights": {
"primary": "#ff4081",
"secondary": "#82b1ff"
}
}
}
}
},
"activity": [
{
"type": "login",
"timestamp": "2025-08-29T15:12:00Z",
"ip": "192.168.1.10",
"device": {
"type": "desktop",
"os": {
"name": "macOS",
"version": "14.2"
},
"browser": {
"name": "Chrome",
"version": "126.0.6478.56"
}
}
},
{
"type": "purchase",
"timestamp": "2025-08-28T18:45:00Z",
"details": {
"orderId": "order_98765",
"items": [
{
"productId": "prod_111",
"name": "Wireless Keyboard",
"price": 79.99,
"quantity": 1
},
{
"productId": "prod_222",
"name": "Ergonomic Mouse",
"price": 49.99,
"quantity": 2
}
],
"shipping": {
"carrier": "UPS",
"status": "delivered",
"estimatedDelivery": "2025-08-30T14:00:00Z"
}
}
}
]
}`,
},
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage,
prettifyJSON,
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});
test.each([
[false, false],
[true, false],
[false, true],
[true, true],
])('Serializes JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => {
const log = createLogLine(
{
entry: `_entry="log text [149843146]" counter=11203 float=12.53 wave=0.8090169943751789 label=val3 level=info`,
},
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage,
prettifyJSON,
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});
});
@@ -0,0 +1,33 @@
import { Token } from 'prismjs';
import { LogListModel } from './processing';
export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => {
return (
<>
{log.highlightedBodyTokens.map((token, i) => (
<LogToken token={token} key={i} />
))}
</>
);
};
const LogToken = ({ token }: { token: Token | string }) => {
if (typeof token === 'string') {
return token;
}
if (Array.isArray(token.content)) {
return (
<span className={`token ${token.type}`}>
{token.content.map((subToken, i) => (
<LogToken key={i} token={subToken} />
))}
</span>
);
}
return (
<span className={`token ${token.type}`}>
{typeof token.content === 'string' ? token.content : <LogToken token={token.content} />}
</span>
);
};
@@ -10,6 +10,7 @@ import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { HighlightedLogRenderer } from './HighlightedLogRenderer';
import { InlineLogLineDetails } from './LogLineDetails';
import { LogLineMenu } from './LogLineMenu';
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
@@ -393,7 +394,11 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles
);
}
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
return (
<span className="field log-syntax-highlight">
<HighlightedLogRenderer log={log} />
</span>
);
};
export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) {
@@ -3,6 +3,9 @@ import { memo, useMemo } from 'react';
import { useStyles2 } from '@grafana/ui';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { HighlightedLogRenderer } from './HighlightedLogRenderer';
import { getStyles } from './LogLine';
import { LogListModel } from './processing';
@@ -20,15 +23,22 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }:
return (
<div className={styles.logLineWrapper}>
{!syntaxHighlighting ? (
<div className="field no-highlighting">{log.body}</div>
) : (
<div className={logStyles.logLine}>
<div className={logStyles.wrappedLogLine}>
<div className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />
</div>
<div className={logStyles.logLine}>
<div className={logStyles.wrappedLogLine}>
{log.hasAnsi ? (
<span className="field no-highlighting">
<LogMessageAnsi value={log.body} />
</span>
) : (
<>
{!syntaxHighlighting && <div className="field no-highlighting">{log.body}</div>}
{syntaxHighlighting && (
<div className="field log-syntax-highlight">{<HighlightedLogRenderer log={log} />}</div>
)}
</>
)}
</div>
)}
</div>
</div>
);
});
@@ -399,7 +399,7 @@ const LogListComponent = ({
const focusLogLine = useCallback(
(log: LogListModel) => {
const index = filteredLogs.indexOf(log);
const index = filteredLogs.findIndex((filteredLog) => filteredLog.uid === log.uid);
if (index >= 0) {
debouncedScrollToItem(index, 'start');
}
@@ -283,21 +283,47 @@ describe('preProcessLogs', () => {
});
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[0].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-label' })])
);
expect(processedLogs[0].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-key' })])
);
expect(processedLogs[0].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })])
);
expect(processedLogs[0].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-uuid' })])
);
expect(processedLogs[0].highlightedBodyTokens).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })])
);
expect(processedLogs[0].highlightedBodyTokens).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: '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[1].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })])
);
expect(processedLogs[1].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-key' })])
);
expect(processedLogs[1].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })])
);
expect(processedLogs[1].highlightedBodyTokens).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: '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');
expect(processedLogs[2].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-json-key' })])
);
expect(processedLogs[2].highlightedBodyTokens).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })])
);
expect(processedLogs[2].highlightedBodyTokens).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })])
);
});
test('Returns displayed field values', () => {
@@ -1,6 +1,6 @@
import ansicolor from 'ansicolor';
import { LosslessNumber, parse, stringify } from 'lossless-json';
import Prism, { Grammar } from 'prismjs';
import Prism, { Grammar, Token } from 'prismjs';
import {
DataFrame,
@@ -10,7 +10,6 @@ import {
LogRowModel,
LogsSortOrder,
systemDateFormats,
textUtil,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
@@ -59,6 +58,7 @@ export class LogListModel implements LogRowModel {
private _currentSearch: string | undefined = undefined;
private _grammar?: Grammar;
private _highlightedBody: string | undefined = undefined;
private _highlightTokens: Array<string | Token> | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
private _prettifyJSON: boolean;
@@ -120,7 +120,7 @@ export class LogListModel implements LogRowModel {
// Unless this function is required outside of <LogLineDetailsLog />, we create a wrapped clone, so new lines are not stripped.
clone._wrapLogMessage = true;
clone._body = undefined;
clone._highlightedBody = undefined;
clone._highlightTokens = undefined;
return clone;
}
@@ -161,14 +161,25 @@ export class LogListModel implements LogRowModel {
get highlightedBody() {
if (this._highlightedBody === undefined) {
// Body is accessed first to trigger the getter code before generateLogGrammar()
const sanitizedBody = textUtil.sanitize(this.body);
const body = this.body;
this._grammar = this._grammar ?? generateLogGrammar(this);
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
this._highlightedBody = Prism.highlight(sanitizedBody, { ...extraGrammar, ...this._grammar }, 'lokiql');
this._highlightedBody = Prism.highlight(body, { ...extraGrammar, ...this._grammar }, 'logs');
}
return this._highlightedBody;
}
get highlightedBodyTokens() {
if (this._highlightTokens === undefined) {
// Body is accessed first to trigger the getter code before generateLogGrammar()
const body = this.body;
this._grammar = this._grammar ?? generateLogGrammar(this);
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
this._highlightTokens = Prism.tokenize(body, { ...extraGrammar, ...this._grammar });
}
return this._highlightTokens;
}
get isJSON() {
return this._json;
}
@@ -223,21 +234,21 @@ export class LogListModel implements LogRowModel {
if (this.collapsed === undefined || collapsed === undefined) {
this.collapsed = collapsed;
this._body = undefined;
this._highlightedBody = undefined;
this._highlightTokens = undefined;
}
}
setCollapsedState(collapsed: boolean) {
if (this.collapsed !== collapsed) {
this._body = undefined;
this._highlightedBody = undefined;
this._highlightTokens = undefined;
}
this.collapsed = collapsed;
}
setCurrentSearch(search: string | undefined) {
this._currentSearch = search;
this._highlightedBody = undefined;
this._highlightTokens = undefined;
}
}