Netpulse_SasS/server/internal/logbuf/logbuf.go
byrsapty 04e1242c52
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m10s
CI / server (push) Successful in 1m41s
CI / agent (push) Successful in 59s
Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.

Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:

  1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
     бачить, а до правил rsyslog вони доходять не завжди. Каталог
     створювався й лишався порожнім.
  2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
     programname на скісній: для ВСІХ контейнерів вона ставала просто
     «netpulse». Тег тепер — саме ім'я контейнера, воно й так має
     префікс проєкту.
  3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
     текст: файли були, рядки були, слів не було. Журнал виглядав
     робочим і не містив нічого — найгірший різновид поломки.

Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.

МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.

Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
2026-08-27 22:55:29 +03:00

480 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package logbuf — останні рядки власного журналу процесу, у пам'яті.
package logbuf
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"log/slog"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/netpulse/netpulse/server/internal/gitstore"
)
// ЩО ЦЕ ЛІКУЄ
//
// Продукт показує syslog із пристроїв, журнал аудиту, черги й
// стенограми команд — тобто все про мережу й нічого про себе. Коли
// лається сам NetPulse (не поїхало дзеркало, не закрився період SLA,
// відмовила розсилка), єдиний спосіб це побачити — `docker logs`, тобто
// ssh на сервер. Людина, яка має право дивитись на моніторинг, такого
// доступу зазвичай не має й не повинна мати.
//
// Тому обгортка над slog.Handler: окрім звичайного виводу вона кладе
// запис у кільце останніх рядків, а REST віддає це кільце сторінці.
//
// ЧОМУ В ПАМ'ЯТІ, А НЕ В БАЗІ
//
// Журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres, —
// тобто в найцікавіший момент. Той самий такт, що мав би зберегти рядок
// «немає з'єднання з БД», сам упреться в те саме «немає з'єднання».
// Пам'ять цієї залежності не має взагалі. Саме тому тут немає й
// міграції: зберігати нема чого й нема де.
//
// Ціна відома й названа прямо на сторінці: кільце не переживає
// перезапуску процесу. Якщо процес упав, його останні слова лишились
// лише в `docker logs`. Це не недогляд, а межа механізму, і мовчати про
// неї не можна: людина, яка шукає в журналі причину падіння й нічого не
// знаходить, зробить хибний висновок «нічого не сталося».
//
// ЧОМУ СТЕЛЯ В БАЙТАХ, А НЕ В РЯДКАХ
//
// Бо рядки не однакові. Звичайний рядок рівня info — це десь 150200
// байтів; один рядок із текстом помилки Postgres, у який укладено сам
// запит, буває довшим за сотню звичайних. «Тримати 5000 рядків» на
// потоці, де кожен третій — така помилка, означає непередбачувані
// десятки мегабайтів у процесі, який їх нікому не обіцяв. Стеля в
// байтах — це стеля, яку можна порахувати наперед і назвати в звіті.
//
// Звідси ж друга стеля, MaxRecordBytes: без неї один рядок на кілька
// мегабайтів (а конфіг маршрутизатора, який хтось поклав у атрибут,
// саме такий) виніс би з кільця геть усе, і сторінка показала б рівно
// цей один рядок. Обрізаний запис лишається корисним; порожнє кільце —
// ні.
const (
// DefaultMaxBytes — скільки байтів тексту тримає кільце одного
// процесу.
//
// 8 МіБ, і ось арифметика, з якої це число взялося. Звичайний рядок
// (повідомлення + атрибути + службові поля) важить близько 170
// байтів — це приблизно 50 000 рядків. Найгірший випадок, коли
// КОЖЕН рядок уперся в MaxRecordBytes, дає 8 Мі/(8 Кі+128) ≈ 1000
// рядків. Тобто глибина журналу гуляє в п'ятдесят разів, а пам'ять
// не гуляє взагалі — саме заради цього стеля й у байтах.
//
// Процесів два, тож інсталяція платить 16 МіБ тексту плюс службові
// поля записів (ще близько 6 МіБ у типовому випадку). Для сервера,
// який тримає Postgres із TimescaleDB, це шум; для сторінки, заради
// якої більше не треба йти по ssh, — вигідний обмін.
DefaultMaxBytes = 8 << 20
// MaxRecordBytes — стеля одного запису.
//
// 8 КіБ вистачає на будь-яку помилку з текстом SQL-запиту (їх
// бачили до ~3 КіБ) і не вистачає на конфіг пристрою — а це рівно
// та межа, яку тут і треба провести.
MaxRecordBytes = 8 << 10
// recordOverhead — скільки важить запис понад свій текст.
//
// Сама структура Record — 96 байтів на 64-бітній платформі, плюс
// вміст рядків рівня й джерела, плюс місце в слайсі. Округлено до
// 128 навмисно в більший бік: стеля має бути обіцянкою, а не
// оцінкою, і недорахувати тут гірше, ніж перерахувати.
recordOverhead = 128
// truncMark — позначка обрізаного запису.
//
// Видима навмисно: рядок, який мовчки обрізали, читають як цілий і
// шукають у ньому причину, якої в ньому вже немає.
truncMark = "…[обрізано]"
)
// Record — один рядок журналу так, як його бачить сторінка.
//
// Атрибути зведені в один рядок "ключ=значення", а не збережені
// структурою: сторінці однаково показувати їх текстом, пошук по тексту
// має бачити і ключі, і значення, а зберігати []slog.Attr означало б
// тримати живими всі об'єкти, на які вони посилаються, — тобто кільце
// на 8 МіБ тексту утримувало б у пам'яті сотні мегабайтів чужих даних.
type Record struct {
// Seq — наскрізний номер у межах процесу. Потрібен не для показу, а
// для порядку: два записи в одну наносекунду впорядкувати за часом
// не можна, і сторінка їх перемішувала б на кожному оновленні.
Seq uint64 `json:"seq"`
Time time.Time `json:"time"`
Level string `json:"level"`
Msg string `json:"msg"`
Attrs string `json:"attrs,omitempty"`
// Source — який процес це написав. Заповнюється не кільцем, а тим,
// хто знімок віддає: саме кільце свого імені не знає й знати не
// мусить.
Source string `json:"source,omitempty"`
}
func (r Record) size() int { return len(r.Msg) + len(r.Attrs) + recordOverhead }
// Snapshot — стан кільця на мить запиту.
//
// Разом із записами їдуть і числа про саме кільце. Це не діагностика
// заради діагностики: без Dropped і Since людина не відрізнить «за
// годину нічого не сталося» від «сталося стільки, що початок уже
// витіснено», а це протилежні висновки.
type Snapshot struct {
Source string `json:"source"`
Records []Record `json:"records"`
Dropped uint64 `json:"dropped"`
Since time.Time `json:"since"`
Bytes int `json:"bytes"`
MaxBytes int `json:"max_bytes"`
}
// Ring — кільце останніх рядків.
type Ring struct {
mu sync.Mutex
max int
bytes int
seq uint64
dropped uint64
since time.Time
recs []Record
// secrets — що маскувати в кожному записі; див. Mask.
secrets []string
}
// New створює кільце зі стелею max байтів тексту.
//
// Стеля, менша за MaxRecordBytes, піднімається до неї: інакше перший же
// довгий рядок витіснив би сам себе, і кільце лишалося б порожнім
// назавжди — стан, у якому сторінка бреше найпереконливіше.
func New(max int) *Ring {
if max < MaxRecordBytes {
max = MaxRecordBytes
}
return &Ring{max: max, since: time.Now()}
}
// ЧОМУ СЕКРЕТИ МАСКУЮТЬСЯ ТУТ, ЯКЩО ВОНИ ВЖЕ МАСКУЮТЬСЯ В ДЖЕРЕЛІ
//
// Бо маскування в джерелі — це набір окремих рішень у окремих файлах
// (gitstore.RedactURL для адрес дзеркала, scrubToken для бота), і
// кожне з них правильне рівно доти, доки хтось не додасть ще один
// log.Error із чужою помилкою всередині. Досі ціна такого недогляду
// була невелика: рядок їхав у stderr, звідти в docker, і читав його
// той, хто вже має ssh, тобто вже має і DSN, і ключі. Кільце міняє
// саме це — воно робить журнал видимим у браузері й вивантажуваним у
// файл, який піде в тікет. Тому тут другий, суцільний рубіж.
//
// Маскування НЕ своє: береться gitstore.Scrub — той самий, яким
// чиститься текст помилок дзеркала. Він робить дві речі, і обидві
// потрібні: замінює передані значення й вирізає логін-пароль із
// будь-якої адреси виду «схема://щось@», хоч би де вона в рядку
// трапилась. Друге важливіше за перше: воно ловить і DSN із паролем, і
// вставлений із чужої інструкції git-URL із токеном — тобто те, про що
// ми не знали заздалегідь.
//
// Значення коротші за 8 байтів Scrub ігнорує (вони збіглися б із
// випадковим підрядком і перетворили журнал на кашу) — тож коротким
// секретом цей рубіж не закрити, і покладатись на нього як на єдиний
// не можна.
// Mask реєструє секрети, які не мають потрапити в кільце.
func (r *Ring) Mask(secrets ...string) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range secrets {
if s = strings.TrimSpace(s); s != "" {
r.secrets = append(r.secrets, s)
}
}
}
// Wrap загортає наявний обробник.
//
// Саме загортає, а не заміняє: next викликається завжди й першим, а
// кільце наповнюється після нього. Порядок тут структурний, а не
// стилістичний — він робить неможливим стан «журнал є на сторінці, але
// зник із stderr». Вивід у stderr — головний: його забирає docker, він
// переживає перезапуск процесу, і саме він лишається єдиним джерелом
// про падіння.
func (r *Ring) Wrap(next slog.Handler) slog.Handler {
if next == nil {
panic("logbuf: Wrap(nil)")
}
return &handler{next: next, ring: r}
}
// Snapshot — копія кільця, найсвіжіші перші.
//
// Порядок задано тут, а не в кожного споживача: сторінка, вивантаження
// у файл і злиття двох процесів усі хочуть свіже зверху, і забути
// розвернути десь в одному місці — питання часу.
func (r *Ring) Snapshot(source string) Snapshot {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]Record, len(r.recs))
for i, rec := range r.recs {
rec.Source = source
out[len(r.recs)-1-i] = rec
}
return Snapshot{
Source: source,
Records: out,
Dropped: r.dropped,
Since: r.since,
Bytes: r.bytes,
MaxBytes: r.max,
}
}
// add кладе запис у кільце, витісняючи найстаріші.
func (r *Ring) add(t time.Time, level, msg, attrs string) {
r.mu.Lock()
defer r.mu.Unlock()
// Маскування під тим самим замком, що й запис: секрети реєструються
// при старті, але Mask лишається відкритим методом, і читати
// r.secrets без замка означало б гонку рівно на тому полі, заради
// якого все це й робиться.
msg = scrub(msg, r.secrets)
attrs = scrub(attrs, r.secrets)
// Обрізається саме хвіст атрибутів: повідомлення й перші атрибути
// кажуть, ЩО сталося, а розпух зазвичай останній — вкладений текст
// помилки чи конфігу.
if over := len(msg) + len(attrs) - MaxRecordBytes; over > 0 {
if len(attrs) > over+len(truncMark) {
attrs = attrs[:len(attrs)-over-len(truncMark)] + truncMark
} else {
attrs = truncMark
if len(msg) > MaxRecordBytes {
msg = msg[:MaxRecordBytes-len(truncMark)] + truncMark
}
}
}
r.seq++
rec := Record{Seq: r.seq, Time: t, Level: level, Msg: msg, Attrs: attrs}
r.recs = append(r.recs, rec)
r.bytes += rec.size()
for r.bytes > r.max && len(r.recs) > 1 {
r.bytes -= r.recs[0].size()
// Обнуляється навмисно: без цього рядки витісненого запису
// лишалися б живими у хвості масиву аж до наступного
// перевиділення, і кільце тримало б у пам'яті помітно більше за
// свою стелю.
r.recs[0] = Record{}
r.recs = r.recs[1:]
r.dropped++
// Since переїжджає на найстаріший із тих, що лишились. Спочатку
// це мить створення кільця («журнал від старту процесу»), після
// першого витіснення — час найстарішого запису. Різниця між
// цими двома станами і є відповіддю на питання, чи бачить
// людина початок історії, чи вже ні.
r.since = r.recs[0].Time
}
}
// scrub — маскування одного рядка наявним механізмом.
//
// gitstore.Scrub працює з помилками, а не з рядками, тому текст їде
// туди й назад через errors.New. Це дешевше, ніж мати другу копію тієї
// самої логіки: копія розійшлася б із оригіналом на першій же правці, і
// розійшлася б мовчки.
func scrub(s string, secrets []string) string {
if s == "" {
return s
}
return gitstore.Scrub(errors.New(s), secrets...).Error()
}
// ---------------------------------------------------------------------
// slog.Handler
// ---------------------------------------------------------------------
type handler struct {
next slog.Handler
ring *Ring
// Атрибути, накопичені через WithAttrs, уже відрендерені в текст:
// логер створюють раз, а пишуть у нього тисячі разів, і рендерити
// незмінну частину на кожен рядок було б платою ні за що.
attrs []string
// Префікс поточної групи з крапкою на кінці; порожній — груп немає.
group string
}
func (h *handler) Enabled(ctx context.Context, lv slog.Level) bool {
return h.next.Enabled(ctx, lv)
}
func (h *handler) Handle(ctx context.Context, rec slog.Record) error {
// Текст збирається ДО передачі далі. Формально slog.Record можна
// читати й після, але покладатись на це не варто: обробник нижче
// має право робити з копією що завгодно, а ми залежали б від його
// внутрішньої поведінки.
attrs := h.render(rec)
// Спершу stderr — і лише потім кільце. Помилка нижнього обробника
// не скасовує запису в кільце: рядок, який не вдалося записати в
// stderr, потрібен на сторінці тим більше.
err := h.next.Handle(ctx, rec)
h.ring.add(rec.Time, rec.Level.String(), rec.Message, attrs)
return err
}
func (h *handler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
n := *h
// slices.Clip обов'язковий: без нього два логери, породжені від
// одного, писали б у спільний хвіст масиву й затирали атрибути один
// одному.
n.attrs = slices.Clip(h.attrs)
for _, a := range as {
n.attrs = appendAttr(n.attrs, h.group, a)
}
n.next = h.next.WithAttrs(as)
return &n
}
func (h *handler) WithGroup(name string) slog.Handler {
// Порожнє ім'я groupʼи — «нічого не робити»: цього вимагає контракт
// slog.Handler.
if name == "" {
return h
}
n := *h
n.group = h.group + name + "."
n.next = h.next.WithGroup(name)
return &n
}
// render зводить атрибути запису в один рядок.
func (h *handler) render(rec slog.Record) string {
out := h.attrs
if rec.NumAttrs() > 0 {
out = slices.Clip(out)
rec.Attrs(func(a slog.Attr) bool {
out = appendAttr(out, h.group, a)
return true
})
}
return strings.Join(out, " ")
}
// appendAttr додає атрибут як "ключ=значення", розкриваючи групи.
func appendAttr(dst []string, prefix string, a slog.Attr) []string {
v := a.Value.Resolve()
if v.Kind() == slog.KindGroup {
g := v.Group()
// Група без атрибутів не пишеться зовсім — так само, як у
// стандартних обробниках.
if len(g) == 0 {
return dst
}
p := prefix
if a.Key != "" {
p += a.Key + "."
}
for _, sub := range g {
dst = appendAttr(dst, p, sub)
}
return dst
}
// Порожній ключ із порожнім значенням slog викидає; повторюємо.
if a.Equal(slog.Attr{}) {
return dst
}
return append(dst, prefix+a.Key+"="+quote(v.String()))
}
// quote бере значення в лапки лише тоді, коли без них рядок
// «ключ=значення» перестає читатись як пара.
func quote(s string) string {
if s == "" {
return `""`
}
if strings.ContainsAny(s, " \t\r\n\"=") {
return strconv.Quote(s)
}
return s
}
// ---------------------------------------------------------------------
// Що саме маскувати
// ---------------------------------------------------------------------
// DSNSecrets вибирає з рядка підключення те, що не має світитись.
//
// Саме пароль, а не DSN цілком: у DSN лишається хост і база, а вони в
// журналі потрібні — «не можу підключитись до db:5432» і «не можу
// підключитись до «секрет»» різняться тим, чи можна щось зробити з
// прочитаним.
//
// Дві форми, бо pgx приймає обидві: URL (postgres://user:pass@host/db)
// і набір ключів (host=db password=xxx). Друга трапляється рідше, але
// саме її вводять руками — тобто там, де пароль найчастіше і буває
// незвичним.
func DSNSecrets(dsn string) []string {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return nil
}
var out []string
if u, err := url.Parse(dsn); err == nil && u.User != nil {
if pw, ok := u.User.Password(); ok && pw != "" {
out = append(out, pw)
}
}
for _, part := range strings.Fields(dsn) {
if k, v, ok := strings.Cut(part, "="); ok {
switch strings.ToLower(strings.TrimSpace(k)) {
case "password", "sslpassword":
if v != "" {
out = append(out, v)
}
}
}
}
return out
}
// KeySecrets вибирає матеріал ключів шифрування зі спеки -dek.
//
// Формат той самий, що читає crypto.ParseKeyring: "key_id=<hex|base64>".
// Маскується і сам запис у тому вигляді, як він прийшов, і розібраний
// ключ: у журнал ключ може потрапити обома шляхами — і як шматок
// командного рядка, і як байти всередині чужої помилки.
func KeySecrets(spec string) []string {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []string
for _, part := range strings.Split(spec, ",") {
_, raw, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok || raw == "" {
continue
}
out = append(out, raw)
if key, err := hex.DecodeString(raw); err == nil {
out = append(out, string(key))
} else if key, err := base64.StdEncoding.DecodeString(raw); err == nil {
out = append(out, string(key))
}
}
return out
}