Почему не подошли текущие инструменты?

Loki

Хорошее хранилище логов, но у него есть очень существенное ограничение: поиск по содержимому это скан по сжатым чанкам всех совпавших потоков, индекса по телу строки нет. Так что при высококардинальных полях (тот же trace_id, user_id) — привет, полный скан, чего конечно хочется избежать. Ну а чтобы получить fts по телу, соизвольте заплтатить.

Трейсы кстати Loki не хранит, так что корреляция логов и трейсов идёт через Grafana и Jaeger — это уже вторая система и ещё один запрос.


VictoriaLogs

Уже имеется FTS что решает проблему Loki, но за трейсами увы пройдите в VictoriaTraces.


Jaeger

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


Elasticsearch

После их шизо-качелей с лицензией которые убили этот продукт. А брать форк который вырос на фоне его смерти (OpenSearch) тоже не хочется, ибо там везде под капотом тащится JVM с проблемами которые это за собой тянет — догадайтесь сами.


Что имеем?

Собственно у нас есть точечные продукты, которые хороши в X и не очень в Y. А что делать когда хочется всё в одном месте — тащим либ Elastic с его jvm мусором, либо собирайте зоопарк технологий из нескольких продуктов, что на средне-больших команды круто, но на маленьких продуктах такое разворачивать не хочется.(По крайне мере мне)

Да, есть OpenObserve — open-source, single binary, логи и трейсы вместе. Но часть нужных фич уже уехала под платную подписку, а история последних лет показывает что это только начало: Terraform, Redis, Elasticsearch,ScyllaDB — паттерн знакомый. Брать опенсоурс на который нельзя положиться в долгую не хотелось.

Вот из чего появился Amber.


Что внутри

Два отдельных хранилища: data/logs/ и data/spans/. Логи и спаны живут в одном файловом формате (.alog), но в разных директориях — у них разные схемы и разные паттерны запросов. Fulltext-поиск по телу лога не должен трогать спаны. Запрос трейса не должен сканировать все логи. Индексы, WAL, ротация сегментов — всё независимо.

LogEntry содержит TraceID и SpanID совместимые с W3C TraceContext. SpanEntry рядом — там ParentSpanID, Operation, StartTime, Duration, атрибуты.

Запрос GET /api/v1/traces/{trace_id} объединяет оба хранилища:

  1. Из data/spans/ достаём все спаны с этим trace_id
  2. Строим дерево в памяти: кладём все спаны в map[SpanID]SpanEntry, находим корни (те у кого ParentSpanID нулевой), рекурсивно прикрепляем детей. Спаны из разных сервисов записываются независимо — порядок записи не гарантирован, поэтому дерево собирается на выдаче, а не при записи.
  3. Из data/logs/ достаём все логи с этим trace_id, прикрепляем к соответствующим спанам по span_id.
{
  "trace_id": "a1b2c3...",
  "span_count": 3,
  "tree": [
    {
      "span": { "operation": "POST /api/v1/orders", "duration_ms": 145 },
      "logs": [
        { "level": "INFO", "message": "order created", "ts": "..." }
      ],
      "children": [
        { "span": { "operation": "db.query orders" }, "logs": [], "children": [] },
        { "span": { "operation": "redis.get cache" }, "logs": [], "children": [] }
      ]
    }
  ],
  "took_ms": 2
}

Всё — за один запрос, без отдельного Jaeger и второго round-trip.


Архитектура: почему append-only

Логи никогда не изменяются — это свойство данных. Можно агрессивно сжимать без MVCC, не думать про update-in-place, не беспокоиться о фрагментации.

Данные хранятся в .alog файлах: data/logs/seg_00000001.alog, data/spans/seg_00000001.alog. Каждый файл состоит из заголовка, последовательности блоков и footer-а:

┌─────────────────────────────────┐
│ HEADER (16 bytes)               │
│   magic "AMBR", version,        │
│   created_at (unixnano), flags  │
├─────────────────────────────────┤
│ BLOCK 0                         │
│   magic "BLOK"                  │
│   uncompressed_size / comp_size │
│   record_count                  │
│   data: zstd(records...)        │
├─────────────────────────────────┤
│ BLOCK 1 ...                     │
├─────────────────────────────────┤
│ FOOTER                          │
│   min_ts, max_ts                │
│   record_count, block_count     │
│   block_offsets[] (байт-оффсет  │
│   каждого блока в файле)        │
│   footer_size, magic "FOOT"     │
└─────────────────────────────────┘

Один блок — это буфер до 4MB несжатых записей. Внутри него записи лежат последовательно: [4 байта длина][N байт данные][4 байта длина][N байт данные].... Одна запись — от ста до нескольких сотен байт, в один блок помещается 8–20K записей в зависимости от их размера. Когда буфер достигает 4MB, он сжимается zstd и пишется на диск целиком.

Footer — ключевая деталь. При открытии файла читаем только его: последние 4 байта проверяем на magic "FOOT", оттуда читаем footer_size, прыгаем назад на footer_size байт и читаем весь footer. В block_offsets[] лежат байтовые позиции каждого блока в файле — можно прыгнуть сразу на нужный блок без скана.

Из min_ts и max_ts сразу понятно — есть ли в этом сегменте что-то нужное для временного запроса. Большинство файлов пропускаем.

Если процесс упал посередине записи блока — footer не записан, magic "FOOT" не будет совпадать, читатель получает ErrNoFooter. Незавершённый сегмент либо игнорируется, либо восстанавливается через WAL-replay.


Write path: WAL и батчинг

Перед сегментом — WAL. Каждая запись сначала идёт в WAL с fsync, потом в сегмент. При краше — replay.

Каждая запись в WAL имеет фиксированный 12-байтовый заголовок:

[4]  magic    = 0xABCD1234
[4]  crc32    = IEEE CRC32 от payload
[4]  length   = длина payload в байтах
[N]  payload  = [8 байт timestamp LE] + [данные записи]

Replay при старте: читаем записи последовательно, проверяем magic и CRC. Первая запись с битым magic или несовпадающим CRC — стоп. Всё до неё применяем к сегменту. Оборванная запись в хвосте это штатная ситуация при краше — WAL об этом знает.

После того как батч успешно записан в сегмент, WAL очищается: Truncate(0), seek в начало, пересоздаём буферизованный writer. Следующий батч пишет в чистый файл.

Но fsync дорогой. На NVMe ~0.1ms, при 1000 запросов/сек в одном потоке — максимум 10K записей/сек.

Так что батчим:

// N fsync:
for _, entry := range entries {
    wal.Write(entry) // N × fsync
}

// 1 fsync на весь батч:
wal.WriteBatch(payloads) // 1 × fsync, N записей

Все заголовки и payload пишутся в один 64KB буферизованный writer, потом один buf.Flush(), потом один file.Sync(). Размер канала, размер батча и таймаут сброса задаются через конфиг — можно подбирать под свою нагрузку:

ingest:
  queue_size: 10000    # cap входного канала
  batch_size: 1000     # записей в батче
  batch_timeout: 100ms # максимальное ожидание перед сбросом
BenchmarkWALWriteBatch    115 278 ops/sec
BenchmarkWALWriteSingle     7 812 ops/sec

15x на одном изменении.


Индексы: воронка фильтрации

Три уровня. Каждый отсекает данные без чтения сегментов с диска.

flowchart TD
    A["Все сегменты 100%"]
    B["SparseIndex\nsравнение min/max ts\n→ 3–5% сегментов"]
    C["BitmapIndex\nroaring AND по полям\n→ ~1% записей"]
    D["FTSIndex\nрадикс-дерево + stemming\n→ <1% при fulltext"]
    E["Scan сегмента\nzstd decode + filter"]

    A --> B --> C --> D --> E

SparseIndex — один файл, один диапазон времени на сегмент. Запрос “последний час” при данных за 30 дней отсекает 97% файлов за 380ns.

BitmapIndex — для каждого поля и каждого значения хранится множество entry ID в виде Roaring Bitmap. Если не знакомы с этой структурой: можно думать о ней как о сжатом битовом массиве, где бит N означает “запись с ID=N удовлетворяет условию”. level=ERROR — один bitmap с ID всех error-логов. service=api-gateway — другой bitmap. AND двух bitmap через SIMD-операции возвращает список ID, которые совпадают по обоим полям.

"level":
  "ERROR" → {3, 7, 42, 891, 1204, ...}
  "INFO"  → {1, 2, 4, 5, 6, ...}

"service":
  "api-gateway" → {2, 7, 42, 100, ...}

level=ERROR AND service=api-gateway:
  {3,7,42,891,...} AND {2,7,42,100,...} = {7, 42, ...}

Строится при запечатывании сегмента, не при записи — в профилировщике addDoc занимал 39% CPU и memmove от роста trie ещё 37% при build-on-write. Убрал из write path.

FTSIndex — радикс-три (сжатое prefix-дерево) со stemming-ом (Snowball, русский + английский). В листьях дерева лежат списки document ID — для каждого слова это все записи где оно встречается. Stemming превращает слово в корневую форму перед индексированием и перед поиском: “connections” и “connected” оба ищутся через “connect”, русское “логирование” через “логир”. Это увеличивает recall — находишь документы даже если форма слова не совпадает точно. Fulltext поиск по содержимому логов — то чего у Loki нет без платного облака.

FTS как и BitmapIndex строится при запечатывании сегмента. Причина та же — в write path добавление в растущее trie слишком дорого.

За основу fts был взят движок: fts, и адаптирован для работы в amber fork. Можете глянуть кому интересно.


Полная картина работы amber

При старте — загружаем .bidx для всех sealed сегментов. Если файла нет — строим через scan.


Что дальше

Amber сейчас — хранилище логов и трейсов с HTTP API. Следующие шаги:

OTLP endpointPOST /v1/logs и POST /v1/traces в формате OpenTelemetry Protocol. Модель уже совместима с OTel: TraceID/SpanID следуют W3C TraceContext, атрибуты — []Attr{Key, Value}. Добавить OTLP receiver означает совместимость со всей экосистемой OTel сразу — Go, Python, Java, Node.js — без отдельного Collector.

Embeddable library — закрыть ту нишу которой нет. amber.Open("./data") и приложение само хранит свои логи и трейсы без внешнего сервера. Полезно для CLI-инструментов, тестовых окружений, edge-деплоя.

Go SDK — нативная интеграция с slog, zap, zerolog с автобатчингом и локальным буфером.

• • •