From 684b37556f30093852948c52e90d32502f607ebe Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu, 27 Mar 2025 07:11:16 -0500 Subject: [PATCH] Logs Panel: Add ISO8601 date to log download files (#102932) * feat: add ISO8601 date to log download files --- .../explore/Logs/LogsMetaRow.test.tsx | 4 +- .../app/features/explore/Logs/LogsMetaRow.tsx | 42 +++++++++++++++---- .../features/inspector/utils/download.test.ts | 2 +- .../app/features/inspector/utils/download.ts | 3 +- public/app/features/logs/utils.test.ts | 13 +++++- public/app/features/logs/utils.ts | 2 + 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index e66a3d1bd77..da0277fbcf4 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -273,7 +273,7 @@ describe('LogsMetaRow', () => { { name: 'time', type: FieldType.time, - values: ['1970-01-02T00:00:00Z'], + values: [1], }, { name: 'message', @@ -315,6 +315,6 @@ describe('LogsMetaRow', () => { const blob = (saveAs as unknown as jest.Mock).mock.lastCall[0]; expect(blob.type).toBe('text/csv;charset=utf-8'); const text = await blob.text(); - expect(text).toBe(`"time","message bar"\r\n1970-01-02T00:00:00Z,INFO 1`); + expect(text).toBe(`"Date","time","message bar"\r\n1970-01-01T00:00:00.001Z,1,INFO 1`); }); }); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 8be56cca1cc..610432cbaf8 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import saveAs from 'file-saver'; import { memo } from 'react'; -import { lastValueFrom } from 'rxjs'; +import { lastValueFrom, map, Observable } from 'rxjs'; import { LogsDedupStrategy, @@ -15,6 +15,9 @@ import { CustomTransformOperator, Labels, DataFrame, + Field, + getTimeField, + dateTime, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; @@ -96,15 +99,18 @@ export const LogsMetaRow = memo( }); dataFrameMap.forEach(async (dataFrame) => { const transforms: Array = getLogsExtractFields(dataFrame); - transforms.push({ - id: 'organize', - options: { - excludeByName: { - ['labels']: true, - ['labelTypes']: true, + transforms.push( + { + id: 'organize', + options: { + excludeByName: { + ['labels']: true, + ['labelTypes']: true, + }, }, }, - }); + addISODateTransformation + ); const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame])); downloadDataFrameAsCsv(transformedDataFrame[0], `Explore-logs-${dataFrame.refId}`); }); @@ -215,3 +221,23 @@ function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind) { console.error(`Meta type ${typeof value} ${value} not recognized.`); return <>; } + +const addISODateTransformation: CustomTransformOperator = () => (source: Observable) => { + return source.pipe( + map((data: DataFrame[]) => { + return data.map((frame: DataFrame) => { + const timeField = getTimeField(frame); + return { + ...frame, + fields: [ + { + name: 'Date', + values: timeField.timeField?.values.map((v) => dateTime(v).toISOString()), + } as Field, + ...frame.fields, + ], + }; + }); + }) + ); +}; diff --git a/public/app/features/inspector/utils/download.test.ts b/public/app/features/inspector/utils/download.test.ts index fe39b2d61e7..e03ee569d79 100644 --- a/public/app/features/inspector/utils/download.test.ts +++ b/public/app/features/inspector/utils/download.test.ts @@ -105,7 +105,7 @@ describe('inspector download', () => { rows: [{ timeEpochMs: 100, entry: 'testEntry' } as unknown as LogRowModel], }, 'test', - `testLabel: 1\nsecondTestLabel: 2\n\n\n100\ttestEntry\n`, + `testLabel: 1\nsecondTestLabel: 2\n\n\n100\t1970-01-01T00:00:00.100Z\ttestEntry\n`, ], ])('should, when logsModel is %s and title is %s, resolve in %s', async (logsModel, title, expected) => { downloadLogsModelAsTxt(logsModel, title); diff --git a/public/app/features/inspector/utils/download.ts b/public/app/features/inspector/utils/download.ts index 9f55548aea4..6a8a5b6a6d2 100644 --- a/public/app/features/inspector/utils/download.ts +++ b/public/app/features/inspector/utils/download.ts @@ -4,6 +4,7 @@ import { CSVConfig, DataFrame, DataTransformerID, + dateTime, dateTimeFormat, LogsModel, MutableDataFrame, @@ -30,7 +31,7 @@ export function downloadLogsModelAsTxt(logsModel: Pick { - const newRow = row.timeEpochMs + '\t' + row.entry + '\n'; + const newRow = row.timeEpochMs + '\t' + dateTime(row.timeEpochMs).toISOString() + '\t' + row.entry + '\n'; textToDownload = textToDownload + newRow; }); diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index 289dfd023f8..37597bb7d12 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -356,13 +356,22 @@ describe('logRowsToReadableJson', () => { it('should format a single row', () => { const result = logRowsToReadableJson([testRow]); - expect(result).toEqual([{ line: 'test entry', timestamp: '123456789', fields: { foo: 'bar' } }]); + expect(result).toEqual([ + { date: '1970-01-01T00:00:00.010Z', line: 'test entry', timestamp: '123456789', fields: { foo: 'bar' } }, + ]); }); it('should format a df field row', () => { const result = logRowsToReadableJson([testRow2]); - expect(result).toEqual([{ line: 'test entry', timestamp: '123456789', fields: { foo: 'bar', foo2: 'bar2' } }]); + expect(result).toEqual([ + { + date: '1970-01-01T00:00:00.010Z', + line: 'test entry', + timestamp: '123456789', + fields: { foo: 'bar', foo2: 'bar2' }, + }, + ]); }); }); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index b55e2f7a3a2..486c3682ece 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -19,6 +19,7 @@ import { getDefaultTimeRange, locationUtil, urlUtil, + dateTime, } from '@grafana/data'; import { getConfig } from 'app/core/config'; @@ -176,6 +177,7 @@ export function logRowsToReadableJson(logs: LogRowModel[]) { return { line: log.entry, timestamp: log.timeEpochNs, + date: dateTime(log.timeEpochMs).toISOString(), fields: { ...fields, ...log.labels,