Repository: grafana/grafana
Pull request #103801: Logs: Clean up Explore meta information
Author: login: matyax
Labels: name: area/explore
name: area/frontend
name: add to changelog
name: no-backport
description: Skip backport of PR
Created: 2025-04-10T15:56:41Z | Merged: 2025-04-10T18:47:17Z

- Reorganized meta information
- Common labels have now a collapsed/expanded state.
  ![2025-04-10 17 54 13](https://github.com/user-attachments/assets/82c33673-39e8-4f16-8824-ac37fe0ac4fe)
- Controls for "escape newlines" has been moved to the Controls component
  ![2025-04-10 17 56 08](https://github.com/user-attachments/assets/d29773a2-494d-49c6-9d6b-ddc9b96632c7)



Diff:
diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx
index c98eec23cf69..59b509f7707c 100644
--- a/public/app/features/explore/Logs/Logs.tsx
+++ b/public/app/features/explore/Logs/Logs.tsx
@@ -209,7 +209,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
   );
   const [isFlipping, setIsFlipping] = useState<boolean>(false);
   const [displayedFields, setDisplayedFields] = useState<string[]>([]);
-  const [forceEscape, setForceEscape] = useState<boolean>(false);
   const [contextOpen, setContextOpen] = useState<boolean>(false);
   const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
   const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@@ -497,10 +496,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
     [sortOrderChanged]
   );
 
-  const onEscapeNewlines = useCallback(() => {
-    setForceEscape(!forceEscape);
-  }, [forceEscape]);
-
   const onChangeVisualisation = useCallback(
     (visualisation: LogsVisualisationType) => {
       setVisualisationType(visualisation);
@@ -1000,10 +995,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
             meta={logsMeta || []}
             dedupStrategy={dedupStrategy}
             dedupCount={dedupCount}
-            hasUnescapedContent={hasUnescapedContent}
-            forceEscape={forceEscape}
             displayedFields={displayedFields}
-            onEscapeNewlines={onEscapeNewlines}
             clearDetectedFields={clearDetectedFields}
           />
         </div>
@@ -1051,7 +1043,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
                 showLabels={showLabels}
                 showTime={showTime}
                 enableLogDetails={true}
-                forceEscape={forceEscape}
                 wrapLogMessage={wrapLogMessage}
                 prettifyLogMessage={prettifyLogMessage}
                 timeZone={timeZone}
@@ -1075,6 +1066,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
                 logsMeta={logsMeta}
                 logOptionsStorageKey={SETTING_KEY_ROOT}
                 onLogOptionsChange={onLogOptionsChange}
+                hasUnescapedContent={hasUnescapedContent}
               />
             </div>
           )}
@@ -1110,7 +1102,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
                       showLabels={showLabels}
                       showTime={showTime}
                       enableLogDetails={true}
-                      forceEscape={forceEscape}
                       wrapLogMessage={wrapLogMessage}
                       prettifyLogMessage={prettifyLogMessage}
                       timeZone={timeZone}
@@ -1158,7 +1149,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
                   dedupStrategy={dedupStrategy}
                   displayedFields={displayedFields}
                   filterLevels={filterLevels}
-                  forceEscape={forceEscape}
                   getFieldLinks={getFieldLinks}
                   getRowContextQuery={getRowContextQuery}
                   loadMore={loadMoreLogs}
diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx
index da0277fbcf45..618be8465110 100644
--- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx
+++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx
@@ -3,11 +3,19 @@ import userEvent from '@testing-library/user-event';
 import saveAs from 'file-saver';
 import { ComponentProps } from 'react';
 
-import { FieldType, LogLevel, LogsDedupStrategy, standardTransformersRegistry, toDataFrame } from '@grafana/data';
+import {
+  FieldType,
+  LogLevel,
+  LogsDedupStrategy,
+  LogsMetaItem,
+  LogsMetaKind,
+  standardTransformersRegistry,
+  store,
+  toDataFrame,
+} from '@grafana/data';
 import { organizeFieldsTransformer } from '@grafana/data/internal';
 import { config } from '@grafana/runtime';
 
-import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
 import { logRowsToReadableJson } from '../../logs/utils';
 import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields';
 
@@ -26,10 +34,7 @@ const defaultProps: LogsMetaRowProps = {
   dedupStrategy: LogsDedupStrategy.none,
   dedupCount: 0,
   displayedFields: [],
-  hasUnescapedContent: false,
-  forceEscape: false,
   logRows: [],
-  onEscapeNewlines: jest.fn(),
   clearDetectedFields: jest.fn(),
 };
 
@@ -49,13 +54,6 @@ describe('LogsMetaRow', () => {
     expect(await screen.findByText('1234')).toBeInTheDocument();
   });
 
-  it('renders a highlighting warning', async () => {
-    setup({ logRows: [{ entry: 'A'.repeat(MAX_CHARACTERS + 1) }] });
-    expect(
-      await screen.findByText('Logs with more than 100,000 characters could not be parsed and highlighted')
-    ).toBeInTheDocument();
-  });
-
   it('renders the show original line button', () => {
     setup({ displayedFields: ['test'] });
     expect(
@@ -65,7 +63,7 @@ describe('LogsMetaRow', () => {
     ).toBeInTheDocument();
   });
 
-  it('renders the displayedfield', async () => {
+  it('renders the displayed fields', async () => {
     setup({ displayedFields: ['testField1234'] });
     expect(await screen.findByText('testField1234')).toBeInTheDocument();
   });
@@ -85,39 +83,6 @@ describe('LogsMetaRow', () => {
     expect(clearSpy).toBeCalled();
   });
 
-  it('renders a button to remove escaping', () => {
-    setup({ hasUnescapedContent: true, forceEscape: true });
-    expect(
-      screen.getByRole('button', {
-        name: 'Remove escaping',
-      })
-    ).toBeInTheDocument();
-  });
-
-  it('renders a button to remove escaping', () => {
-    setup({ hasUnescapedContent: true, forceEscape: false });
-    expect(
-      screen.getByRole('button', {
-        name: 'Escape newlines',
-      })
-    ).toBeInTheDocument();
-  });
-
-  it('renders a button to remove escaping', () => {
-    const escapeSpy = jest.fn();
-    setup({ hasUnescapedContent: true, forceEscape: false, onEscapeNewlines: escapeSpy });
-    fireEvent(
-      screen.getByRole('button', {
-        name: 'Escape newlines',
-      }),
-      new MouseEvent('click', {
-        bubbles: true,
-        cancelable: true,
-      })
-    );
-    expect(escapeSpy).toBeCalled();
-  });
-
   it('renders a button to show the download menu', () => {
     setup();
     expect(screen.getByText('Download').closest('button')).toBeInTheDocument();
@@ -317,4 +282,62 @@ describe('LogsMetaRow', () => {
     const text = await blob.text();
     expect(text).toBe(`"Date","time","message bar"\r\n1970-01-01T00:00:00.001Z,1,INFO 1`);
   });
+
+  it('renders common labels', async () => {
+    const meta: LogsMetaItem[] = [
+      {
+        label: 'Common labels',
+        value: {
+          exporter: 'OTLP',
+          job: 'cicd-o11y/grafana-deployment-tools',
+        },
+        kind: LogsMetaKind.LabelsMap,
+      },
+    ];
+    setup({ meta });
+    expect(await screen.findByText(/Common labels/)).toBeInTheDocument();
+    expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument();
+    expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument();
+  });
+
+  it('renders collapsed common labels', async () => {
+    const meta: LogsMetaItem[] = [
+      {
+        label: 'Common labels',
+        value: {
+          exporter: 'OTLP',
+          job: 'cicd-o11y/grafana-deployment-tools',
+          service_name: 'grafana',
+          service_namespace: 'cicd-o11y',
+        },
+        kind: LogsMetaKind.LabelsMap,
+      },
+    ];
+    setup({ meta });
+    expect(await screen.findByText(/Common labels/)).toBeInTheDocument();
+    expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument();
+    expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument();
+    expect(await screen.findByLabelText('Expand labels')).toBeInTheDocument();
+  });
+
+  it('renders expanded common labels', async () => {
+    jest.spyOn(store, 'getBool').mockReturnValue(true);
+    const meta: LogsMetaItem[] = [
+      {
+        label: 'Common labels',
+        value: {
+          exporter: 'OTLP',
+          job: 'cicd-o11y/grafana-deployment-tools',
+          service_name: 'grafana',
+          service_namespace: 'cicd-o11y',
+        },
+        kind: LogsMetaKind.LabelsMap,
+      },
+    ];
+    setup({ meta });
+    expect(await screen.findByText(/Common labels/)).toBeInTheDocument();
+    expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument();
+    expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument();
+    expect(await screen.findByLabelText('Collapse labels')).toBeInTheDocument();
+  });
 });
diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx
index 96aa09fa933c..ab893d02933a 100644
--- a/public/app/features/explore/Logs/LogsMetaRow.tsx
+++ b/public/app/features/explore/Logs/LogsMetaRow.tsx
@@ -1,21 +1,26 @@
 import { css } from '@emotion/css';
 import { memo } from 'react';
 
-import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels } from '@grafana/data';
+import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
 import { config, reportInteraction } from '@grafana/runtime';
-import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
 import { Trans } from 'app/core/internationalization';
 
-import { LogLabels, LogLabelsList } from '../../logs/components/LogLabels';
-import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
+import { LogLabels, LogLabelsList, Props as LogLabelsProps } from '../../logs/components/LogLabels';
 import { DownloadFormat, downloadLogs } from '../../logs/utils';
 import { MetaInfoText, MetaItemProps } from '../MetaInfoText';
 
+import { SETTINGS_KEYS } from './utils/logs';
+
 const getStyles = () => ({
   metaContainer: css({
     flex: 1,
     display: 'flex',
     flexWrap: 'wrap',
+    '& span': {
+      fontWeight: 'normal',
+      lineHeight: '1.25em',
+    },
   }),
 });
 
@@ -24,25 +29,12 @@ export type Props = {
   dedupStrategy: LogsDedupStrategy;
   dedupCount: number;
   displayedFields: string[];
-  hasUnescapedContent: boolean;
-  forceEscape: boolean;
   logRows: LogRowModel[];
-  onEscapeNewlines: () => void;
   clearDetectedFields: () => void;
 };
 
 export const LogsMetaRow = memo(
-  ({
-    meta,
-    dedupStrategy,
-    dedupCount,
-    displayedFields,
-    clearDetectedFields,
-    hasUnescapedContent,
-    forceEscape,
-    onEscapeNewlines,
-    logRows,
-  }: Props) => {
+  ({ meta, dedupStrategy, dedupCount, displayedFields, clearDetectedFields, logRows }: Props) => {
     const style = useStyles2(getStyles);
 
     const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
@@ -55,14 +47,6 @@ export const LogsMetaRow = memo(
         kind: LogsMetaKind.Number,
       });
     }
-    // Add info about limit for highlighting
-    if (logRows.some((r) => r.entry.length > MAX_CHARACTERS)) {
-      logsMetaItem.push({
-        label: 'Info',
-        value: 'Logs with more than 100,000 characters could not be parsed and highlighted',
-        kind: LogsMetaKind.String,
-      });
-    }
 
     // Add detected fields info
     if (displayedFields?.length > 0) {
@@ -91,22 +75,6 @@ export const LogsMetaRow = memo(
       downloadLogs(format, logRows, meta);
     }
 
-    // Add unescaped content info
-    if (hasUnescapedContent) {
-      logsMetaItem.push({
-        label: 'Your logs might have incorrectly escaped content',
-        value: (
-          <Tooltip
-            content="Fix incorrectly escaped newline and tab sequences in log lines. Manually review the results to confirm that the replacements are correct."
-            placement="right"
-          >
-            <Button variant="secondary" size="sm" onClick={onEscapeNewlines}>
-              {forceEscape ? 'Remove escaping' : 'Escape newlines'}
-            </Button>
-          </Tooltip>
-        ),
-      });
-    }
     const downloadMenu = (
       <Menu>
         {/* eslint-disable-next-line @grafana/no-untranslated-strings */}
@@ -117,6 +85,17 @@ export const LogsMetaRow = memo(
         <Menu.Item label="csv" onClick={() => download(DownloadFormat.CSV)} />
       </Menu>
     );
+
+    const onCommonLabelsToggle = (state: boolean) => {
+      store.set(SETTINGS_KEYS.commonLabels, state);
+    };
+
+    const commonLabelsProps = {
+      onDisplayMaxToggle: onCommonLabelsToggle,
+      displayMax: 3,
+      displayAll: store.getBool(SETTINGS_KEYS.commonLabels, false),
+    };
+
     return (
       <>
         {logsMetaItem && (
@@ -125,7 +104,7 @@ export const LogsMetaRow = memo(
               metaItems={logsMetaItem.map((item) => {
                 return {
                   label: item.label,
-                  value: 'kind' in item ? renderMetaItem(item.value, item.kind) : item.value,
+                  value: 'kind' in item ? renderMetaItem(item.value, item.kind, commonLabelsProps) : item.value,
                 };
               })}
             />
@@ -145,12 +124,12 @@ export const LogsMetaRow = memo(
 
 LogsMetaRow.displayName = 'LogsMetaRow';
 
-function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind) {
+function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind, logLabelsProps: Partial<LogLabelsProps>) {
   if (typeof value === 'string' || typeof value === 'number') {
     return <>{value}</>;
   }
   if (kind === LogsMetaKind.LabelsMap) {
-    return <LogLabels labels={value} />;
+    return <LogLabels labels={value} {...logLabelsProps} />;
   }
   if (kind === LogsMetaKind.Error) {
     return <span className="logs-meta-item__error">{value.toString()}</span>;
diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts
index 1e8cd0374360..51926b33c877 100644
--- a/public/app/features/explore/Logs/utils/logs.ts
+++ b/public/app/features/explore/Logs/utils/logs.ts
@@ -5,6 +5,7 @@ export const SETTINGS_KEYS = {
   prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage',
   logsSortOrder: 'grafana.explore.logs.sortOrder',
   logContextWrapLogMessage: 'grafana.explore.logs.logContext.wrapLogMessage',
+  commonLabels: 'grafana.explore.logs.commonLabels',
 };
 
 export const SETTING_KEY_ROOT = 'grafana.explore.logs';
diff --git a/public/app/features/explore/MetaInfoText.tsx b/public/app/features/explore/MetaInfoText.tsx
index 906ff0c2c9c2..d2106cb155d5 100644
--- a/public/app/features/explore/MetaInfoText.tsx
+++ b/public/app/features/explore/MetaInfoText.tsx
@@ -26,6 +26,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
     marginRight: `calc(${theme.spacing(2)} / 2)`,
     fontSize: theme.typography.bodySmall.fontSize,
     fontWeight: theme.typography.fontWeightMedium,
+    whiteSpace: 'nowrap',
   }),
   metaValue: css({
     fontFamily: theme.typography.fontFamilyMonospace,
diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx
index dba49b9df093..a08a7955f951 100644
--- a/public/app/features/logs/components/ControlledLogRows.tsx
+++ b/public/app/features/logs/components/ControlledLogRows.tsx
@@ -25,6 +25,7 @@ import { LogListControls } from './panel/LogListControls';
 import { ScrollToLogsEvent } from './panel/virtualization';
 
 export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
+  hasUnescapedContent?: boolean;
   loading: boolean;
   logsMeta?: LogsMetaItem[];
   loadMoreLogs?: (range: AbsoluteTimeRange) => void;
@@ -50,6 +51,7 @@ export type LogRowsComponentProps = Omit<
 export const ControlledLogRows = ({
   deduplicatedRows,
   dedupStrategy,
+  hasUnescapedContent,
   showLabels,
   showTime,
   logsMeta,
@@ -65,6 +67,7 @@ export const ControlledLogRows = ({
       app={rest.app || CoreApp.Unknown}
       displayedFields={[]}
       dedupStrategy={dedupStrategy}
+      hasUnescapedContent={hasUnescapedContent}
       logOptionsStorageKey={logOptionsStorageKey}
       logs={deduplicatedRows ?? []}
       logsMeta={logsMeta}
@@ -83,8 +86,17 @@ export const ControlledLogRows = ({
 };
 
 const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range, ...rest }: LogRowsComponentProps) => {
-  const { app, dedupStrategy, filterLevels, prettifyJSON, sortOrder, showTime, showUniqueLabels, wrapLogMessage } =
-    useLogListContext();
+  const {
+    app,
+    dedupStrategy,
+    filterLevels,
+    forceEscape,
+    prettifyJSON,
+    sortOrder,
+    showTime,
+    showUniqueLabels,
+    wrapLogMessage,
+  } = useLogListContext();
   const eventBus = useMemo(() => new EventBusSrv(), []);
   const scrollElementRef = useRef<HTMLDivElement | null>(null);
 
@@ -124,6 +136,7 @@ const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range,
             app={app}
             dedupStrategy={dedupStrategy}
             deduplicatedRows={filteredLogs}
+            forceEscape={forceEscape}
             logRows={filteredLogs}
             logsSortOrder={sortOrder}
             scrollElement={scrollElementRef.current}
diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx
index f736b0a595c0..b70af5720217 100644
--- a/public/app/features/logs/components/LogLabels.test.tsx
+++ b/public/app/features/logs/components/LogLabels.test.tsx
@@ -35,6 +35,60 @@ describe('<LogLabels />', () => {
     await userEvent.hover(screen.getByText('foo=bar'));
     expect(screen.getAllByText('foo=bar')).toHaveLength(1);
   });
+  describe('displayMax', () => {
+    it('renders up to displayMax labels', () => {
+      render(<LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} />);
+      expect(screen.getByText('foo=bar')).toBeInTheDocument();
+      expect(screen.getByLabelText('Expand labels')).toBeInTheDocument();
+      expect(screen.queryByText('baz=42')).not.toBeInTheDocument();
+    });
+
+    it('allows to render all labels', async () => {
+      const onDisplayMaxToggle = jest.fn();
+      const { rerender } = render(
+        <LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} onDisplayMaxToggle={onDisplayMaxToggle} />
+      );
+
+      await userEvent.click(screen.getByLabelText('Expand labels'));
+      expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1);
+      expect(onDisplayMaxToggle).toHaveBeenCalledWith(true);
+
+      rerender(<LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} onDisplayMaxToggle={onDisplayMaxToggle} />);
+
+      expect(screen.getByText('foo=bar')).toBeInTheDocument();
+      expect(screen.getByText('baz=42')).toBeInTheDocument();
+      expect(screen.getByLabelText('Collapse labels')).toBeInTheDocument();
+    });
+
+    it('allows to collapse labels', async () => {
+      const onDisplayMaxToggle = jest.fn();
+      const { rerender } = render(
+        <LogLabels
+          labels={{ foo: 'bar', baz: '42' }}
+          displayMax={1}
+          displayAll
+          onDisplayMaxToggle={onDisplayMaxToggle}
+        />
+      );
+
+      await userEvent.click(screen.getByLabelText('Collapse labels'));
+      expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1);
+      expect(onDisplayMaxToggle).toHaveBeenCalledWith(false);
+
+      rerender(
+        <LogLabels
+          labels={{ foo: 'bar', baz: '42' }}
+          displayMax={1}
+          displayAll
+          onDisplayMaxToggle={onDisplayMaxToggle}
+        />
+      );
+
+      expect(screen.getByText('foo=bar')).toBeInTheDocument();
+      expect(screen.getByLabelText('Expand labels')).toBeInTheDocument();
+      expect(screen.queryByText('baz=42')).not.toBeInTheDocument();
+    });
+  });
 });
 
 describe('<LogLabelsList />', () => {
diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx
index fa675287a726..6b363383906e 100644
--- a/public/app/features/logs/components/LogLabels.tsx
+++ b/public/app/features/logs/components/LogLabels.tsx
@@ -1,54 +1,101 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            aria-label={t('logs.log-labels.expand', 'Expand labels')}
+            onClick={() => {
+              setDisplayAll(true);
+              onDisplayMaxToggle?.(true);
+            }}
+          >
+            <Icon name="plus" size="xs" />
+            {allLabels.length - displayLabels.length}
+          </Button>
+        )}
+        {displayAll === true && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            aria-label={t('logs.log-labels.collapse', 'Collapse labels')}
+            onClick={() => {
+              setDisplayAll(false);
+              onDisplayMaxToggle?.(false);
+            }}
+          >
+            <Icon name="minus" size="xs" />
+          </Button>
+        )}
       </span>
     );
   }
-
-  return (
-    <span className={cx([styles.logsLabels])}>
-      {displayLabels.map((labelValue) => {
-        return addTooltip ? (
-          <Tooltip content={labelValue} key={labelValue} placement="top">
-            <LogLabel styles={styles}>{labelValue}</LogLabel>
-          </Tooltip>
-        ) : (
-          <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
-            {labelValue}
-          </LogLabel>
-        );
-      })}
-    </span>
-  );
-});
+);
 LogLabels.displayName = 'LogLabels';
 
 interface LogLabelsArrayProps {
@@ -58,7 +105,7 @@ interface LogLabelsArrayProps {
 export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
   const styles = useStyles2(getStyles);
   return (
-    <span className={cx([styles.logsLabels])}>
+    <span className={styles.logsLabels}>
       {labels.map((label) => (
         <LogLabel key={label} styles={styles} tooltip={label}>
           {label === LOG_LINE_BODY_FIELD_NAME ? 'log line' : label}
@@ -77,8 +124,8 @@ interface LogLabelProps {
 
 const LogLabel = forwardRef<HTMLSpanElement, LogLabelProps>(({ styles, tooltip, children }: LogLabelProps, ref) => {
   return (
-    <span className={cx([styles.logsLabel])} ref={ref}>
-      <span className={cx([styles.logsLabelValue])} title={tooltip}>
+    <span className={styles.logsLabel} ref={ref}>
+      <span className={styles.logsLabelValue} title={tooltip}>
         {children}
       </span>
     </span>
@@ -92,6 +139,7 @@ const getStyles = (theme: GrafanaTheme2) => {
       display: 'flex',
       flexWrap: 'wrap',
       fontSize: theme.typography.size.xs,
+      alignItems: 'center',
     }),
     logsLabel: css({
       label: 'logs-label',
@@ -103,6 +151,7 @@ const getStyles = (theme: GrafanaTheme2) => {
       textOverflow: 'ellipsis',
       whiteSpace: 'nowrap',
       overflow: 'hidden',
+      maxHeight: theme.spacing(2),
     }),
     logsLabelValue: css({
       label: 'logs-label__value',
diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx
index 366293af4ae0..f74acfdf181d 100644
--- a/public/app/features/logs/components/panel/LogList.tsx
+++ b/public/app/features/logs/components/panel/LogList.tsx
@@ -47,7 +47,6 @@ interface Props {
   displayedFields: string[];
   eventBus?: EventBus;
   filterLevels?: LogLevel[];
-  forceEscape?: boolean;
   getFieldLinks?: GetFieldLinksFn;
   getRowContextQuery?: GetRowContextQueryFn;
   grammar?: Grammar;
@@ -89,7 +88,6 @@ export const LogList = ({
   dedupStrategy,
   eventBus,
   filterLevels,
-  forceEscape = false,
   getFieldLinks,
   getRowContextQuery,
   grammar,
@@ -143,7 +141,6 @@ export const LogList = ({
       <LogListComponent
         containerElement={containerElement}
         eventBus={eventBus}
-        forceEscape={forceEscape}
         getFieldLinks={getFieldLinks}
         grammar={grammar}
         initialScrollPosition={initialScrollPosition}
@@ -160,7 +157,6 @@ export const LogList = ({
 const LogListComponent = ({
   containerElement,
   eventBus = new EventBusSrv(),
-  forceEscape = false,
   getFieldLinks,
   grammar,
   initialScrollPosition = 'top',
@@ -170,7 +166,7 @@ const LogListComponent = ({
   timeRange,
   timeZone,
 }: LogListComponentProps) => {
-  const { app, displayedFields, filterLevels, showTime, sortOrder, wrapLogMessage } = useLogListContext();
+  const { app, displayedFields, filterLevels, forceEscape, showTime, sortOrder, wrapLogMessage } = useLogListContext();
   const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
   const [listHeight, setListHeight] = useState(
     app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
@@ -197,7 +193,9 @@ const LogListComponent = ({
   }, [eventBus, logs.length]);
 
   useEffect(() => {
-    setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone }, grammar));
+    setProcessedLogs(
+      preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar)
+    );
   }, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]);
 
   useEffect(() => {
diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx
index 38de4bfab75b..7a1269d28bec 100644
--- a/public/app/features/logs/components/panel/LogListContext.tsx
+++ b/public/app/features/logs/components/panel/LogListContext.tsx
@@ -28,9 +28,11 @@ import { GetRowContextQueryFn } from './LogLineMenu';
 export interface LogListContextData extends Omit<Props, 'logs' | 'logsMeta' | 'showControls'> {
   downloadLogs: (format: DownloadFormat) => void;
   filterLevels: LogLevel[];
+  hasUnescapedContent?: boolean;
   setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
   setDisplayedFields: (displayedFields: string[]) => void;
   setFilterLevels: (filterLevels: LogLevel[]) => void;
+  setForceEscape: (forceEscape: boolean) => void;
   setLogListState: Dispatch<SetStateAction<LogListState>>;
   setPinnedLogs: (pinnedlogs: string[]) => void;
   setPrettifyJSON: (prettifyJSON: boolean) => void;
@@ -47,9 +49,11 @@ export const LogListContext = createContext<LogListContextData>({
   displayedFields: [],
   downloadLogs: () => {},
   filterLevels: [],
+  hasUnescapedContent: false,
   setDedupStrategy: () => {},
   setDisplayedFields: () => {},
   setFilterLevels: () => {},
+  setForceEscape: () => {},
   setLogListState: () => {},
   setPinnedLogs: () => {},
   setPrettifyJSON: () => {},
@@ -82,7 +86,9 @@ export type LogListState = Pick<
   LogListContextData,
   | 'dedupStrategy'
   | 'displayedFields'
+  | 'forceEscape'
   | 'filterLevels'
+  | 'hasUnescapedContent'
   | 'pinnedLogs'
   | 'prettifyJSON'
   | 'showUniqueLabels'
@@ -98,6 +104,8 @@ export interface Props {
   dedupStrategy: LogsDedupStrategy;
   displayedFields: string[];
   filterLevels?: LogLevel[];
+  forceEscape?: boolean;
+  hasUnescapedContent?: boolean;
   getRowContextQuery?: GetRowContextQueryFn;
   logs: LogRowModel[];
   logsMeta?: LogsMetaItem[];
@@ -125,11 +133,13 @@ export const LogListContextProvider = ({
   children,
   dedupStrategy,
   displayedFields,
+  filterLevels,
+  forceEscape = false,
+  hasUnescapedContent,
   getRowContextQuery,
   logs,
   logsMeta,
   logOptionsStorageKey,
-  filterLevels,
   logSupportsContext,
   onLogOptionsChange,
   onLogLineHover,
@@ -152,6 +162,8 @@ export const LogListContextProvider = ({
     displayedFields,
     filterLevels:
       filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []),
+    forceEscape,
+    hasUnescapedContent,
     pinnedLogs,
     prettifyJSON,
     showTime,
@@ -205,6 +217,12 @@ export const LogListContextProvider = ({
     }
   }, [filterLevels, logListState]);
 
+  useEffect(() => {
+    if (logListState.hasUnescapedContent !== hasUnescapedContent) {
+      setLogListState({ ...logListState, hasUnescapedContent });
+    }
+  }, [hasUnescapedContent, logListState]);
+
   const setDedupStrategy = useCallback(
     (dedupStrategy: LogsDedupStrategy) => {
       setLogListState({ ...logListState, dedupStrategy });
@@ -221,6 +239,13 @@ export const LogListContextProvider = ({
     [logListState, onLogOptionsChange]
   );
 
+  const setForceEscape = useCallback(
+    (forceEscape: boolean) => {
+      setLogListState({ ...logListState, forceEscape });
+    },
+    [logListState]
+  );
+
   const setFilterLevels = useCallback(
     (filterLevels: LogLevel[]) => {
       setLogListState({ ...logListState, filterLevels });
@@ -322,6 +347,8 @@ export const LogListContextProvider = ({
         displayedFields: logListState.displayedFields,
         downloadLogs,
         filterLevels: logListState.filterLevels,
+        forceEscape: logListState.forceEscape,
+        hasUnescapedContent: logListState.hasUnescapedContent,
         getRowContextQuery,
         logSupportsContext,
         onLogLineHover,
@@ -335,6 +362,7 @@ export const LogListContextProvider = ({
         setDedupStrategy,
         setDisplayedFields,
         setFilterLevels,
+        setForceEscape,
         setLogListState,
         setPinnedLogs,
         setPrettifyJSON,
diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx
index ec35cf0fa84a..2471506d8d51 100644
--- a/public/app/features/logs/components/panel/LogListControls.test.tsx
+++ b/public/app/features/logs/components/panel/LogListControls.test.tsx
@@ -41,6 +41,10 @@ describe('LogListControls', () => {
     expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
     expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument();
     expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument();
+    expect(
+      screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')
+    ).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
   });
 
   test('Renders legacy controls', () => {
@@ -242,4 +246,19 @@ describe('LogListControls', () => {
     await userEvent.click(await screen.findByText('txt'));
     expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined);
   });
+
+  test('Controls new lines', async () => {
+    const { rerender } = render(
+      <LogListContextProvider {...contextProps} hasUnescapedContent>
+        <LogListControls eventBus={new EventBusSrv()} />
+      </LogListContextProvider>
+    );
+    await userEvent.click(screen.getByLabelText('Fix incorrectly escaped newline and tab sequences in log lines'));
+    rerender(
+      <LogListContextProvider {...contextProps} hasUnescapedContent>
+        <LogListControls eventBus={new EventBusSrv()} />
+      </LogListContextProvider>
+    );
+    await userEvent.click(screen.getByLabelText('Remove escaping'));
+  });
 });
diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx
index 2eccad4c4e33..231591764757 100644
--- a/public/app/features/logs/components/panel/LogListControls.tsx
+++ b/public/app/features/logs/components/panel/LogListControls.tsx
@@ -42,9 +42,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
     dedupStrategy,
     downloadLogs,
     filterLevels,
+    forceEscape,
+    hasUnescapedContent,
     prettifyJSON,
     setDedupStrategy,
     setFilterLevels,
+    setForceEscape,
     setPrettifyJSON,
     setShowTime,
     setShowUniqueLabels,
@@ -76,6 +79,11 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
     );
   }, [eventBus]);
 
+  const onForceEscapeClick = useCallback(() => {
+    reportInteraction('logs_log_list_controls_force_escape_clicked');
+    setForceEscape(!forceEscape);
+  }, [forceEscape, setForceEscape]);
+
   const onFilterLevelClick = useCallback(
     (level?: LogLevel) => {
       reportInteraction('logs_log_list_controls_level_clicked');
@@ -310,6 +318,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
                   size="lg"
                 />
               )}
+              {hasUnescapedContent && (
+                <IconButton
+                  name="enter"
+                  aria-pressed={forceEscape}
+                  className={forceEscape ? styles.controlButtonActive : styles.controlButton}
+                  onClick={onForceEscapeClick}
+                  tooltip={
+                    forceEscape
+                      ? t('logs.logs-controls.remove-escaping', 'Remove escaping')
+                      : t(
+                          'logs.logs-controls.escape-newlines',
+                          'Fix incorrectly escaped newline and tab sequences in log lines'
+                        )
+                  }
+                  size="lg"
+                />
+              )}
             </>
           )}
           {!config.exploreHideLogsDownload && (
diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
index e514463374c0..9cff7a3367bf 100644
--- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
+++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
@@ -13,6 +13,7 @@ export const LogListContext = createContext<LogListContextData>({
   setDedupStrategy: () => {},
   setDisplayedFields: () => {},
   setFilterLevels: () => {},
+  setForceEscape: () => {},
   setLogListState: () => {},
   setPinnedLogs: () => {},
   setPrettifyJSON: () => {},
@@ -57,6 +58,7 @@ export const defaultProps = {
   setDedupStrategy: jest.fn(),
   setDisplayedFields: jest.fn(),
   setFilterLevels: jest.fn(),
+  setForceEscape: jest.fn(),
   setLogListState: jest.fn(),
   setPinnedLogs: jest.fn(),
   setShowTime: jest.fn(),
@@ -108,6 +110,7 @@ export const LogListContextProvider = ({
         setDedupStrategy: jest.fn(),
         setDisplayedFields: jest.fn(),
         setFilterLevels: jest.fn(),
+        setForceEscape: jest.fn(),
         setLogListState: jest.fn(),
         setPinnedLogs: jest.fn(),
         setPrettifyJSON: jest.fn(),
diff --git a/public/app/features/logs/logsModel.test.ts b/public/app/features/logs/logsModel.test.ts
index 69bf270c891f..3c7b599c7f6a 100644
--- a/public/app/features/logs/logsModel.test.ts
+++ b/public/app/features/logs/logsModel.test.ts
@@ -348,6 +348,11 @@ describe('dataFrameToLogsModel', () => {
     ]);
     expect(logsModel.meta).toHaveLength(2);
     expect(logsModel.meta![0]).toMatchObject({
+      label: '',
+      value: `2 lines returned`,
+      kind: LogsMetaKind.String,
+    });
+    expect(logsModel.meta![1]).toMatchObject({
       label: COMMON_LABELS,
       value: {
         filename: '/var/log/grafana/grafana.log',
@@ -355,11 +360,6 @@ describe('dataFrameToLogsModel', () => {
       },
       kind: LogsMetaKind.LabelsMap,
     });
-    expect(logsModel.meta![1]).toMatchObject({
-      label: '',
-      value: `2 lines returned`,
-      kind: LogsMetaKind.String,
-    });
   });
 
   it('given one series should return expected logs model with detected_level', () => {
@@ -430,17 +430,17 @@ describe('dataFrameToLogsModel', () => {
     ]);
     expect(logsModel.meta).toHaveLength(2);
     expect(logsModel.meta![0]).toMatchObject({
+      label: '',
+      value: `2 lines returned`,
+      kind: LogsMetaKind.String,
+    });
+    expect(logsModel.meta![1]).toMatchObject({
       label: COMMON_LABELS,
       value: {
         job: 'grafana',
       },
       kind: LogsMetaKind.LabelsMap,
     });
-    expect(logsModel.meta![1]).toMatchObject({
-      label: '',
-      value: `2 lines returned`,
-      kind: LogsMetaKind.String,
-    });
   });
 
   it('with infinite scrolling enabled it should return expected logs model', () => {
@@ -485,7 +485,7 @@ describe('dataFrameToLogsModel', () => {
   it('given one series with limit as custom meta property should return correct limit', () => {
     const series: DataFrame[] = getTestDataFrame();
     const logsModel = dataFrameToLogsModel(series, 1);
-    expect(logsModel.meta![1]).toMatchObject({
+    expect(logsModel.meta![0]).toMatchObject({
       label: '',
       value: `2 lines returned`,
       kind: LogsMetaKind.String,
@@ -516,16 +516,16 @@ describe('dataFrameToLogsModel', () => {
       queries
     );
     expect(logsModel.meta).toEqual([
-      {
-        label: 'Common labels',
-        value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
-        kind: 2,
-      },
       {
         label: '',
         value: '2 lines shown — 21.85% (5h 14min 40sec) of 24h',
         kind: 1,
       },
+      {
+        label: 'Common labels',
+        value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+        kind: 2,
+      },
     ]);
   });
 
@@ -554,16 +554,16 @@ describe('dataFrameToLogsModel', () => {
       queries
     );
     expect(logsModel.meta).toEqual([
-      {
-        label: 'Common labels',
-        value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
-        kind: 2,
-      },
       {
         label: '',
         value: '2 reached',
         kind: 1,
       },
+      {
+        label: 'Common labels',
+        value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+        kind: 2,
+      },
     ]);
   });
 
@@ -649,15 +649,15 @@ describe('dataFrameToLogsModel', () => {
     ]);
     expect(logsModel.meta).toHaveLength(2);
     expect(logsModel.meta![0]).toMatchObject({
-      label: COMMON_LABELS,
-      value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
-      kind: LogsMetaKind.LabelsMap,
-    });
-    expect(logsModel.meta![1]).toMatchObject({
       label: '',
       value: `2 lines returned`,
       kind: LogsMetaKind.String,
     });
+    expect(logsModel.meta![1]).toMatchObject({
+      label: COMMON_LABELS,
+      value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' },
+      kind: LogsMetaKind.LabelsMap,
+    });
   });
 
   it('given one series with labels-field it should work regardless the label-fields position', () => {
@@ -768,20 +768,20 @@ describe('dataFrameToLogsModel', () => {
     expect(logsModel.series).toHaveLength(2);
     expect(logsModel.meta).toHaveLength(3);
     expect(logsModel.meta![0]).toMatchObject({
-      label: COMMON_LABELS,
-      value: series[0].fields[1].labels,
-      kind: LogsMetaKind.LabelsMap,
-    });
-    expect(logsModel.meta![1]).toMatchObject({
       label: '',
       value: `2 lines returned`,
       kind: LogsMetaKind.String,
     });
-    expect(logsModel.meta![2]).toMatchObject({
+    expect(logsModel.meta![1]).toMatchObject({
       label: '',
       value: 'Error when parsing some of the logs',
       kind: LogsMetaKind.Error,
     });
+    expect(logsModel.meta![2]).toMatchObject({
+      label: COMMON_LABELS,
+      value: series[0].fields[1].labels,
+      kind: LogsMetaKind.LabelsMap,
+    });
   });
 
   it('given one series without labels should return expected logs model', () => {
@@ -1110,15 +1110,15 @@ describe('dataFrameToLogsModel', () => {
     const logsModel = dataFrameToLogsModel(series, 1, { from: 1556270591353, to: 1556289770991 });
     expect(logsModel.meta).toHaveLength(2);
     expect(logsModel.meta![0]).toMatchObject({
-      label: COMMON_LABELS,
-      value: series[0].fields[1].labels,
-      kind: LogsMetaKind.LabelsMap,
-    });
-    expect(logsModel.meta![1]).toMatchObject({
       label: '',
       value: `2 lines shown — 98.44% (5h 14min 40sec) of 5h 19min 40sec`,
       kind: LogsMetaKind.String,
     });
+    expect(logsModel.meta![1]).toMatchObject({
+      label: COMMON_LABELS,
+      value: series[0].fields[1].labels,
+      kind: LogsMetaKind.LabelsMap,
+    });
   });
 
   it('should fallback to row index if no id', () => {
@@ -1271,9 +1271,9 @@ describe('logSeriesToLogsModel', () => {
 
     const logsModel = dataFrameToLogsModel(logSeries, 0);
     expect(logsModel.meta).toMatchObject([
-      { kind: 2, label: COMMON_LABELS, value: { foo: 'bar', level: 'dbug' } },
       { kind: 0, label: LIMIT_LABEL, value: 2000 },
       { kind: 1, label: 'Total bytes processed', value: '194  kB' },
+      { kind: 2, label: COMMON_LABELS, value: { foo: 'bar', level: 'dbug' } },
     ]);
     expect(logsModel.rows).toHaveLength(3);
     expect(logsModel.rows).toMatchObject([
diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts
index 12aebd4368ea..7e75684d423a 100644
--- a/public/app/features/logs/logsModel.ts
+++ b/public/app/features/logs/logsModel.ts
@@ -473,13 +473,6 @@ export function logSeriesToLogsModel(
 
   // Meta data to display in status
   const meta: LogsMetaItem[] = [];
-  if (size(commonLabels) > 0) {
-    meta.push({
-      label: COMMON_LABELS,
-      value: commonLabels,
-      kind: LogsMetaKind.LabelsMap,
-    });
-  }
   // Data sources that set up searchWords on backend use meta.custom.limit.
   // Data sources that set up searchWords through frontend can use meta.limit.
   const limits = logSeries.filter((series) => series?.meta?.custom?.limit ?? series?.meta?.limit);
@@ -538,6 +531,14 @@ export function logSeriesToLogsModel(
     });
   }
 
+  if (size(commonLabels) > 0) {
+    meta.push({
+      label: COMMON_LABELS,
+      value: commonLabels,
+      kind: LogsMetaKind.LabelsMap,
+    });
+  }
+
   return {
     hasUniqueLabels,
     meta,
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 51a56abfae4d..5f39fb626eb6 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -5213,6 +5213,10 @@
         "tooltip-show-log-line": "Show log line"
       }
     },
+    "log-labels": {
+      "collapse": "Collapse labels",
+      "expand": "Expand labels"
+    },
     "log-line-menu": {
       "copy-link": "Copy link to log line",
       "copy-log": "Copy log line",
@@ -5265,11 +5269,13 @@
         "txt": "txt"
       },
       "enable-highlighting": "Enable highlighting",
+      "escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
       "hide-timestamps": "Hide timestamps",
       "hide-unique-labels": "Hide unique labels",
       "newest-first": "Newest logs first",
       "oldest-first": "Oldest logs first",
       "prettify-json": "Expand JSON logs",
+      "remove-escaping": "Remove escaping",
       "scroll-bottom": "Scroll to bottom",
       "scroll-top": "Scroll to top",
       "show-timestamps": "Show timestamps",


Reviews:
user: login: gtk-grafana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T15:59:00Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:01:18Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:10:26Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:11:41Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:12:57Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:17:13Z
user: login: gtk-grafana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:35:55Z
user: login: gtk-grafana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:38:30Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:39:35Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:40:39Z
user: login: gtk-grafana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:44:50Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:45:01Z
user: login: gtk-grafana
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:50:18Z
user: login: gtk-grafana
body: LGTM, amazing work as always. Everything looks great and is working as expected locally.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:53:21Z
user: login: matyax
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:54:44Z
user: login: L2D2Grafana
body: lgtm
I'm assuming these are just my own user errors and unfamiliarity with core grafana
- I do not see the new escape icon on the side bar
![Screenshot 2025-04-10 at 10 40 38 AM](https://github.com/user-attachments/assets/69da097f-6a0b-4c8a-8cbe-ea8bbe013241)
- I do not see data in the table view
![Screenshot 2025-04-10 at 10 40 44 AM](https://github.com/user-attachments/assets/a8d19b35-5ca1-4550-868d-5204fda2cb01)



state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T17:43:41Z

Review comments:
diff_hunk: @@ -538,6 +531,14 @@ export function logSeriesToLogsModel(
     });
   }
 
+  if (size(commonLabels) > 0) {
path: public/app/features/logs/logsModel.ts
user: login: gtk-grafana
body: does this need to move?
created_at: 2025-04-10T15:59:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -538,6 +531,14 @@ export function logSeriesToLogsModel(
     });
   }
 
+  if (size(commonLabels) > 0) {
path: public/app/features/logs/logsModel.ts
user: login: matyax
body: Yeah. This was the simplest approach, since it shows stuff as it's been defined in the array. The next time we rearrange this it should happen in LogsMetaRow.
created_at: 2025-04-10T16:01:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,13 +54,6 @@ describe('LogsMetaRow', () => {
     expect(await screen.findByText('1234')).toBeInTheDocument();
   });
 
-  it('renders a highlighting warning', async () => {
path: public/app/features/explore/Logs/LogsMetaRow.test.tsx
user: login: matyax
body: This will be no longer displayed after these changes.
created_at: 2025-04-10T16:10:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,26 @@
 import { css } from '@emotion/css';
 import { memo } from 'react';
 
-import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels } from '@grafana/data';
+import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
 import { config, reportInteraction } from '@grafana/runtime';
-import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
 import { Trans } from 'app/core/internationalization';
 
-import { LogLabels, LogLabelsList } from '../../logs/components/LogLabels';
-import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
+import { LogLabels, LogLabelsList, Props as LogLabelsProps } from '../../logs/components/LogLabels';
 import { DownloadFormat, downloadLogs } from '../../logs/utils';
 import { MetaInfoText, MetaItemProps } from '../MetaInfoText';
 
+import { SETTINGS_KEYS } from './utils/logs';
+
 const getStyles = () => ({
   metaContainer: css({
     flex: 1,
     display: 'flex',
     flexWrap: 'wrap',
+    '& span': {
path: public/app/features/explore/Logs/LogsMetaRow.tsx
user: login: matyax
body: Changing font weight would require to make the change in MetaInfoText, component that is used in other places.
Line height is also being set to properly align meta rows, otherwise key and values would be misaligned.
created_at: 2025-04-10T16:11:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
path: public/app/features/logs/components/LogLabels.tsx
user: login: matyax
body: Inspired by https://developers.grafana.com/ui/latest/index.html?path=/docs/forms-tags-taglist--docs
created_at: 2025-04-10T16:12:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2534,6 +2534,10 @@ exports[`better eslint`] = {
     "public/app/features/logs/components/LogLabelStats.tsx:5381": [
       [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
     ],
+    "public/app/features/logs/components/LogLabels.tsx:5381": [
+      [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
path: .betterer.results
user: login: matyax
body: Complaining about the + and - characters in the buttons.
created_at: 2025-04-10T16:17:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
path: public/app/features/logs/components/LogLabels.tsx
user: login: gtk-grafana
body: if we gonna export this interface should we rename?
created_at: 2025-04-10T16:35:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            onClick={() => {
+              setDisplayAll(true);
+              onDisplayMaxToggle?.(true);
+            }}
+          >
+            +{allLabels.length - displayLabels.length}
path: public/app/features/logs/components/LogLabels.tsx
user: login: gtk-grafana
body: Use icons instead of +/- char? 
created_at: 2025-04-10T16:38:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
path: public/app/features/logs/components/LogLabels.tsx
user: login: matyax
body: No, this is something that it's done in many places that I consider (I hope not wrongly) a standard. https://github.com/search?q=repo%3Agrafana%2Fgrafana%20export%20interface%20Props&type=code

And then rename, if nedeed, on the import.
created_at: 2025-04-10T16:39:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            onClick={() => {
+              setDisplayAll(true);
+              onDisplayMaxToggle?.(true);
+            }}
+          >
+            +{allLabels.length - displayLabels.length}
path: public/app/features/logs/components/LogLabels.tsx
user: login: matyax
body: Ahhh, good point, let me try.
created_at: 2025-04-10T16:40:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            onClick={() => {
+              setDisplayAll(true);
+              onDisplayMaxToggle?.(true);
+            }}
+          >
+            +{allLabels.length - displayLabels.length}
path: public/app/features/logs/components/LogLabels.tsx
user: login: gtk-grafana
body: [IconButton](https://developers.grafana.com/ui/latest/index.html?path=/story/buttons-iconbutton--basic) instead of the button should be an easy swap
created_at: 2025-04-10T16:44:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
+            size="sm"
+            fill="outline"
+            variant="secondary"
+            onClick={() => {
+              setDisplayAll(true);
+              onDisplayMaxToggle?.(true);
+            }}
+          >
+            +{allLabels.length - displayLabels.length}
path: public/app/features/logs/components/LogLabels.tsx
user: login: matyax
body: Using icons.
created_at: 2025-04-10T16:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
path: public/app/features/logs/components/LogLabels.tsx
user: login: gtk-grafana
body: Tooltip might be nice to have "expand common labels" and "collapse common labels" would help clarify it's function
created_at: 2025-04-10T16:50:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,54 +1,97 @@
-import { css, cx } from '@emotion/css';
-import { memo, forwardRef, useMemo } from 'react';
+import { css } from '@emotion/css';
+import { memo, forwardRef, useMemo, useState } from 'react';
 
 import { GrafanaTheme2, Labels } from '@grafana/data';
-import { Tooltip, useStyles2 } from '@grafana/ui';
+import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui';
 
 import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
 
 // Levels are already encoded in color, filename is a Loki-ism
 const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
 
-interface Props {
+export interface Props {
   labels: Labels;
   emptyMessage?: string;
   addTooltip?: boolean;
+  displayMax?: number;
+  displayAll?: boolean;
+  onDisplayMaxToggle?(state: boolean): void;
 }
 
-export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => {
-  const styles = useStyles2(getStyles);
-  const displayLabels = useMemo(
-    () =>
-      Object.keys(labels)
-        .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
-        .map((label) => `${label}=${labels[label]}`),
-    [labels]
-  );
+export const LogLabels = memo(
+  ({
+    labels,
+    emptyMessage,
+    addTooltip = true,
+    displayMax,
+    onDisplayMaxToggle,
+    displayAll: initialDisplayAll = false,
+  }: Props) => {
+    const [displayAll, setDisplayAll] = useState<boolean | undefined>(displayMax ? initialDisplayAll : undefined);
+    const styles = useStyles2(getStyles);
+    const allLabels = useMemo(
+      () =>
+        Object.keys(labels)
+          .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label])
+          .map((label) => `${label}=${labels[label]}`),
+      [labels]
+    );
+    const displayLabels = useMemo(
+      () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity),
+      [allLabels, displayAll, displayMax]
+    );
+
+    if (displayLabels.length === 0 && emptyMessage) {
+      return (
+        <span className={styles.logsLabels}>
+          <span className={styles.logsLabel}>{emptyMessage}</span>
+        </span>
+      );
+    }
 
-  if (displayLabels.length === 0 && emptyMessage) {
     return (
-      <span className={cx([styles.logsLabels])}>
-        <span className={cx([styles.logsLabel])}>{emptyMessage}</span>
+      <span className={styles.logsLabels}>
+        {displayLabels.map((labelValue) => {
+          return addTooltip ? (
+            <Tooltip content={labelValue} key={labelValue} placement="top">
+              <LogLabel styles={styles}>{labelValue}</LogLabel>
+            </Tooltip>
+          ) : (
+            <LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
+              {labelValue}
+            </LogLabel>
+          );
+        })}
+        {displayLabels.length < allLabels.length && !displayAll && (
+          <Button
path: public/app/features/logs/components/LogLabels.tsx
user: login: matyax
body: Because LogLabels is a generic component, that would require more code that I would like, but great call out because the button was missing, at least, a good aria-label.
created_at: 2025-04-10T16:54:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: matyax
created_at: 2025-04-10T17:46:40Z
body: @L2D2Grafana 

> I do not see the new escape icon on the side bar

Good point. It only shows when your logs have "unescaped content", otherwise is hidden. I'll give you a query that, sometimes, returns unscaped content.

Also, you have `newLogsPanel` enabled, and these changes are for the current panel + new controls, so the flag is `logsPanelControls`

> I do not see data in the table view

Looking
author_association: CONTRIBUTOR
user: login: gtk-grafana
created_at: 2025-04-10T17:47:07Z
body: @L2D2Grafana the escape icon will only show when improperly escaped newlines are detected, check the cloud/aws service in grafanacloud-ops-dev db.

And the table bit is concerning, it's working for me locally, can you reproduce reliably? Any console errors? If you can repro with specific datasource can you send us the query where you see that so we can double check? 
author_association: CONTRIBUTOR
user: login: matyax
created_at: 2025-04-10T17:48:56Z
body: @L2D2Grafana has `newLogsPanel` enabled, I think it's that
author_association: CONTRIBUTOR
user: login: gtk-grafana
created_at: 2025-04-10T17:50:20Z
body: > @L2D2Grafana has newLogsPanel enabled, I think it's that

Ahhh we probably missed a conditional in that other PR you merged earlier
author_association: CONTRIBUTOR