ГлавнаяАкадемияСценарии умного дома: режимы, состояния, приоритеты → Структура записи в журнале: 'контракт' для логов

Структура записи в журнале: 'контракт' для логов

Урок 1 · Сценарии умного дома: режимы, состояния, приоритеты · 30 мин · theory

Введение: лог как 'контракт' данных

В любом серьезном проекте автоматизации, будь то умный дом или небольшой промышленный объект, журнал событий (лог) перестает быть просто текстовым файлом для отладки. Он превращается в критически важный поток данных, который несет информацию о состоянии системы, действиях пользователей и сбоях оборудования. Чтобы этот поток данных был действительно полезен, он должен подчиняться строгим правилам — «контракту».

Аналогия с API-контрактом здесь наиболее уместна. Когда вы интегрируете два сервиса через API, вы ожидаете, что структура запросов и ответов будет стабильной и предсказуемой. Если один сервис вдруг начнет отправлять данные в другом формате, вся интеграция сломается. То же самое происходит с логами. Если каждая часть вашей системы (каждый поток Node-RED, каждый сценарий) записывает информацию в журнал в своем собственном, произвольном формате, вы получаете хаос.

> ℹ️ Информация: Рассматривайте структуру лога как внутренний стандарт вашей компании. Единожды определив 'контракт', вы значительно упрощаете жизнь и себе, и коллегам в будущем.

Преимущества стандартизированного, машиночитаемого формата (например, JSON) для логов огромны:

Последствия отсутствия контракта всегда плачевны. Попытка проанализировать журнал, где одно событие записано как `"Light turned ON"`, другое — `{ "device": "light-01", "state": 1 }`, а третье — `"relay 5 activated at kitchen"`, превращается в невыполнимую задачу. Отладка затягивается на часы и дни, построение отчетов становится невозможным, а система остается «черным ящиком», поведение которого сложно предсказать и контролировать. В этом уроке мы разработаем четкий и надежный «контракт» для наших логов, который станет основой для отладки и аудита всех сценариев, создаваемых в этом курсе.

---

Анатомия записи: обязательные поля

натомия записи: обязательные поля

Чтобы лог был по-настоящему полезным, каждая запись в нем должна содержать минимальный набор обязательных полей. Этот набор формирует основу нашего «контракта». Мы будем использовать формат JSON, так как он является стандартом де-факто для структурированных данных.

> 💡 Подсказка: Всегда используйте временные метки в формате ISO 8601 с указанием таймзоны (например, '2023-10-27T10:00:00.123+03:00'). Это устраняет любую двусмысленность при анализе логов с распределенных систем.

Ниже представлена таблица с обязательными полями для каждой записи в журнале.

| Поле | Тип данных | Описание | Пример |

|---------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|

| `timestamp` | Строка (ISO 8601) | Временная метка. Точное время события. Формат ISO 8601 включает дату, время, миллисекунды и часовой пояс. | `"2023-11-21T14:35:01.123+03:00"` |

| `source` | Объект | Источник события. Структурированный объект, указывающий часть системы: `controllerId`, `flowId`, `nodeId`. | `{ "controllerId": "HI-01", "flowId": "..." }` |

| `stateId` | Строка | ID состояния. Ссылка на конкретное состояние из Графа состояний. Позволяет проводить аудит переходов и логики системы. | `"climate_eco"`, `"security_armed"` |

| `level` | Строка | Уровень важности. Классифицирует событие по степени критичности: `"info"`, `"warn"`, `"error"`, `"debug"`. | `"info"` |

| `eventId` | Число или Строка | Идентификатор события. Уникальный код типа события для автоматической обработки. | `1001` или `"STATE_CHANGED"` |

| `message` | Строка | Человекочитаемое сообщение. Краткое описание произошедшего для быстрого анализа инженером. | `"Climate mode switched to ECO due to no occupancy."` |

| `payload` | Объект | Контекстная нагрузка (опционально). Данные, связанные с событием: значения датчиков, объекты `msg` и т.д. | `{ "temp": 21.5, "presence": false }` |

📋 Ключевые понятия:

Использование Unix timestamp также возможно, но ISO 8601 лишен проблем с часовыми поясами и более удобен для человеческого восприятия без дополнительных преобразований.

Далее мы подробно разберем, как формировать каждое из этих полей на практике в среде Node-RED.

Практика: Формирование поля 'Source'

Поле `source` — одно из самых важных в структуре лога. Оно отвечает на вопрос «Где именно в системе это произошло?». Недостаточно просто написать "Node-RED", так как в крупном проекте могут быть сотни узлов на десятках потоков. Нам нужна максимальная гранулярность.

Стандартная структура объекта `source` для нашей платформы выглядит так:

{

"controllerId": "HI-Main-Cottage-01",

"component": "node-red",

"flowId": "a1b2c3d4.e5f6g7",

"nodeId": "h8i9j0k1.l2m3n4"

}

Разберем, как получить каждый из этих идентификаторов в узле `Function` в Node-RED:

  • `controllerId`: Это уникальное имя вашего контроллера. Самый надежный способ — задать его как переменную окружения при запуске Node-RED или определить в файле `settings.js`.
  • * В `settings.js`:

            functionGlobalContext: {

    controllerId: "HI-Main-Cottage-01"

    },

    Внутри `Function` узла его можно будет получить так: `global.get('controllerId')`.

  • `component`: В большинстве случаев это будет `"node-red"`. Но если у вас есть другие сервисы, пишущие в ту же систему логирования (например, кастомный скрипт на Python), это поле поможет их различить.
  • `flowId`: Идентификатор вкладки (потока), на которой находится узел. Node-RED предоставляет его через свойство `node.z`.
  • * Получение в узле `Function`: `const flowId = node.z;`

  • `nodeId`: Уникальный идентификатор самого узла, сгенерировавшего событие. Это позволяет точно определить источник проблемы.
  • * Получение в узле `Function`: `const nodeId = node.id;`

    * Можно также получить имя узла, если оно задано: `const nodeName = node.name;`

    Пример создания 'source' в Function-ноде

    Представим, что у нас есть узел `Function`, который должен подготовить объект `source` для лога.

    // Получаем статические данные (лучше всего из global context или env)
    

    const controllerId = global.get('controllerId') || 'unknown-controller';

    const component = 'node-red';

    // Получаем динамические данные от самого узла

    const flowId = node.z;

    const nodeId = node.id;

    const nodeName = node.name || ''; // Получаем имя узла, если оно есть

    // Формируем полный объект source

    const source = {

    controllerId: controllerId,

    component: component,

    flowId: flowId,

    nodeId: nodeId,

    nodeName: nodeName // Дополнительное поле для удобства отладки

    };

    // Теперь этот объект можно добавить в нашу запись лога

    // msg.logEntry = { source: source, ... };

    node.status({fill: "blue", shape: "dot", text: "Source created: " + nodeId});

    return msg;

    Такой подход обеспечивает невероятную точность. Когда вы видите в логах ошибку, вы можете скопировать `flowId` и `nodeId` и мгновенно найти проблемный узел через поиск в редакторе Node-RED. Это сокращает время диагностики с часов до секунд. В сложных проектах с десятками вкладок и тысячами узлов такой уровень детализации является не роскошью, а абсолютной необходимостью для поддержания системы в рабочем состоянии.

    ---

    Классификация и приоритезация: EventID и Level

    Если поля `timestamp` и `source` отвечают на вопросы «когда» и «где», то `level` и `eventId` отвечают на вопросы «насколько это важно» и «что именно произошло». Правильная классификация событий — ключ к построению эффективных систем мониторинга и оповещения.

    > ⚠️ Внимание: Не злоупотребляйте уровнем 'error'. Он должен сигнализировать о реальной неисправности, требующей немедленного вмешательства. Слишком большое количество 'ошибок' по некритичным поводам приводит к 'баннерной слепоте', и инженеры начинают игнорировать оповещения.

    Уровни важности (Level)

    Стандартная иерархия уровней выглядит следующим образом (от наименее к наиболее важному):

    Идентификатор события (EventID)

    EventID — это уникальный код, который вы присваиваете каждому конкретному типу события. Это позволяет машинам (системам мониторинга) однозначно идентифицировать событие, даже если человекочитаемое `message` изменится. Рекомендуется создать и поддерживать централизованный словарь или реестр EventID для вашего проекта. Пример словаря EventID:

    | Диапазон ID | Категория | Пример |

    |-------------|--------------------|------------------------------------------------------------------------------------|

    | 1000-1999 | Оборудование (I/O) | `1001`: Modbus Read Error, `1002`: Modbus Write Error, `1101`: DALI Bus Power Failure |

    | 2000-2999 | Сценарии и Логика | `2001`: Scene 'Morning' Triggered, `2101`: Climate Control Switched to 'Heating' |

    | 3000-3999 | Безопасность | `3001`: Login Success, `3002`: Login Failed, `3101`: Intrusion Detected (Zone 1) |

    | 4000-4999 | Система и Сеть | `4001`: MQTT Broker Connected, `4002`: MQTT Broker Disconnected |

    | 9000-9999 | Пользовательские | `9001`: User changed temperature setpoint |

    Связь EventID с бизнес-логикой очень важна. Событие `2001` («Сценарий 'Утро' запущен») интересно в основном для аудита и анализа поведения жильцов. А вот событие `3101` («Обнаружено вторжение») или `1101` («Сбой питания шины DALI») — это критически важная информация, которая должна немедленно вызывать срабатывание многоканальных оповещений, как мы рассматривали в предыдущих модулях. Наличие `EventID` позволяет настроить такую логику очень просто: `if (msg.payload.eventId === 3101) { triggerAlarm(); }`. Именно такой подход мы будем использовать в последующих модулях при создании систем оповещения и реагирования на инциденты.

    ---

    Пример: Сборка объекта лога в Node-RED

    Теперь объединим все наши знания и создадим полноценный, структурированный объект лога на реальном примере.

    Сценарий: У нас есть поток, который опрашивает датчик CO2 по шине Modbus RTU. Иногда из-за помех на линии узел `modbus-read` выдает ошибку таймаута. Мы должны перехватить эту ошибку с помощью узла `Catch`, сформировать канонический объект лога и отправить его в центральную систему журналирования через MQTT.

    🔗 Связанный материал: Этот пример напрямую использует паттерны, описанные в скилле "Паттерны Node-RED", в частности "Обработка ошибок" и "Контракт сообщения".

    1. Входящее сообщение от узла `Catch`

    Когда узел `modbus-read` сгенерирует ошибку, узел `Catch` (настроенный на перехват ошибок со всех узлов) сформирует сообщение `msg` примерно такой структуры:

    {
    

    "_msgid": "abcdef12.345678",

    "error": {

    "message": "Port Not Closed: Error: Polling error: \"Timed out\"",

    "source": {

    "id": "h8i9j0k1.l2m3n4",

    "type": "modbus-read",

    "name": "Read CO2 Sensor",

    "count": 1

    }

    },

    "payload": {

    "value": {

    "fc": 3,

    "unitid": 15,

    "address": 102,

    "quantity": 2

    }

    },

    "topic": ""

    }

    2. Код в узле `Function` для формирования лога

    Мы размещаем узел `Function` после узла `Catch`. Его задача — преобразовать входящее `msg` в наш стандартный формат лога.

    // --- Словари и статические данные ---
    

    const CONTROLLER_ID = global.get('controllerId') || 'HI-UNKNOWN';

    const EVENT_IDS = {

    MODBUS_READ_ERROR: 1001,

    UNKNOWN_ERROR: 9999

    };

    // --- Получаем информацию из входящего msg ---

    const originalError = msg.error;

    const originalPayload = msg.payload;

    // --- 1. Формируем поле 'source' ---

    const source = {

    controllerId: CONTROLLER_ID,

    component: "node-red",

    flowId: originalError.source.z || node.z, // Берем из ошибки, если есть

    nodeId: originalError.source.id,

    nodeType: originalError.source.type,

    nodeName: originalError.source.name || "Unnamed Node"

    };

    // --- 2. Определяем 'level' и 'eventId' ---

    const level = "error"; // Ошибки от Catch всегда 'error' или 'fatal'

    const eventId = EVENT_IDS.MODBUS_READ_ERROR;

    // --- 3. Формируем 'message' ---

    const message = `Modbus read error from '${source.nodeName}' (ID: ${source.nodeId}): ${originalError.message}`;

    // --- 4. Собираем финальный объект лога ---

    const logEntry = {

    timestamp: new Date().toISOString(),

    source: source,

    level: level,

    eventId: eventId,

    message: message,

    // В 'payload' кладем все, что поможет в отладке

    payload: {

    originalMsg: {

    error: originalError,

    payload: originalPayload,

    _msgid: msg._msgid

    }

    }

    };

    // --- 5. Заменяем msg.payload для отправки в MQTT ---

    msg.payload = logEntry;

    msg.topic = `logs/${CONTROLLER_ID}/${level}`; // Тема MQTT для маршрутизации

    return msg;

    3. Итоговый JSON-объект, отправляемый в MQTT

    После обработки узлом `Function` сообщение, готовое к отправке в журнал, будет выглядеть так:

    {
    

    "timestamp": "2023-11-21T15:10:45.556+03:00",

    "source": {

    "controllerId": "HI-Office-01",

    "component": "node-red",

    "flowId": "a1b2c3d4.e5f6g7",

    "nodeId": "h8i9j0k1.l2m3n4",

    "nodeType": "modbus-read",

    "nodeName": "Read CO2 Sensor"

    },

    "level": "error",

    "eventId": 1001,

    "message": "Modbus read error from 'Read CO2 Sensor' (ID: h8i9j0k1.l2m3n4): Port Not Closed: Error: Polling error: \"Timed out\"",

    "payload": {

    "originalMsg": {

    "error": {

    "message": "Port Not Closed: Error: Polling error: \"Timed out\"",

    "source": {

    "id": "h8i9j0k1.l2m3n4",

    "type": "modbus-read",

    "name": "Read CO2 Sensor"

    }

    },

    "payload": {

    "value": { "fc": 3, "unitid": 15, "address": 102, "quantity": 2 }

    },

    "_msgid": "abcdef12.345678"

    }

    }

    }

    Этот объект полностью соответствует нашему «контракту» и является эталоном, на который следует ориентироваться в последующих практических работах. Он атомарен, структурирован и содержит исчерпывающую информацию для диагностики и автоматической обработки.

    ---

    Итоги: закрепление 'контракта'

    В этом уроке мы определили один из самых важных стандартов для построения надежной системы автоматизации — «контракт» для журнала событий. Давайте закрепим ключевые принципы.

    Каждая запись в логе должна быть самодостаточным JSON-объектом, содержащим обязательные поля:

    Главный принцип, который вы должны вынести из этого урока — консистентность и автоматизация. Не полагайтесь на то, что каждый раз вы будете вручную собирать этот объект. Создайте переиспользуемый компонент — субпоток (subflow) или глобальную функцию — который принимает базовую информацию о событии и автоматически генерирует полную, валидную запись в логе. Это гарантирует, что 100% событий в вашей системе будут задокументированы по единому стандарту. Применение такого универсального логгера станет сквозной задачей во всех последующих модулях.

    При проектировании системы журналирования всегда думайте о потребителе логов:

    Принятый и неукоснительно соблюдаемый «контракт» для логов превращает вашу систему из «черного ящика» в прозрачный, наблюдаемый и управляемый механизм. Это фундамент, на котором строятся надежность, отказоустойчивость и удобство эксплуатации любого профессионального решения для умного дома.

    Что дальше?

    Теперь, когда мы научились генерировать качественные, структурированные логи, в следующем уроке мы рассмотрим, куда и как их сохранять. Мы изучим различные хранилища, от простой записи в файл до использования базы данных MySQL на контроллере и отправки в облачные сервисы, а также настроим их в Node-RED.