Не каждому проекту нужно децентрализованное логирование. В моём случае, оказалось проще хранить логи в .json файлах формата Compact Log Event Format (CLEF). Мне нужно было простое и бесплатное решение для просмотра логов.
Готовое решение
На момент написания статьи в свободном доступе было только 1 подходящее приложение для чтения логов в формате CLEF - Compact Log Viewer, доступно в Microsoft Store.
Уже неплохо, приложение позволяло смотреть и фильтровать логи. Однако в этом решении было несколько минусов:
Каждому разработчике нужно устанавливать локально через Microsoft Store
За раз можно открыть только 1 файл
Нельзя поставить на тестовый сервер если это линукс
Часть UI занимает бесполезный график
После повторного открытия приложения загруженный ранее файл терялся, его нужно было грузить заново
Через некоторое время я устал от постоянного скачивания логов с сервера на локальную машину для их просмотра. Поэтому решил написать своё собственное приложение.
Требования к разрабатываемому приложению
К разрабатываемое приложению я установил следующие требования:
Отображать список логов
Поддерживать просмотр свойств отдельных логово
Поддерживать фильтрацию по свойствам
Поддерживать загрузку нескольких файлов
Сохранять логи между сессиями
Поддерживать запуск в среде Docker
Поддержка среды Docker позволила сделать приложение "доступным из коробки", включив его в дефолтный docker-compose файл на проектах. Также стала возможна загрузка на сервер.
Процесс разработки
Для разработки проекта был выбор между React и Angular. По личным предпочтениям был выбран React + StyledComponents.
Для UI компонентов выбрал бесплатное решение RsuiteJs.
Flow-приложения:
Перейти на страницу загрузки логов
Загрузить логи
Перенаправить на страницу просмотра логов
Реализация загрузки логов
Для форм остановился на связке Yup с Formik. Создал обёртки над компонентами форм, FileUploader
от RSuite не подходил, используя библиотеку ReactDropzone написал кастомную реализацию.
Хранение загруженных логов
Для упрощения проекта отказался от backend и NoSql базы данных. Логи записываю в хранилище браузера используя Localforage. Добавил AppState
и AppStorage
контексты.
//прослойка над хранилищем, контролирует согласованность данных за счет storageVersion
export interface AppStorage {
loadedLogFilesInfo?: FileInfo[];
csLogs?: CsLog[];
storageVersion: number;
}
//Основной контекст приложения
export interface AppState {
loadedLogFilesInfo?: FileInfo[];
csLogs?: CsLog[];
}
export function BaseLayout(): ReactElement {
//кастомная реализация светлой и темной темы
const theme = useTheme();
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<AppStorageContextProvider>
<AppStateContextProvider>
<RSuiteProvider theme={theme}>
<AppLayout />
</RSuiteProvider>
</AppStateContextProvider>
</AppStorageContextProvider>
</ErrorBoundary>
);
}
Для дальнейшего отображения логов в списке нужно установить что принимать за уникальный Id каждой записи в файле. Рассмотрев представленный ниже пример логов видно, что поля id нет. Можно попробовать использовать "@t"
, однако гарантии уникальности время лога не даёт.
{"@t":"2024-03-06T07:43:20.5672449Z","@mt":"Starting up","@l":"Information","EnvironmentName":"Staging"}
{"@t":"2024-03-06T07:43:23.3405140Z","@mt":"Executing ViewResult, running view {ViewName}.","@l":"Warning","@tr":"f3e14fda062a0ae0d641782c77ee0617","@sp":"0aee0c652548be62","ViewName":"Index","EventId":{"Id":1,"Name":"ViewResultExecuting"},"SourceContext":"Microsoft.AspNetCore.Mvc.ViewFeatures.ViewResultExecutor","ActionId":"9ea02b27-cd15-4432-82b1-7f9768a48e4b","ActionName":"DealerServiceSystem.Web.Controllers.CheckListCategoryController.Index (DealerServiceSystem.Web)","RequestId":"40000bc6-0002-f200-b63f-84710c7967bb","EnvironmentName":"Staging"}
{"@t":"2024-03-06T07:43:23.7431244Z","@mt":"Executed ViewResult - view {ViewName} executed in {ElapsedMilliseconds}ms.","@l":"Debug","@tr":"f3e14fda062a0ae0d641782c77ee0617","@sp":"0aee0c652548be62","ViewName":"Index","ElapsedMilliseconds":404.2372,"EventId":{"Id":4,"Name":"ViewResultExecuted"},"SourceContext":"Microsoft.AspNetCore.Mvc.ViewFeatures.ViewResultExecutor","ActionId":"9ea02b27-cd15-4432-82b1-7f9768a48e4b","RequestId":"40000bc6-0002-f200-b63f-84710c7967bb","EnvironmentName":"Staging"}
{"@t":"2024-03-06T07:43:23.7466340Z","@mt":"Executed action in {ElapsedMilliseconds}ms","@l":"Warning","@tr":"f3e14fda062a0ae0d641782c77ee0617","@sp":"0aee0c652548be62","ElapsedMilliseconds":536.3229,"EventId":{"Id":105,"Name":"ActionExecuted"},"SourceContext":"Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker","RequestId":"40000bc6-0002-f200-b63f-84710c7967bb","EnvironmentName":"Staging"}
Решил эту проблему добавив "искусственный" id
, сгенерированный через crypto-randomuuid, вышли следующие структуры:
export interface CsLog {
id: string;
data: CsLogData;
}
export interface CsLogData {
[key: string]: any;
'@t': Date;
'@mt': string;
'@l'?: 'Verbose' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Fatal';
}
Реализация просмотра и фильтрации логов
Реализовал пагинацию в отдельном компоненте, используется следующим образом:
const [paginatedLogs, setPaginatedLogs] = useState<CsLog[] | null>(null)
<StyledPagination values={filteredLogs} onChange={setPaginatedLogs} />
Для фильтра по логам использовал SearchJs, синтаксис простейшего фильтра { "data.<property_name>": "value" },
реализация:
searchjs.matchArray(appState.csLogs, JSON.parse(filter))
Валидацию фильтра сделал в лоб, пытаюсь парсить json, ошибка - неверный формат:
export const logsViewPageFormSchema: yup.Schema<LogsViewPageFormData> = yup.object({
filter: yup.string().test('json', 'Filter must have JSON format', (value) => {
if (value === undefined) {
return true;
}
try {
JSON.parse(value);
return true;
} catch (error) {
return false;
}
})
});
В итоге вышла следующая страница:
Публикация приложения
Чтобы сделать проект общедоступным выложил его код в общедоступный репозиторий на GitHub по этой ссылке.
Настроил workflow на билд Docker Image-а и паблиш в репозитории DockerHub ссылка. Приложение можно запустить используя следующий docker-compose файл, проект будет доступен на по ссылке http://localhost:8080.
version: "3.8"
services:
client:
image: "migiki/cs-logs-viewer:latest"
container_name: cs-logs-viewer
ports:
- "8080:80"
Также добавил MkDocs документацию к проекту, доступна по ссылке
Итоги
Разработал на React CS Logs viewer для просмотра структурированных логов, образ которого весит около 55мб, в то время как рассматриваемый выше аналог занимает 300мб. Приложение можно деплоить на тестовый сервер или поставлять в docker-compose файле вместе с кодом своего проекта, чтобы можно было смотреть логи на любой системе с установленной Docker-средой.
Буду рад вашим предложениям и замечаниям!
Комментарии (12)
SpiderEkb
07.04.2024 21:01+1А не думали сделать UDTF чтобы читать логи при помощи SQL запроса? У нас есть такое - очень удобно.
Продуктовые логи сразу пишем в БД, но есть еще системные (joblog) логи заданий - вот для них есть UDTF.
SStefS Автор
07.04.2024 21:01Да, сразу смотрел на эту реализацию, по примеру как у Seq. Для более простого старта пошёл на компромисс.
В планах на минорных версиях провести оптимизацию рендера клиента, на мажорной версии доработать хранение и фильтрацию логов.
SpiderEkb
07.04.2024 21:01+2Смотреть логи скулем универсально и удобно с точки зрения фильтрации
Поскокльку у нас вся работа крутится вокруг БД, то все логи ведем в виде таблиц бд. Там есть уровни логирования (как минимум три уровня - только ошибки, ошибки + бизнес ссобщения (то, что может быть интересно бизнесу), ошибки, бизнес и трейсы (это уже отладочная информацимя). Есть настройки - глубина хранения логов для разных уровней логирования, уровень логирования...
Естественно, для каждого продукта есть своя таблица логов (структура плюс-минус одинаковая, но могут быть какие-то специфические поля), свои настройки логирования.
Поскульку наша платформа работает "как большие машины" (т.е. все работает в рамках задания - job), для каждого задания системой ведется joblog (туда всякие системные вещи пишутся - системные исключений и т.п.). Это хранится в каком-то своем формате и смотреть можно или системными командами в виде текста, или есть системная UDTF JOBLOG_INFO, которая позволяет делать это скулем
SELECT ORDINAL_POSITION, MESSAGE_TYPE, MESSAGE_TIMESTAMP, FROM_LIBRARY, FROM_PROGRAM, MESSAGE_TEXT, MESSAGE_SECOND_LEVEL_TEXT FROM TABLE(QSYS2.JOBLOG_INFO(JOB_NAME => '316557/QUSER/QZDASOINIT'));
Ну и получаем примерно такое
1 INFORMATIONAL 2024-04-06 09:32:40.412299 QSYS QWTPIIPP Задание 400332/QUSER/QZDASOINIT запущено 06.04.24 в 09:32:40 в подсистеме QUSRWRK в библиотеке QSYS. Задание появилось в системе 06.04.24 в 09:32:40. 2 INFORMATIONAL 2024-04-08 11:47:52.655109 QSYS QWTCHGJB ACGDTA для 400332/QUSER/QZDASOINIT не занесен в журнал, код причины 1. &N Причина . . . . : Данные Задание учета ресурсов задания 400332/QUSER/QZDASOINIT не были занесены в журнал учета ресурсов системы с именем QSYS/QACGJRN. &P -- Коды причин и их значения приведены ниже: &P -- 1 - Системное значение уровня учета ресурсов (QACGLVL) указывает, что выполнять учет ресурсов этого уровня при входе задания в систему не нужно. &P -- 2 - Журнал QSYS/QACGJRN не в состоянии принимать данные. Данные учета ресурсов были отправлены в протокол хронологии (QHST) в тексте сообщения CPF1303. Действия по исправлению см. в сообщении CPF1302 протокола хронологии (QHST). &P -- 3 - Журнал учета ресурсов QSYS/QACGJRN был выделен другому заданию. Данные учета ресурсов были отправлены в протокол хронологии (QHST) в виде текста сообщения CPF1303. 3 INFORMATIONAL 2024-04-08 13:39:45.479460 QSYS QWTCHGJB ACGDTA для 400332/QUSER/QZDASOINIT не занесен в журнал, код причины 1. &N Причина . . . . : Данные Задание учета ресурсов задания 400332/QUSER/QZDASOINIT не были занесены в журнал учета ресурсов системы с именем QSYS/QACGJRN. &P -- Коды причин и их значения приведены ниже: &P -- 1 - Системное значение уровня учета ресурсов (QACGLVL) указывает, что выполнять учет ресурсов этого уровня при входе задания в систему не нужно. &P -- 2 - Журнал QSYS/QACGJRN не в состоянии принимать данные. Данные учета ресурсов были отправлены в протокол хронологии (QHST) в тексте сообщения CPF1303. Действия по исправлению см. в сообщении CPF1302 протокола хронологии (QHST). &P -- 3 - Журнал учета ресурсов QSYS/QACGJRN был выделен другому заданию. Данные учета ресурсов были отправлены в протокол хронологии (QHST) в виде текста сообщения CPF1303. 4 INFORMATIONAL 2024-04-08 13:39:45.479800 QSYS QZBSSECR К серверу подключен пользователь *** с клиента **.**.**.**. &N Причина . . . . : В данный момент с этим заданием сервера связан пользовательский профайл *** с клиента **.**.**.**. Имя клиента - это имя удаленной системы TCP/IP, IP-адрес этой системы в десятичной записи с точками, адрес IPv6 либо имя локального хоста. 5 COMPLETION 2024-04-08 13:39:46.070314 QSYS QWTCHGJB Задание 400332/QUSER/QZDASOINIT изменено пользователем Y2KU. 6 INFORMATIONAL 2024-04-08 13:39:46.102125 QSYS QZDASRV Следующие специальные регистры были заданы: CLIENT_ACCTNG: Windows 10;SSL=false;admin_user=true, CLIENT_APPLNAME: IBM i Access Client Solutions - Run SQL Scripts, CLIENT_PROGRAMID: file:/C:/Users/*****/IBM/ClientSolutions/acsbundle.jar | Версия: 1.1.8.8 | ИД компоновки: 1380 | 2021-09-13 13:47:59, CLIENT_USERID: *****, CLIENT_WRKSTNNAME: ****** &N Причина . . . . : Это сообщение отправлено когда любой из клиентских специальных регистров был обновлен.
В общем и целом это все достаточоно удобно.
Pijng
07.04.2024 21:01Спасибо за статью! Интересно смотреть как коллеги по цеху задачу с продуктовыми логами решают.
А в рамках одного лог файла свойства у каждой записи по шейпу совпадают или могут быть разными? Если совпадают – не думали прям на клиенте генерить «форму» для фильтра свойств? Или нужна более сложная фильтрация по логам?
Недавно в целом чем-то схожее решение разработали у себя. Буду наблюдать за гитхабом у вас для референсов :)
SStefS Автор
07.04.2024 21:01Спасибо за отзыв!
Форма объявлена в интерфейсе:
export interface CsLogData { //в логе могут быть любые свойства [key: string]: any; //Минимально нужные данные для одного лога '@t': Date; '@mt': string; '@l'?: 'Verbose' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Fatal'; }
Форма может быть любой, минимально требуемые параметры: Время, Сообщение, Уровень. Более гибкий вариант.
Генерировать форму отличная идея! В OpenSearch есть подобная система, автоматически сканируются параметры логов, можно по готовому списку фильтровать.
denaspireone
07.04.2024 21:01+1Я просто оставлю это здесь https://docs.victoriametrics.com/victorialogs/querying/#web-ui
14,9 Мб, а еще это база данных для логовPS: vim = 4.6Mb
SStefS Автор
07.04.2024 21:01Крутой проект! Как-то не попадался в поисковике. Может сможете подсказать, логи нужно грузить через агрегатор на подобии logstash?
denaspireone
07.04.2024 21:01SStefS Автор
07.04.2024 21:01Из документации выглядит скорее как децентрализованное логирование, а не просмотр json файлов напрямую, возможно пропустил что-то в документации.
radioxoma
Зачётная иконка. Напомнило почему-то про Log Lady из Твин Пикса.
SStefS Автор
Иконку сгенерировал с ИИ. Правда был водяной знак, перерисовал вручную.