ФАЙЛИ В /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, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
247 lines
12 KiB
Go
247 lines
12 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"log/slog"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||
)
|
||
|
||
// Журнал самого сервера: на що лається NetPulse, коли лається він, а не
|
||
// мережа.
|
||
//
|
||
// ПРО ПРАВА
|
||
//
|
||
// settings:write, і нового права не заводиться.
|
||
//
|
||
// Звірено з наявним переліком (core.permissions, міграції 0010 і 0053).
|
||
// Придатних кандидатів там рівно два. audit:read — «читати журнал
|
||
// аудиту», і воно НЕ підходить: аудит відповідає на питання «хто що
|
||
// зробив», його дають внутрішньому контролеру, і давати разом із ним
|
||
// тексти помилок SQL і внутрішні адреси означало б розширити те право
|
||
// мовчки. settings:write — «Змінювати налаштування організації», і воно
|
||
// є лише у власника й адміна (0064 спирається на це прямо). Той, хто
|
||
// може змінити строки зберігання даних і викинути піврічну історію,
|
||
// уже бачить усе, що можна побачити; додати йому системний журнал —
|
||
// не розширення доступу, а виправлення того, що він досі не міг
|
||
// подивитись, не маючи ssh.
|
||
//
|
||
// Право на ЗАПИС для сторінки, яка лише читає, виглядає дивно рівно
|
||
// доти, доки не спитати, що воно насправді означає в цьому продукті:
|
||
// «це власник інсталяції». Заводити читальний двійник settings:read
|
||
// заради одної сторінки — це нове право в переліку ролей, нова галочка
|
||
// в редакторі ролей і нова міграція; коштує це дорожче, ніж дає.
|
||
//
|
||
// ЧОГО ТУТ НЕМАЄ Й НЕ БУДЕ
|
||
//
|
||
// Ані запису в базу, ані фільтра по кабінету. Журнал процесу — це
|
||
// журнал ІНСТАЛЯЦІЇ, а не кабінету: один такт прибиральника обслуговує
|
||
// всі кабінети одразу, і розділити його рядки між ними неможливо. Тому
|
||
// сторінка доступна власникові кабінету, але показує спільне — і саме
|
||
// тому нею не можна користуватись на спільному хостингу кількох
|
||
// клієнтів. На інсталяції в клієнта (а це і є коробковий продукт)
|
||
// кабінет один, і питання не виникає.
|
||
|
||
const (
|
||
// serverLogPerm — див. розбір прав вище.
|
||
serverLogPerm = "settings:write"
|
||
|
||
// Скільки рядків віддавати за раз.
|
||
//
|
||
// Фільтр накладається на сервері ДО стелі, і це не оптимізація, а
|
||
// сенс: «останні 500 рядків, серед яких три помилки» і «останні 500
|
||
// помилок» — різні відповіді, а людина, яка обрала рівень «error»,
|
||
// питає друге.
|
||
serverLogLimit = 500
|
||
serverLogMaxLimit = 5000
|
||
|
||
// Скільки чекати на сусідній процес. Три секунди: більше — і
|
||
// сторінка підвисає рівно тоді, коли по неї прийшли; менше — і
|
||
// завантажений колектор не встигав би відповісти.
|
||
serverLogTimeout = 3 * time.Second
|
||
)
|
||
|
||
// serverLogSource — стан одного джерела для сторінки.
|
||
//
|
||
// Помилка живе тут, а не в загальному полі відповіді, з тієї самої
|
||
// причини, з якої в StorageAlertState є Blind: «джерело мовчить» і
|
||
// «джерело недоступне» на екрані виглядають однаково, а означають
|
||
// протилежне. Без цього поля порожній колектор читався б як «у
|
||
// колекторі все тихо».
|
||
type serverLogSource struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error,omitempty"`
|
||
|
||
Records int `json:"records"`
|
||
Bytes int `json:"bytes"`
|
||
MaxBytes int `json:"max_bytes"`
|
||
Dropped uint64 `json:"dropped"`
|
||
Since time.Time `json:"since,omitempty"`
|
||
}
|
||
|
||
// WithServerLog вмикає сторінку журналу сервера.
|
||
//
|
||
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
|
||
// шифрування чи ліцензія: тести й частина інсталяцій кільця не мають, і
|
||
// сторінка тоді має чесно відповісти 503, а не впасти.
|
||
//
|
||
// collectorURL порожній — робочий стан: журнал колектора просто не
|
||
// показується, і сторінка каже, чому саме.
|
||
func (s *Server) WithServerLog(ring *logbuf.Ring, collectorURL, token string) *Server {
|
||
s.logRing = ring
|
||
s.collectorLogURL = strings.TrimSpace(collectorURL)
|
||
s.collectorLogToken = token
|
||
return s
|
||
}
|
||
|
||
// handleServerLog віддає злитий журнал обох процесів, найсвіжіші зверху.
|
||
func (s *Server) handleServerLog(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, serverLogPerm) {
|
||
return
|
||
}
|
||
if s.logRing == nil {
|
||
writeError(w, http.StatusServiceUnavailable, "no_log_buffer",
|
||
"кільцевий буфер журналу не увімкнено в цій збірці")
|
||
return
|
||
}
|
||
|
||
minLevel, levelOK := parseLogLevel(r.URL.Query().Get("level"))
|
||
if !levelOK {
|
||
writeError(w, http.StatusBadRequest, "bad_level", "рівень має бути error, warn або info")
|
||
return
|
||
}
|
||
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||
limit := serverLogLimit
|
||
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
|
||
limit = min(v, serverLogMaxLimit)
|
||
}
|
||
|
||
snaps := []logbuf.Snapshot{s.logRing.Snapshot("api")}
|
||
sources := []serverLogSource{describeSource("api", "API", snaps[0], nil)}
|
||
|
||
// Колектор питається завжди, коли адреса є: кешувати тут нема чого,
|
||
// сторінку відкриває одна людина й дивиться на неї хвилину, а
|
||
// застарілий на п'ять секунд журнал у момент аварії — це рівно ті
|
||
// п'ять секунд, яких бракує.
|
||
if s.collectorLogURL != "" {
|
||
snap, err := logbuf.Fetch(r.Context(), s.collectorLogURL, s.collectorLogToken, serverLogTimeout)
|
||
sources = append(sources, describeSource("collector", "Колектор", snap, err))
|
||
if err == nil {
|
||
snaps = append(snaps, snap)
|
||
} else {
|
||
// У власний журнал це не пишеться навмисно: сторінку
|
||
// оновлюють раз на кілька секунд, і недоступний сусід за
|
||
// хвилину залив би кільце сотнею однакових рядків — тобто
|
||
// витіснив би саме те, по що людина прийшла.
|
||
s.log.Debug("журнал колектора недоступний", "url", s.collectorLogURL, "err", err)
|
||
}
|
||
} else {
|
||
sources = append(sources, serverLogSource{
|
||
Key: "collector", Label: "Колектор",
|
||
Error: "адресу колектора не налаштовано (-collector-log або NETPULSE_COLLECTOR_LOG_URL)",
|
||
})
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"records": mergeLog(snaps, minLevel, query, limit),
|
||
"sources": sources,
|
||
"limit": limit,
|
||
})
|
||
}
|
||
|
||
// describeSource зводить знімок і помилку в один опис для сторінки.
|
||
func describeSource(key, label string, snap logbuf.Snapshot, err error) serverLogSource {
|
||
if err != nil {
|
||
return serverLogSource{Key: key, Label: label, Error: err.Error()}
|
||
}
|
||
return serverLogSource{
|
||
Key: key, Label: label, OK: true,
|
||
Records: len(snap.Records),
|
||
Bytes: snap.Bytes,
|
||
MaxBytes: snap.MaxBytes,
|
||
Dropped: snap.Dropped,
|
||
Since: snap.Since,
|
||
}
|
||
}
|
||
|
||
// parseLogLevel читає фільтр рівня як НИЖНЮ МЕЖУ.
|
||
//
|
||
// Саме межу, а не точний збіг: людина, яка обрала «warn», питає «що
|
||
// пішло не так», і ховати від неї error було б буквальним виконанням
|
||
// букви запиту проти його змісту.
|
||
//
|
||
// Порожнє значення — «усе», і сюди ж потрапляє debug: він вмикається
|
||
// прапорцем процесу, а не сторінкою, і якщо вже ввімкнений — має бути
|
||
// видним.
|
||
func parseLogLevel(s string) (slog.Level, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "", "all", "debug":
|
||
return slog.LevelDebug, true
|
||
case "info":
|
||
return slog.LevelInfo, true
|
||
case "warn", "warning":
|
||
return slog.LevelWarn, true
|
||
case "error":
|
||
return slog.LevelError, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// mergeLog зливає знімки процесів в один перелік, найсвіжіші зверху.
|
||
//
|
||
// Порядок — за часом, а не за джерелом: людина шукає, ЩО сталося о
|
||
// третій ночі, а не що робив кожен процес окремо. Номер запису
|
||
// розв'язує збіги в межах одного джерела: два рядки в одну наносекунду
|
||
// цілком трапляються, і без цього вони переставлялися б місцями на
|
||
// кожному оновленні сторінки — рух, який виглядає як нові події.
|
||
func mergeLog(snaps []logbuf.Snapshot, minLevel slog.Level, query string, limit int) []logbuf.Record {
|
||
out := make([]logbuf.Record, 0, limit)
|
||
for _, snap := range snaps {
|
||
for _, rec := range snap.Records {
|
||
if !matchLog(rec, minLevel, query) {
|
||
continue
|
||
}
|
||
out = append(out, rec)
|
||
}
|
||
}
|
||
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if !out[i].Time.Equal(out[j].Time) {
|
||
return out[i].Time.After(out[j].Time)
|
||
}
|
||
if out[i].Source != out[j].Source {
|
||
return out[i].Source < out[j].Source
|
||
}
|
||
return out[i].Seq > out[j].Seq
|
||
})
|
||
|
||
// Стеля накладається після злиття й сортування: інакше «останні 500»
|
||
// означало б 500 з кожного процесу, тобто тисячу, і найсвіжіші рядки
|
||
// одного могли б випасти на користь старих рядків другого.
|
||
if len(out) > limit {
|
||
out = out[:limit]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matchLog — чи проходить запис фільтр.
|
||
//
|
||
// Пошук іде і по повідомленню, і по атрибутах: імена хостів, адреси й
|
||
// тексти помилок живуть саме в атрибутах, а шукають зазвичай саме їх.
|
||
func matchLog(rec logbuf.Record, minLevel slog.Level, query string) bool {
|
||
var lv slog.Level
|
||
if err := lv.UnmarshalText([]byte(rec.Level)); err == nil && lv < minLevel {
|
||
return false
|
||
}
|
||
if query == "" {
|
||
return true
|
||
}
|
||
return strings.Contains(strings.ToLower(rec.Msg), query) ||
|
||
strings.Contains(strings.ToLower(rec.Attrs), query)
|
||
}
|