OTel: Add experimental formatting for OTel identified log lines (#108170)
* OTel: create formats service * LogListContext: use OTel service to resolve the default displayed fields * Create otelLogsFormatting feature flag * LogListContext: conditionally use otel format based on feature flag * OTel logs: reformat body and improve displayed fields * formats: remove duplicated scope name * Update formats.ts * format: use regex rules to exclude fields * Add tests * Formatting * LogsPanel: expose setDisplayedFields * LogListContext: refactor otel displayed fields * Prettier * format: optimize identifyOTelLanguages
This commit is contained in:
@@ -1026,4 +1026,8 @@ export interface FeatureToggles {
|
||||
* @default false
|
||||
*/
|
||||
enablePluginImporter?: boolean;
|
||||
/**
|
||||
* Applies OTel formatting templates to displayed logs
|
||||
*/
|
||||
otelLogsFormatting?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface Options {
|
||||
onLogOptionsChange?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
prettifyLogMessage: boolean;
|
||||
setDisplayedFields?: unknown;
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showLabels: boolean;
|
||||
|
||||
@@ -1765,6 +1765,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "otelLogsFormatting",
|
||||
Description: "Applies OTel formatting templates to displayed logs",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -229,3 +229,4 @@ newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,fa
|
||||
enableAppChromeExtensions,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
foldersAppPlatformAPI,experimental,@grafana/grafana-search-navigate-organise,false,false,true
|
||||
enablePluginImporter,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
otelLogsFormatting,experimental,@grafana/observability-logs,false,false,true
|
||||
|
||||
|
@@ -926,4 +926,8 @@ const (
|
||||
// FlagEnablePluginImporter
|
||||
// Set this to true to use the new PluginImporter functionality
|
||||
FlagEnablePluginImporter = "enablePluginImporter"
|
||||
|
||||
// FlagOtelLogsFormatting
|
||||
// Applies OTel formatting templates to displayed logs
|
||||
FlagOtelLogsFormatting = "otelLogsFormatting"
|
||||
)
|
||||
|
||||
@@ -2225,6 +2225,19 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "otelLogsFormatting",
|
||||
"resourceVersion": "1750152612973",
|
||||
"creationTimestamp": "2025-06-17T09:30:12Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Applies OTel formatting templates to displayed logs",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelFilterVariable",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
||||
import { getDisplayedFieldsForLogs, getOtelFormattedBody } from './formats';
|
||||
|
||||
describe('getDisplayedFieldsForLogs', () => {
|
||||
test('Does not return displayed fields if not an OTel log line', () => {
|
||||
const log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` });
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual([]);
|
||||
});
|
||||
|
||||
test('Does not return displayed fields if telemetry_sdk_language is empty', () => {
|
||||
const log = createLogLine({ labels: { severity_number: '1' }, entry: `place="luna" 1ms 3 KB` });
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual([]);
|
||||
});
|
||||
|
||||
test('Does not return displayed fields if telemetry_sdk_language is empty', () => {
|
||||
const log = createLogLine({
|
||||
labels: { severity_number: '1', telemetry_sdk_language: 'php', scope_name: 'scope' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOtelFormattedBody', () => {
|
||||
test('Does not modify non OTel logs', () => {
|
||||
const log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` });
|
||||
expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB`);
|
||||
});
|
||||
|
||||
test('Returns an OTel augmented log line body', () => {
|
||||
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(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { LogRowModel } from '@grafana/data';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { LogListModel } from '../panel/processing';
|
||||
|
||||
/**
|
||||
* The presence of this field along log fields determines OTel origin.
|
||||
*/
|
||||
const OTEL_PROBE_FIELD = 'severity_number';
|
||||
export function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
const languagesSet = new Set<string>();
|
||||
logs.forEach((log) => {
|
||||
const lang = identifyOTelLanguage(log);
|
||||
if (lang !== undefined) {
|
||||
languagesSet.add(lang);
|
||||
}
|
||||
});
|
||||
return [...languagesSet];
|
||||
}
|
||||
|
||||
export function identifyOTelLanguage(log: LogListModel | LogRowModel): string | undefined {
|
||||
if ('otelLanguage' in log && log.otelLanguage) {
|
||||
return log.otelLanguage;
|
||||
}
|
||||
return log.labels[OTEL_PROBE_FIELD] !== undefined && log.labels.telemetry_sdk_language
|
||||
? log.labels.telemetry_sdk_language
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) {
|
||||
const displayedFields: string[] = [];
|
||||
|
||||
languages.forEach((language) => {
|
||||
const format = getDisplayFormatForLanguage(language) ?? getDefaultOTelDisplayFormat();
|
||||
format.forEach((field) => {
|
||||
if (!displayedFields.includes(field)) {
|
||||
displayedFields.push(field);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return displayedFields.filter(
|
||||
(field) => field === LOG_LINE_BODY_FIELD_NAME || logs.some((log) => log.labels[field] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayedFieldsForLogs(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
return getDisplayedFieldsForLanguages(logs, identifyOTelLanguages(logs));
|
||||
}
|
||||
|
||||
// Languages not implemented.
|
||||
export function getDisplayFormatForLanguage(language: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDefaultOTelDisplayFormat() {
|
||||
return ['scope_name', 'thread_name', 'exception_type', 'exception_message', LOG_LINE_BODY_FIELD_NAME];
|
||||
}
|
||||
|
||||
const OTEL_RESOURCE_ATTRS_REGEX =
|
||||
/^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_)/;
|
||||
const OTEL_LOG_FIELDS_REGEX =
|
||||
/^(flags|observed_timestamp|scope_name|severity_number|severity_text|span_id|trace_id|detected_level)$/;
|
||||
|
||||
export function getOtelFormattedBody(log: LogListModel) {
|
||||
if (!log.otelLanguage) {
|
||||
return log.raw;
|
||||
}
|
||||
const additionalFields = Object.keys(log.labels).filter(
|
||||
(label) => !OTEL_RESOURCE_ATTRS_REGEX.test(label) && !OTEL_LOG_FIELDS_REGEX.test(label)
|
||||
);
|
||||
return (
|
||||
log.raw +
|
||||
' ' +
|
||||
additionalFields.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : '')).join(' ')
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
shallowCompare,
|
||||
store,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { PopoverContent } from '@grafana/ui';
|
||||
|
||||
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
|
||||
import { getDisplayedFieldsForLogs } from '../otel/formats';
|
||||
|
||||
import { LogLineDetailsMode } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||
@@ -239,6 +241,16 @@ export const LogListContextProvider = ({
|
||||
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
|
||||
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) {
|
||||
return;
|
||||
}
|
||||
const otelDisplayedFields = getDisplayedFieldsForLogs(logs);
|
||||
if (otelDisplayedFields.length) {
|
||||
setDisplayedFields(otelDisplayedFields);
|
||||
}
|
||||
}, [displayedFields.length, logs, setDisplayedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
// Props are updated in the context only of the panel is being externally controlled.
|
||||
if (showControls && app !== CoreApp.PanelEditor) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -347,3 +348,43 @@ describe('preProcessLogs', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,13 @@ import { parse, stringify } from 'lossless-json';
|
||||
import Prism, { Grammar } from 'prismjs';
|
||||
|
||||
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder, textUtil } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { FieldDef, getAllFields } from '../logParser';
|
||||
import { identifyOTelLanguage, getOtelFormattedBody } from '../otel/formats';
|
||||
|
||||
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { LogLineVirtualization } from './virtualization';
|
||||
@@ -30,6 +32,7 @@ export class LogListModel implements LogRowModel {
|
||||
isSampled: boolean;
|
||||
labels: Labels;
|
||||
logLevel: LogLevel;
|
||||
otelLanguage?: string;
|
||||
raw: string;
|
||||
rowIndex: number;
|
||||
rowId?: string | undefined;
|
||||
@@ -69,6 +72,7 @@ export class LogListModel implements LogRowModel {
|
||||
this.isSampled = !!checkLogsSampled(log);
|
||||
this.labels = log.labels;
|
||||
this.logLevel = log.logLevel;
|
||||
this.otelLanguage = identifyOTelLanguage(log);
|
||||
this.rowIndex = log.rowIndex;
|
||||
this.rowId = log.rowId;
|
||||
this.searchWords = log.searchWords;
|
||||
@@ -115,9 +119,10 @@ export class LogListModel implements LogRowModel {
|
||||
this.raw = parsed;
|
||||
}
|
||||
} catch (error) {}
|
||||
const raw = config.featureToggles.otelLogsFormatting && this.otelLanguage ? getOtelFormattedBody(this) : this.raw;
|
||||
this._body = this.collapsed
|
||||
? this.raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
|
||||
: this.raw;
|
||||
? raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
|
||||
: raw;
|
||||
if (!this._wrapLogMessage) {
|
||||
this._body = this._body.replace(NEWLINES_REGEX, '');
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
isOnLogOptionsChange,
|
||||
isOnNewLogsReceivedType,
|
||||
isReactNodeArray,
|
||||
isSetDisplayedFields,
|
||||
onNewLogsReceivedType,
|
||||
Options,
|
||||
} from './types';
|
||||
@@ -91,6 +92,9 @@ interface LogsPanelProps extends PanelProps<Options> {
|
||||
* Called from the "eye" icon in Log Details to request hiding the displayed field. If ommited, a default implementation is used.
|
||||
* onClickHideField?: (key: string) => void;
|
||||
*
|
||||
* Called from the new Log Details Panel when fields are reordered. If ommited, a default implementation is used.
|
||||
* setDisplayedFields?: (key: string) => void;
|
||||
*
|
||||
* Passed to the LogRowMenuCell component to be rendered before the default actions in the menu.
|
||||
* logRowMenuIconsBefore?: ReactNode[];
|
||||
*
|
||||
@@ -517,6 +521,9 @@ export const LogsPanel = ({
|
||||
|
||||
const onClickShowField = isOnClickShowField(options.onClickShowField) ? options.onClickShowField : showField;
|
||||
const onClickHideField = isOnClickHideField(options.onClickHideField) ? options.onClickHideField : hideField;
|
||||
const setDisplayedFieldsFn = isSetDisplayedFields(options.setDisplayedFields)
|
||||
? options.setDisplayedFields
|
||||
: setDisplayedFields;
|
||||
|
||||
// In Dashboards, default to inline. Otherwise, let apps control or have automatic behavior.
|
||||
const detailsMode = detailsModeProp ? detailsModeProp : app === CoreApp.Dashboard ? 'inline' : undefined;
|
||||
@@ -576,7 +583,7 @@ export const LogsPanel = ({
|
||||
onOpenContext={onOpenContext}
|
||||
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
|
||||
permalinkedLogId={getLogsPanelState()?.logs?.id ?? undefined}
|
||||
setDisplayedFields={setDisplayedFields}
|
||||
setDisplayedFields={setDisplayedFieldsFn}
|
||||
showControls={Boolean(showControls)}
|
||||
showTime={showTime}
|
||||
sortOrder={sortOrder}
|
||||
|
||||
@@ -55,6 +55,7 @@ composableKinds: PanelCfg: {
|
||||
logLineMenuCustomItems?: _
|
||||
onNewLogsReceived?: _
|
||||
displayedFields?: [...string]
|
||||
setDisplayedFields?: _
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Options {
|
||||
onLogOptionsChange?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
prettifyLogMessage: boolean;
|
||||
setDisplayedFields?: unknown;
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showLabels: boolean;
|
||||
|
||||
@@ -10,11 +10,12 @@ type onClickFilterLabelType = (key: string, value: string, frame?: DataFrame) =>
|
||||
type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame) => void;
|
||||
type onClickFilterValueType = (value: string, refId?: string) => void;
|
||||
type onClickFilterOutStringType = (value: string, refId?: string) => void;
|
||||
type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
type isOnClickShowFieldType = (value: string) => void;
|
||||
type isOnClickHideFieldType = (value: string) => void;
|
||||
type filterLabelActiveType = (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
type onClickShowFieldType = (value: string) => void;
|
||||
type onClickHideFieldType = (value: string) => void;
|
||||
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
|
||||
type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
type setDisplayedFieldsType = (fields: string[]) => void;
|
||||
|
||||
export type GetFieldLinksFn = (
|
||||
field: Field,
|
||||
@@ -39,15 +40,19 @@ export function isOnClickFilterOutString(callback: unknown): callback is onClick
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isIsFilterLabelActive(callback: unknown): callback is isFilterLabelActiveType {
|
||||
export function isIsFilterLabelActive(callback: unknown): callback is filterLabelActiveType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isOnClickShowField(callback: unknown): callback is isOnClickShowFieldType {
|
||||
export function isOnClickShowField(callback: unknown): callback is onClickShowFieldType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType {
|
||||
export function isOnClickHideField(callback: unknown): callback is onClickHideFieldType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isSetDisplayedFields(callback: unknown): callback is setDisplayedFieldsType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user