Я развернул self-hosted Supabase в Docker и собрал для него отдельный observability-контур. Метрики собирает vmagent и хранит VictoriaMetrics, логи собирает Vector и отправляет в VictoriaLogs, а Grafana стала единым интерфейсом. Сначала я прокидывал логи через HTTP-адаптер на Edge Function, но затем перенёс ingestion в Vector: он уже входит в Supabase-стек, читает docker-логи, умеет нормализовать их в VRL и отправляет данные батчами на отдельный сервер мониторинга.

Задача

Я использую Supabase в self-hosted режиме: сервисы работают в Docker Compose, включая Postgres, Kong, GoTrue, PostgREST, Storage, Realtime, Edge Functions, Logflare и Vector. Перед Kong стоит Nginx, который завершает TLS и проксирует публичное API.

Мне нужно было закрыть две задачи:

  • Собирать метрики хоста, PostgreSQL и доступности компонентов Supabase.

  • Видеть логи всех контейнеров, в первую очередь Edge Functions, и быстро искать ошибки в Grafana.

В self-hosted варианте Supabase нет готового managed Metrics API, поэтому метрики нужно снимать напрямую с инфраструктурных компонентов и exporter’ов.

Итоговая архитектура

Инфраструктура разделена на две машины. На сервере приложения работает Supabase и штатный Vector; на сервере мониторинга - VictoriaMetrics, VictoriaLogs, Grafana и Nginx.

┌────────────────────────────────────────────────────┐
│ Сервер приложения                                  │
│                                                    │
│ Docker Compose:                                    │
│ Postgres / Kong / Auth / REST / Storage / Realtime │
│ Edge Functions / Logflare / Vector                 │
│                                                    │
│ docker json-file logs                              │
│          │                                         │
│          ▼                                         │
│ Vector: docker_logs + VRL                          │
└─────────────────────────┬──────────────────────────┘
                          │ HTTPS + JSONLine batches
                          │
                          ▼
┌────────────────────────────────────────────────────┐
│ Сервер мониторинга                                 │
│                                                    │
│ Nginx: allow <APP_SERVER_IP>; deny all;            │
│          │ localhost HTTP                          │
│          ▼                                         │
│ VictoriaLogs  ◄────────────────────── Grafana      │
│                                                    │
│ vmagent ───► VictoriaMetrics ◄─────── Grafana      │
└────────────────────────────────────────────────────┘

VictoriaMetrics хранит метрики - числовые временные ряды. VictoriaLogs хранит и индексирует логи, а Grafana подключается к обоим системам отдельными datasource.

Почему VictoriaMetrics

vmagent работает по привычной Prometheus-модели: он скрейпит endpoint’ы по конфигурации и передаёт данные в удалённое хранилище через remote write. Он также умеет фильтровать данные и менять labels перед отправкой.

Это означает, что можно использовать стандартный scrape.yml, PromQL и готовые Grafana-дашборды для node-exporter или PostgreSQL.

Метрики Supabase

Для self-hosted Supabase я снимаю метрики с трёх уровней:

  • node-exporter - CPU, RAM, диск, сеть и состояние самого сервера.

  • postgres-exporter - метрики PostgreSQL.

  • blackbox-exporter - HTTP-проверки доступности Kong, Auth, PostgREST и Storage.

PostgreSQL exporter в примерах статьи доступен на digest.the-signal.ru:9187; этот instance можно зафиксировать в Grafana-дашборде, чтобы панели не подхватывали лишние значения.

scrape.yml

global:
  scrape_interval: 15s
  scrape_timeout: 10s

scrape_configs:
  - job_name: node_exporter
    static_configs:
      - targets:
          - digest.the-signal.ru:9100

  - job_name: postgres_exporter
    static_configs:
      - targets:
          - digest.the-signal.ru:9187

  - job_name: supabase_health
    metrics_path: /probe
    params:
      module:
        - http_2xx

    static_configs:
      - targets:
          - http://kong:8000/health
          - http://auth:9999/health
          - http://rest:3000/ready
          - http://storage:5000/status

    relabel_configs:
      - source_labels:
          - __address__
        target_label: __param_target

      - source_labels:
          - __param_target
        target_label: instance

      - target_label: __address__
        replacement: blackbox:9115

Blackbox exporter публикует probe_success = 1, если target вернул успешный HTTP-ответ; при сетевой ошибке, таймауте или неподходящем статус-коде метрика будет равна 0.

Первая версия: HTTP-адаптер

Первой рабочей схемой был простой HTTP-адаптер на Edge Function.

Supabase / Logflare
        │ HTTP POST
        ▼
Edge Function-адаптер
        │ HTTP JSONLine
        ▼
VictoriaLogs

Функция принимала входящий JSON, приводила поля к ожидаемому формату и пересылала записи в VictoriaLogs. Это был быстрый способ проверить путь от генерации лога до его появления в Grafana.

Пример отправки одной записи:

curl -X POST \
  "https://logs.example.com/insert/jsonline?\_stream\_fields=appname,project&\_msg\_field=event\_message&\_time\_field=timestamp" \
  -H "Content-Type: application/stream+json" \
  -d '{
    "timestamp": "2026-06-04T14:24:26.545Z",
    "event_message": "edge function invoked",
    "appname": "supabase-edge-functions",
    "project": "default",
    "metadata": {
      "level": "INFO"
    }
  }'

VictoriaLogs поддерживает HTTP ingestion и позволяет задать поле текстового сообщения через msgfield, время через timefield, а идентификаторы логового потока - через streamfields.

Почему я отказался от адаптера

HTTP-адаптер оказался полезным proof of concept, но плохим кандидатом на постоянную архитектуру.

Во-первых, он добавлял ещё один сервис и сетевой переход: нужно поддерживать Edge Function, маршрут в Kong и отдельно искать ошибки доставки. Во-вторых, разные компоненты Supabase используют разные форматы логов: структурированный JSON, обычные строки, вложенные поля. Логика нормализации быстро начинала расползаться по коду адаптера.

Главное же - Vector уже присутствует в self-hosted Docker Compose Supabase и уже умеет читать docker-логи. Значит отдельный ingestion-сервис дублировал часть существующего pipeline.

Переход на Vector

В новой схеме Vector взял на себя три роли:

  1. Читать stdout/stderr контейнеров через Docker API.

  2. Превращать разные форматы записей в единый контракт через VRL.

  3. Отправлять нормализованные логи батчами в VictoriaLogs.

Docker containers
      │ stdout / stderr
      ▼
Docker json-file driver
      │
      ▼
Vector: docker_logs source
      │
      ▼
VRL transforms
      │
      ▼
HTTP sink
      │
      ▼
Nginx на сервере мониторинга
      │
      ▼
VictoriaLogs

docker_logs - source Vector для чтения контейнерных логов. Он добавляет к событиям Docker-контекст, а сбор можно ограничить через include_containers и exclude_containers.

VictoriaLogs официально поддерживает приём логов из Vector, в том числе через HTTP JSON.

VictoriaLogs на отдельной машине

VictoriaLogs находится на сервере мониторинга, отдельно от Supabase. Поэтому Vector не обращается к внутреннему Docker hostname, а отправляет логи по HTTPS на Nginx.

Сам VictoriaLogs я не открываю наружу: он слушает только loopback-интерфейс.

services:
  victoria-logs:
    image: victoriametrics/victoria-logs:latest
    container_name: victoria-logs
    restart: unless-stopped

    command:
      - "-storageDataPath=/victoria-logs-data"
      - "-retentionPeriod=14d"
      - "-httpListenAddr=127.0.0.1:9428"

    volumes:
      - victoria-logs-data:/victoria-logs-data

В реальной конфигурации лучше использовать конкретную версию образа вместо latest, а retention выбирать по фактическому потоку логов, объёму диска и нужной глубине истории.

Nginx и IP allowlist

Nginx на сервере мониторинга принимает запросы Vector по HTTPS и пропускает их только с внешнего IP сервера приложения.

server {
    listen 443 ssl http2;
    server_name logs.example.com;

    ssl_certificate     /etc/letsencrypt/live/logs.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/logs.example.com/privkey.pem;

    location /insert/ {
        # Внешний IP сервера с Supabase и Vector
        allow 1.2.3.4;
        deny all;

        proxy_pass http://127.0.0.1:9428;

        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        client_max_body_size 20m;
        proxy_request_buffering off;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

Такой allowlist не отменяет firewall, но добавляет прикладной сетевой барьер: запросы с любого IP, кроме разрешённого, Nginx отклонит с 403 Forbidden.

Порт VictoriaLogs 9428 в интернет не публикуется: внешний ingestion идёт только к Nginx по HTTPS, а он проксирует трафик локально на 127.0.0.1:9428.

Что такое VRL

VRL (Vector Remap Language) - встроенный язык преобразования событий в Vector. Он выполняется для каждой записи отдельно и позволяет извлекать и добавлять поля, приводить типы, разбирать JSON, фильтровать записи и формировать стандартизированный payload перед отправкой.

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

{
  "message": "{\"level\":\"error\",\"msg\":\"payment webhook failed\"}",
  "container_name": "supabase-edge-functions",
  "timestamp": "2026-06-07T18:11:42.123Z"
}

После обработки я хочу получить стабильную структуру:

{
  "timestamp": "2026-06-07T18:11:42.123Z",
  "project": "default",
  "appname": "supabase-edge-functions",
  "service": "functions",
  "event_message": "payment webhook failed",
  "metadata": {
    "level": "ERROR"
  }
}

Тогда запросы и дашборды не зависят от формата stdout конкретного контейнера: достаточно полей service, appname, event_message и metadata.level.

Конфигурация Vector

В конфигурации Vector есть три типа компонентов:


БлокНазначениеВ этой конфигурацииsourcesПолучают событияdocker_logs забирает логи контейнеровtransformsМеняют или фильтруют событияVRL разбирает и нормализует записиsinksДоставляют результатHTTP JSONLine в VictoriaLogs

Ниже упрощённый volumes/logs/vector.yml.

api:
  enabled: true
  address: 0.0.0.0:9001

sources:
  docker_host:
    type: docker_logs
    exclude_containers:
      - supabase-vector

transforms:
  project_logs:
    type: remap
    inputs:
      - docker_host
    source: |-
      .appname = .container_name ?? "unknown"
      .project = "default"
      .timestamp = .timestamp ?? now()
      .event_message = string!(.message)

  functions_logs:
    type: remap
    inputs:
      - project_logs
    source: |-
      if .appname != "supabase-edge-functions" {
        abort
      }

      .service = "functions"

      parsed, err = parse_json(.event_message)

      if err == null {
        .event_message = parsed.event_message ?? parsed.msg ?? .event_message
        .metadata = parsed.metadata ?? {}
      }

      if is_null(.event_message) || .event_message == "" {
        .event_message = "empty log"
      }

      .metadata.level = .metadata.level ?? "INFO"

      message = downcase(string!(.event_message))

      if contains(message, "error") {
        .metadata.level = "ERROR"
      } else if contains(message, "warn") {
        .metadata.level = "WARN"
      } else if contains(message, "debug") {
        .metadata.level = "DEBUG"
      }

  other_supabase_logs:
    type: remap
    inputs:
      - project_logs
    source: |-
      if .appname == "supabase-edge-functions" {
        abort
      }

      .service = .appname
      .metadata.level = "INFO"

      if is_null(.event_message) || .event_message == "" {
        .event_message = "empty log"
      }

sinks:
  victoria_logs:
    type: http
    inputs:
      - functions_logs
      - other_supabase_logs

    method: post

    uri: "https://logs.example.com/insert/jsonline?\_stream\_fields=service,appname,project&\_msg\_field=event\_message&\_time\_field=timestamp"

    encoding:
      codec: json

    framing:
      method: newline_delimited

    compression: gzip

    request:
      retry_max_duration_secs: 10
      headers:
        Content-Type: application/stream+json

Здесь HTTP sink отправляет newline-delimited JSON на endpoint VictoriaLogs. Параметры streamfields, msgfield и timefield указывают, какие поля образуют поток, содержат текст сообщения и время события.

Разбираем VRL

Главная ценность Vector в этой схеме - не просто отправка логов, а нормализация разнородных сообщений с помощью VRL.

Фильтрация Edge Functions

if .appname != "supabase-edge-functions" {
  abort
}

Поле .appname выше сформировано из имени Docker-контейнера. abort прекращает обработку текущего события в ветке functions_logs: лог Kong, Postgres или Storage не попадёт в transform, предназначенный для Edge Functions.

Стабильное имя сервиса

.service = "functions"

Имя контейнера - инфраструктурная деталь, а service - стабильное доменное поле. В Grafana можно искать так:

service:functions

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

Безопасный JSON-парсинг

parsed, err = parse_json(.event_message)

if err == null {
  .event_message = parsed.event_message ?? parsed.msg ?? .event_message
  .metadata = parsed.metadata ?? {}
}

Логи Edge Functions не всегда имеют одинаковую форму: некоторые уже являются JSON, другие - простой текст. parse_json возвращает распарсенное значение и ошибку. Если это не JSON, err не равен null, transform сохраняет исходное сообщение и не ломает pipeline.

Оператор ?? означает fallback: взять значение слева, но если его нет, попробовать следующее. Здесь сообщение выбирается из event_message, затем msg, затем сохраняется исходная строка.

Почему нужен fallback

if is_null(.event_message) || .event_message == "" {
  .event_message = "empty log"
}

В sink задано:

_msg_field=event_message

Значит VictoriaLogs ожидает, что в каждой записи есть непустой event_message. Если поле сообщения отсутствует, будет предупреждение missing _msg field.

Иногда полезнее отбрасывать пустые записи:

if is_null(.event_message) || .event_message == "" {
  abort
}

Я оставил значение empty log, потому что при отладке Edge Functions полезно не терять факт технического события. Для шумных системных логов лучше использовать abort.

Нормализация уровня

.metadata.level = .metadata.level ?? "INFO"

message = downcase(string!(.event_message))

if contains(message, "error") {
  .metadata.level = "ERROR"
} else if contains(message, "warn") {
  .metadata.level = "WARN"
} else if contains(message, "debug") {
  .metadata.level = "DEBUG"
}

Если приложение уже прислало структурированный уровень, он используется. Если нет - применяется INFO, а затем простая эвристика по тексту. Такой подход не заменяет структурированные application logs, но делает обычные сообщения stdout/stderr удобнее для фильтрации.

Ошибка VRL E103

VRL не позволяет игнорировать ошибки потенциально опасных функций. Такие функции называются fallible: например, JSON может не распарситься, преобразование может получить значение неверного типа, а поле может отсутствовать. VRL требует явной обработки этих ошибок.

Проблемный код обычно выглядит так:

.message = strip_whitespace(parsed.msg)

Здесь parsed.msg может отсутствовать или не быть строкой. У меня похожее использование strip_whitespace(parsed.msg) и привело к ошибке E103.

Надёжнее сначала обработать parsing и тип:

parsed, err = parse_json(.event_message)

if err == null && exists(parsed.msg) {
  value, trim_err = strip_whitespace(to_string(parsed.msg))

  if trim_err == null {
    .event_message = value
  }
}

Суффикс !, например в string!(.event_message), означает, что при ошибке обработка конкретного события завершится. Его стоит использовать там, где тип гарантирован предыдущими проверками или потеря одной записи приемлема.

Проверяем pipeline

После изменения vector.yml я сначала валидирую конфигурацию:

docker exec supabase-vector \
  vector validate /etc/vector/vector.yml

После успешной проверки перезапускаю только Vector:

docker compose restart vector

docker logs supabase-vector --tail=100 -f

На сервере мониторинга можно проверить, что записи действительно попадают в VictoriaLogs:

curl -G \
  "http://127.0.0.1:9428/select/logsql/query" \
  --data-urlencode "query=*" \
  --data-urlencode "limit=20" | jq

VictoriaLogs поддерживает LogSQL-запросы через endpoint /select/logsql/query.

Для диагностики Edge Functions я использую запросы такого вида:

service:functions

service:functions AND metadata.level:ERROR

appname:supabase-edge-functions AND _msg:timeout

Основной текст лога в VictoriaLogs находится в _msg, а fields позволяет вывести нужные поля в результатах запроса.

Grafana

В Grafana я подключил два datasource:

  • VictoriaMetrics через Prometheus datasource - графики, PromQL и алерты.

  • VictoriaLogs datasource - Explore и поиск по логам.

В результате сценарий расследования выглядит так: сначала на графике видно падение probe_success у одного из сервисов, затем в Grafana Explore можно открыть логи нужного сервиса и отфильтровать ERROR за тот же интервал времени.

Результат

Получился самостоятельный контур наблюдаемости для self-hosted Supabase:

  • VictoriaMetrics хранит метрики хоста, PostgreSQL и health-check’ов.

  • VictoriaLogs хранит логи Supabase.

  • Vector собирает docker-логи, нормализует их через VRL и отправляет на отдельный сервер.

  • Nginx принимает ingestion по HTTPS и ограничивает доступ по IP.

  • Grafana объединяет метрики и логи в одном интерфейсе.

Главный вывод: Edge Function с HTTP-адаптером хорошо подходит, чтобы быстро проверить ingestion и формат сообщений. Но если инфраструктура уже Docker-ориентированная, а Vector входит в стек Supabase, удобнее оставить ему роль единого агента сбора и нормализации: меньше компонентов, меньше дополнительной логики и понятнее путь каждой записи от контейнера до Grafana.

Дебаж 🐞с ноги 🦶