Перейти к содержимому
OmniRoute source

Log export (Русский)

call_logs (SQLite)
→ callLogExportSource.getCallLogsForExport(cursor, batchSize)
→ LogExportRecord[] (набор полей вкладки Logs)
→ клиент назначения.send(пакет)
→ advanceLogExportCursor(id, lastRowId, count)
  • Расписание — одна cron-задача JobRegistry, log_export, по умолчанию запускаемая по расписанию 0 * * * * (ежечасно, UTC). Регистрируется в src/lib/initCloudSync.ts; расписание можно переопределить с помощью OMNIROUTE_LOG_EXPORT_CRON. При каждом запуске последовательно обрабатываются все включённые назначения.
  • Курсор — неявный call_logs.rowid SQLite, сохраняемый отдельно для каждого назначения в log_export_destinations.cursor_row_id. timestamp намеренно не используется в качестве курсора: вызывающие стороны могут передавать собственное значение, поэтому медленный запрос может быть записан после более быстрого, который начался позже, и курсор по временной метке пропустил бы его.
  • Пакетная обработка — batch_size строк на запрос (по умолчанию 500), max_rows_per_run строк за запуск (по умолчанию 10000), чтобы большой объём накопившихся данных обрабатывался за несколько запусков, а не блокировал один из них.
  • Доставка — курсор продвигается только после успешного завершения send(). При сбое отправки пакета курсор остаётся на прежнем месте, поэтому те же строки будут отправлены повторно при следующем запуске. Гарантируется доставка не менее одного раза с устранением дубликатов на стороне назначения, а не настоящая доставка строго один раз: в BigQuery каждая строка идентифицируется по идентификатору журнала вызовов, который учитывается на основе максимальных усилий в пределах собственного окна дедупликации.
  • Защита от перекрытия запусков — cron-запуск и POST .../run могут сработать одновременно. Назначение, которое уже обрабатывается, пропускается вместо повторной обработки (skipped: true в результате запуска), поэтому параллельный запуск не может повторно отправить пакет или переместить курсор назад.
  • Восстановление после очистки — если cursor_row_id оказывается больше MAX(rowid) (вся таблица была очищена, а нумерация строк началась заново), средство запуска сбрасывает курсор на 0, а не перестаёт навсегда видеть новые записи.

По умолчанию экспортируются только сводные поля, отображаемые в списке Logs. Включение параметра Экспорт промптов и ответов (includeBodies) дополнительно экспортирует данные, которые панель подробностей Logs показывает для каждого вызова:

Поле Содержимое
request_body / response_body Полезные нагрузки вызова в том виде, как их отображает панель
pipeline_route_decision Какую цель и модель выбрал маршрутизатор
pipeline_client_request Исходный запрос в точности в том виде, как его отправил клиент
pipeline_openai_request Запрос после преобразования во внутренний формат OpenAI
pipeline_provider_request Запрос, фактически отправленный вышестоящему сервису, в диалекте поставщика
pipeline_provider_response Необработанный ответ вышестоящего сервиса
pipeline_client_response Ответ, возвращённый вызывающей стороне
pipeline_error Подробности ошибки конвейера для неудачного вызова
bodies_truncated Истина, если любое из указанных выше полей достигло maxBodyBytes

Это содержимое промптов, поэтому оно по умолчанию отключено и намеренно настраивается отдельно для каждого назначения. Экспортируется именно то, что отображает панель, поскольку оба механизма читают данные через getCallLogById: полезные нагрузки уже очищены от персональных данных и отредактированы для удаления секретов при записи, а для вызова, выполненного с API-ключом noLog, полезная нагрузка вообще не сохраняется, поэтому экспортировать нечего.

Полезные нагрузки для каждой строки считываются из артефакта в файловой системе, поэтому гидратация выполняется только для назначений, которые её запросили. Если артефакт строки отсутствует или повреждён, экспортируется её сводка с полезными нагрузками, равными null, вместо сбоя всего пакета и блокировки курсора.

maxBodyBytes (по умолчанию 262144) ограничивает размер каждого поля. Более длинные полезные нагрузки обрезаются, а не отбрасываются — даже обрезанный промпт позволяет понять, «что было запрошено», — а строка помечается флагом bodies_truncated. Потоковые дельты по отдельным фрагментам не экспортируются; собранный ответ уже содержится в pipeline_provider_response и pipeline_client_response.


Компонент Расположение
Контракт назначения src/lib/logExport/types.ts
Реестр src/lib/logExport/registry.ts
Обработка секретов src/lib/logExport/secrets.ts
Исполнитель (цикл курсора) src/lib/logExport/runner.ts
Представление API src/lib/logExport/presenter.ts
Назначение BigQuery src/lib/logExport/destinations/bigquery.ts
Аутентификация Google SA src/lib/logExport/googleServiceAccount.ts
Источник журнала вызовов src/lib/usage/callLogExportSource.ts
Хранилище src/lib/db/logExportDestinations.ts
Задание cron src/lib/jobs/logExportJob.ts
Слой REST src/app/api/log-export/
Страница панели управления src/app/(dashboard)/dashboard/log-export/

Схема: src/lib/db/migrations/170_log_export_destinations.sql.


Все маршруты требуют аутентификации управления (requireManagementAuth). Секреты никогда не возвращаются: сохранённый секрет возвращается как литерал __stored__, а отправка этого значения обратно при обновлении сохраняет существующие учётные данные.

Для создания или обновления назначения, тип которого объявляет секрет, требуется STORAGE_ENCRYPTION_KEY. Без него encrypt() незаметно пропускает данные без изменений, поэтому операция записи отклоняется с кодом 400, чтобы учётные данные не попали в SQLite в виде открытого текста (та же проверка применяется вебхуком Telegram).

Метод Путь Назначение
GET /api/log-export/types Типы назначений и списки их полей конфигурации
GET /api/log-export/destinations Список назначений (секреты скрыты)
POST /api/log-export/destinations Создать назначение
GET /api/log-export/destinations/{id} Получить одно назначение
PUT /api/log-export/destinations/{id} Обновить имя / состояние / конфигурацию / пакетирование
DELETE /api/log-export/destinations/{id} Удалить
POST /api/log-export/destinations/{id}/test Проверить учётные данные без записи
POST /api/log-export/destinations/{id}/run Выполнить выгрузку сейчас тем же способом, что и запланированный запуск
GET /api/log-export/status Состояние cron, последние запуски, очередь для каждой цели

Именно GET /api/log-export/types делает пользовательский интерфейс универсальным: форма панели управления создаётся на основе возвращённых описателей полей, поэтому добавление нового назначения не требует изменения пользовательского интерфейса.


Ключи конфигурации (type: "bigquery"):

Ключ Примечания
projectId Проект GCP, содержащий набор данных
datasetId [A-Za-z0-9_]+
tableId [A-Za-z0-9_]+
location Используется только при необходимости создать набор данных (по умолчанию EU)
serviceAccountJson Ключ сервисного аккаунта. Секрет: зашифрован при хранении и никогда не возвращается
autoCreate Создать набор данных и таблицу при первом экспорте (по умолчанию true)

Сервисному аккаунту требуется разрешение bigquery.tables.updateData для целевой таблицы, а также bigquery.datasets.create / bigquery.tables.create, если включён autoCreate.

Настроенный пакет является единицей курсора, а не HTTP: send() разбивает его на вызовы insertAll не более чем по 500 строк, поэтому большой batch_size не может привести к превышению ограничения BigQuery в 10 МБ на запрос. При временных статусах (408/429/500/502/503/504) выполняется до трёх повторных попыток с экспоненциальной задержкой и повторным использованием тех же insertIds; ошибки аутентификации и схемы приводят к исключению при первой попытке, а не расходуют время запуска.

Таблица, созданная несколько мгновений назад, ещё не видна потоковой конечной точке, которая отвечает кодом 404 в течение нескольких секунд. Такой ответ 404 приводит к повторным попыткам, но только если таблица была создана во время этого запуска — если таблица действительно отсутствует, операция сразу завершается ошибкой. Обратите внимание: повторное создание таблицы с именем недавно удалённой таблицы приводит к тому, что BigQuery в течение нескольких минут отклоняет потоковые вставки; это особенность удаления с последующим повторным созданием, поэтому вместо удаления и повторного добавления таблицы лучше использовать новое имя.

Частичная ошибка возвращается как HTTP 200 с непустым массивом insertErrors[]. Это рассматривается как ошибка и приводит к исключению, благодаря чему курсор не продвигается дальше строк, которые BigQuery не принял; такое поведение закреплено тестом tests/unit/log-export-bigquery.test.ts.

Для передачи используется обычный REST: самоподписанное утверждение RS256 обменивается на токен доступа по адресу https://oauth2.googleapis.com/token, после чего строки отправляются в tabledata.insertAll. Google SDK не подключается. Токены доступа кешируются внутри процесса для каждой пары (сервисный аккаунт, область доступа).

Созданная таблица содержит по одному столбцу для каждого поля вкладки Logs, а также exported_at, и организована с учётом того, как фактически выполняются запросы к журналам вызовов:

  • Разбиение по дням на основе timestamp, поэтому запрос с ограничением по дате сканирует только соответствующие дни.
  • Кластеризация по api_key_name, provider, model, status (в указанном порядке), поэтому фильтрация по тому, кто выполнил вызов, куда он был направлен или завершился ли он ошибкой, исключает блоки внутри каждого раздела. BigQuery допускает не более четырёх столбцов кластеризации, и их порядок важен: фильтр только по api_key_name исключает блоки, а фильтр только по status — нет.
  • Необязательный срок хранения разделов через partitionExpirationDays (0 сохраняет всё), применяемый при создании таблицы.

Обе настройки применяются при создании. Существующая таблица сохраняет уже имеющуюся структуру, поэтому, если вы хотите применить их, укажите в качестве назначения новый идентификатор таблицы.

tests/unit/log-export-bigquery.test.ts проверяет, что маппер и схема таблицы остаются синхронизированными, поэтому новый столбец журнала вызовов не может быть незаметно отброшен при экспорте.

Пакеты разбиваются на части с учётом как количества строк, так и объёма сериализованных данных в байтах. Одного количества строк недостаточно, когда экспортируются полезные нагрузки: 500 строк с промптами могут занимать десятки мегабайт, а insertAll отклоняет запросы размером более 10 МБ. Часть закрывается при достижении 500 строк или 9 МБ — в зависимости от того, что произойдёт раньше.


  1. Создайте src/lib/logExport/destinations/<name>.ts, экспортирующий LogExportDestinationType: Zod-схему configSchema, массив дескрипторов fields для пользовательского интерфейса, secretFields и createClient(config), возвращающий test() / prepare() / send(records).
  2. Добавьте его в массив DESTINATIONS в src/lib/logExport/registry.ts.
  3. Напишите тесты в tests/unit/.

Это всё необходимое изменение: хранилище, задание cron, REST-слой, шифрование секретов и форма панели управления используют реестр.

Два правила для нового места назначения:

  • send() обязан выбрасывать исключение при частичном сбое. Успешное завершение означает: «в месте назначения есть эти строки», после чего курсор навсегда перемещается за них.
  • Место назначения, принимающее предоставленный пользователем URL, перед выполнением запроса должно проверить его с помощью parseAndValidateWebhookUrl (src/shared/network/outboundUrlGuardPolicy.ts), как это делают вебхуки. Для BigQuery это не требуется: его хосты заданы константами.

  • Панель управления: Интеграции → Экспорт журналов. Добавьте место назначения, запустите Тест, чтобы проверить учётные данные без записи строк, а затем включите его.
  • Очередь необработанных данных: на каждой карточке места назначения отображаются количество ожидающих строк и курсор; GET /api/log-export/status возвращает те же показатели, а также данные о последних 20 запусках задания.
  • Сбой одного места назначения не приводит к сбою остальных — сводка запуска записывает статус каждого места назначения в last_status / last_error, а журнал запусков задания сохраняет сводные данные.
  • При удалении места назначения удаляется его курсор. При повторном добавлении обработка начинается с самого старого сохранённого журнала вызовов, из-за чего строки, которые уже могут находиться в месте назначения, отправляются повторно. В BigQuery значение insertId каждой строки устраняет такие дубликаты только в пределах собственного окна дедупликации BigQuery, поэтому предпочтительнее отключить место назначения, а не удалять его.

Исходный код OmniRoute (a58000c7685f)

HagiCode

HagiCode — агентная среда разработки со структурированными процессами, параллельным выполнением несколькими агентами и интерфейсами Hero Dungeon.

Превращайте идеи в полезное ПО с более умным, быстрым и увлекательным агентным рабочим процессом.

Главный экран HagiCode в светлой теме
  • SmartСтруктурированные процессы превращают намерение в исполнимый путь от идеи до готового изменения.
  • EfficientМультиагентные процессы параллельно продвигают исследование, реализацию и проверку.
  • FunHero Dungeon делает длительную совместную разработку наглядной и увлекательной.
Перейти на HagiCode