16 KiB
Panel Profiler / Probes — TODO
Замена overview-статусов на латентностный профилировщик каждого именованного code-path в селекторе. Цель — уметь ответить на вопросы вида "ASN resolve через ipmeta или через HTTP API — что быстрее?" за секунду в UI.
Не путать с текущим Overview/Dashboard (там state + counters). Здесь — count, throughput, p50/p95/p99 latency, err-rate, sparkline, compare mode.
Архитектура
1. Ядро — internal/tools/probe/
Новый пакет. Один файл: probe.go.
package probe
type Probe struct {
Name string // "routing.decide", "asn.ipmeta", "iptables.restore"
Group string // "routing" | "ipmeta" | "firewall" | "dns" | "sni" | "feeds" | ...
ring [1024]sample // фиксированный размер — предсказуемая память
head atomic.Uint64 // wrapping index
count atomic.Uint64
errs atomic.Uint64
lastNs atomic.Int64 // last activity timestamp (unix ns)
}
type sample struct {
ns uint32 // latency в наносекундах (uint32 покрывает до ~4 сек — хватает)
at int64 // wall clock ns
err bool
}
type Handle struct {
p *Probe
start int64 // monotonic ns
}
func (p *Probe) Start() Handle {
return Handle{p: p, start: nanotime()}
}
func (h Handle) End(err error) {
dur := nanotime() - h.start
if dur < 0 { dur = 0 }
if dur > (1<<32)-1 { dur = (1<<32) - 1 }
idx := h.p.head.Add(1) - 1
h.p.ring[idx%1024] = sample{ns: uint32(dur), at: time.Now().UnixNano(), err: err != nil}
h.p.count.Add(1)
if err != nil { h.p.errs.Add(1) }
h.p.lastNs.Store(time.Now().UnixNano())
}
nanotime()через//go:linkname runtime.nanotime— быстрееtime.Since(нет обёртки).- Ring размер 1024 — компромисс: покрывает bursts, память ~10 KB на пробу × ~50 проб = 500 KB (пренебрежимо).
- Overhead: 2 nanotime + 1 atomic.Add + 1 memory-write ≈ 40-60 ns на вызов. Не помешает в hot-path.
Global registry:
var registry sync.Map // map[string]*Probe
func Register(name, group string) *Probe { ... } // идемпотентно; повторный Register(name) возвращает существующий
func All() []*Probe { ... }
Percentiles on demand — не streaming, а сортировка снапшота ring'а на GET (O(n log n) при n=1024 = быстрее любой сетевой ходки):
type Snapshot struct {
Name string
Group string
Count uint64
Errs uint64
LastNs int64
P50, P95, P99 uint32 // ns
Mean, Max uint32
Recent [32]sample // последние 32 сэмпла для sparkline
}
func (p *Probe) Snapshot() Snapshot { ... }
2. Инструментация — где расставить пробы
Каждая проба = 2 строки: h := probe.Start(p_xxx); defer h.End(err).
Готовые пробы (создаются в init() соответствующего пакета через probe.Register):
routing (internal/services/routing/):
routing.decide—routeFor()/routeForDst()вdecision.go:117routing.compile—compileRouteRulesGen()вrule_engine.go:79routing.rebuild_shadow—rebuildShadowRuleTable()вrule_shadow.go:21routing.apply—awgApplyRoutingOS()вrouting_linux.go:77routing.expand_entries—expandEntries()вexpand.go:40
ipmeta (internal/tools/ipmeta/):
ipmeta.lookup—Lookup(ip)вipmeta.go:469ipmeta.cidrs_for_asn—CIDRsForASN()вipmeta.go:372ipmeta.cidrs_for_country—CIDRsForCountry()вipmeta.go:395ipmeta.rebuild—rebuild()вipmeta.go:253ipmeta.read_asn_file—readAsnFile()вipmeta.go:674ipmeta.load_cache—loadCache()вipmeta.go(найти линию)
dns (internal/services/routing/dnsproxy_linux.go):
dns.proxy_hop— весь query pipeline (входящий UDP → upstream → респонс → traceAppend)dns.upstream_call— только HTTP/UDP к upstream'уdns.cache_hit/dns.cache_miss— счётчики (без latency; замерять через отдельныеprobe.Register("dns.cache_hit"...)+.End(nil)в fast-path)dns.pihole_check—piholeWouldBlock()вызов
sni (internal/services/routing/snisniff_linux.go):
sni.parse— парсинг ClientHellosni.decide— routeForDst из sniffer'аsni.pihole_verify— piholeWouldBlock из sni-пути
firewall (internal/services/routing/firewall_per_tunnel_linux.go, firewall_linux.go):
firewall.ipset_add_async— от ipsetAddAsync до реального примененияfirewall.ipset_restore—ipset restorebatchedfirewall.iptables_run— каждый shell-out iptables/nftfirewall.per_tunnel_rebuild— prepopulatePerTunnelIpsets
awg / tunnel (internal/services/awgroute/, internal/services/routing/tunnel/):
awg.client_up— client_up вызовawg.uapi_config_apply— UAPI SetConfigawg.iface_stats— per-tick sysstats sample
feeds (internal/app/app_feeds.go):
feeds.fetch_asn_tsv/feeds.fetch_geoip_dat/feeds.fetch_geosite_dat/feeds.fetch_list— per-kindfeeds.parse_asn_tsv— parse-only latency (после fetch)feeds.write_artefact— disk write
pihole (internal/services/pihole/):
pihole.poll— периодический poll containerspihole.docker_exec— shell-out в docker exec
trace (internal/services/routing/trace.go):
trace.append— traceAppend путь (полезно понять сколько tracing стоит)trace.lookup_matched_asn— enrichment
monitor / dashboard (internal/services/monitor/):
monitor.dashboard_snapshot— snapshot генерацияmonitor.perf_tick— perf sample
Всего ~35-45 проб. Инструментация — 1-2 дня механически.
3. HTTP API
Файл: internal/server/probes_api.go.
GET /api/probes → все пробы, снапшот всего
GET /api/probes/:name → одна проба + последние 32 сэмпла
GET /api/probes/:name/samples?n=N → сырые сэмплы (для custom-графиков)
GET /api/probes/stream (SSE) → live-стрим агрегатов, 1 tick/sec
POST /api/probes/reset → занулить ring (для чистого измерения)
POST /api/probes/reset/:name → одну пробу
Схема JSON:
{
"probes": [
{
"name": "asn.ipmeta",
"group": "ipmeta",
"count": 1234,
"errs": 0,
"last_ns": 1783969552000000000,
"p50_ns": 850,
"p95_ns": 3200,
"p99_ns": 12000,
"mean_ns": 1100,
"max_ns": 45000,
"recent": [{"ns": 900, "at": 1783..., "err": false}, ...]
},
...
]
}
SSE стрим шлёт тот же payload 1 раз в секунду с diff'ом от предыдущего (только изменившиеся пробы) — экономит трафик.
Регистрация в server.go:
m.HandleFunc("GET /api/probes", etagBuffered(s.probesList))
m.HandleFunc("GET /api/probes/{name}", s.probeDetail)
m.HandleFunc("GET /api/probes/{name}/samples", s.probeSamples)
m.HandleFunc("GET /api/probes/stream", s.probesStream)
m.HandleFunc("POST /api/probes/reset", s.probesResetAll)
m.HandleFunc("POST /api/probes/reset/{name}", s.probesResetOne)
4. Frontend — новая вкладка "Probes" (или "Profiler")
Новая top-level вкладка в главном layout'е (рядом с Overview / Routing / DNS / Diag / Logs). Файлы:
frontend/src/features/probes/index.tsx— контейнер, poll/api/probes+ SSEfrontend/src/features/probes/ProbesTable.tsx— основная таблицаfrontend/src/features/probes/ProbeRow.tsx— expandable row с sparklinefrontend/src/features/probes/CompareView.tsx— compare mode (2-4 пробы наложенные)frontend/src/features/probes/Sparkline.tsx— минимальный inline SVG-график
Таблица (default view):
| ☐ | Name | Group | Count/s | p50 | p95 | p99 | Err % | Last | Sparkline |
|---|
- Сортировка по любой колонке (SortableHead из shadcn).
- Search input (filter by name substring).
- Group filter (chips: routing / ipmeta / firewall / ...).
- Checkbox выделения (max 4) → кнопка "Compare (N)" сверху → CompareView.
- Rate:
count/sсчитается как(now.count - prev.count) / dtиз diff SSE. - Sparkline: последние 32 сэмпла в мини-SVG (~60×20 px). Красный dot если err.
Expandable row (клик):
- Полный список последних 32 сэмплов таблицей (ns, wall-time, err).
- Кнопка "Reset probe" — POST /api/probes/reset/:name.
- Ссылка "View 1024" — GET /api/probes/:name/samples?n=1024, показывает scatter-plot (recharts или самописный canvas).
CompareView (модалка или отдельный роут #probes/compare):
- 2-4 выбранные пробы.
- Наложенные sparkline'ы (разные цвета).
- Таблица дельт:
probe A p50 vs probe B p50 → Δ = +42%. - "Winner" бейдж по каждой колонке (кто ниже — тот выигрывает).
- Кнопка "Copy comparison as markdown" — генерит markdown-таблицу для вставки в issue/notes.
i18n keys (в frontend/src/i18n/ru.json и en.json):
probes.title, probes.desc, probes.tabTitle,
probes.col.name, probes.col.group, probes.col.rate, probes.col.p50, probes.col.p95, probes.col.p99, probes.col.errPct, probes.col.last,
probes.action.compare, probes.action.reset, probes.action.viewSamples,
probes.compare.title, probes.compare.winnerBadge, probes.compare.exportMd,
probes.filter.hotOnly, probes.filter.search
5. Overhead-бюджет и наблюдения
- Пробы всегда включены (не gate'ится через флаг) — 40-60 ns × 1000 rps = 60 µs/sec суммарно. Незаметно.
- Если позже окажется, что одна проба стоит слишком дорого (например,
dns.proxy_hopвызывается 10k rps) — глобальный флагprobe.SetEnabled(false)черезPOST /api/probes/enabledв API.probe.Startтогда возвращает пустой Handle с no-op End. - Ring выделяется один раз при Register — GC не давит.
- Reset обнуляет
count/errs/head, очищает ring — для чистого повторного бенчмарка (например, "тап Reset, курлани 100 раз, посмотри p50").
6. Порядок работы
Ветка: feat/probes.
-
Ядро (0.5 дня):
internal/tools/probe/probe.go— struct, ring, Register/All/Snapshot.internal/tools/probe/nanotime_linkname.go— go:linkname на runtime.nanotime.internal/tools/probe/probe_test.go— unit-тесты на percentile расчёт (feed 1000 known values, assert p50/p95/p99 в пределах ±1%).
-
API (0.5 дня):
internal/server/probes_api.go— 6 хэндлеров.- Регистрация роутов в
server.go. - SSE-стрим по подписке (перерасчёт снапшота 1 раз/сек).
-
Инструментация — routing/ipmeta первым (1 день):
- Только группы
routingиipmeta— это ядро, эффектив с точки зрения "asn resolve vs api". - Проверить в панели что данные капают, sparkline рисуется.
- Только группы
-
Frontend MVP (1-2 дня):
ProbesTableсо всеми колонками, sortable, searchable.Sparkline(простой SVG polyline).- Row expand с 32-sample таблицей.
- SSE poll → live update.
-
Compare mode (1 день):
- Checkbox выделение.
CompareViewкомпонент с наложенными sparkline'ами и таблицей дельт.- Winner badge.
- Copy-as-markdown.
-
Остальная инструментация (1 день):
- Группы
firewall,dns,sni,feeds,pihole,trace,monitor,awg,tunnel. - По одной группе за коммит.
- Группы
-
Полировка (1 день):
- Group-фильтры (chips).
- "Hot only" фильтр (top-10 по count/s).
- Reset-кнопки.
View 1024scatter-plot для одной пробы.- Persist expand-state и column-sort в hash/localStorage.
7. Non-goals (не делать в этом рано)
- Per-goroutine attribution через pprof labels — оверхед и сложнее визуализация. Отдельная фича если понадобится.
- Отправка метрик в Prometheus/Grafana — SSE в панели покрывает 90% use-case; экспортер добавим если станут нужны исторические графики за дни.
- Flame graphs / trace timelines — есть
go tool pprofдля этого, дублировать в панели не имеет смысла. - Гистограммы (bucketed histograms типа Prometheus) — сортировка 1024 сэмплов быстрее и точнее, буденьт если понадобится > 10k samples/window.
8. Проверка на реальном примере "ASN resolve: ipmeta vs HTTP"
После шага 3:
- В
expandOne(rule_engine.go) обернуть вызовipmeta.CIDRsForASNвprobe.Start("asn.ipmeta"). - Написать одноразовый бенч-эндпоинт (или use debug/pprof
?benchmark=asn.httpparam): дёрнутьhttps://iptoasn.com/data/ip2asn-v4.tsv.gz→ парсить → лукап. Обернуть вprobe.Start("asn.iptoasn_http"). (Или ложный тест:probe.Start("asn.http")+time.Sleep(200*time.Millisecond)для проверки инфраструктуры.) - Дёрнуть каждый эндпоинт по 200 раз.
- Panel → Probes → checkbox обе → Compare → видим
p50: 850ns vs 180ms→ HTTP на 5 порядков медленнее.
Файлы, которые тронем
internal/tools/probe/probe.go(новый)internal/tools/probe/nanotime_linkname.go(новый)internal/tools/probe/probe_test.go(новый)internal/server/probes_api.go(новый)internal/server/server.go(регистрация роутов)- ~30-45 файлов инструментации (по 2 строки каждый)
frontend/src/features/probes/index.tsx(новый)frontend/src/features/probes/ProbesTable.tsx(новый)frontend/src/features/probes/ProbeRow.tsx(новый)frontend/src/features/probes/CompareView.tsx(новый)frontend/src/features/probes/Sparkline.tsx(новый)frontend/src/App.tsx— добавить вкладку Probes в NAVfrontend/src/i18n/{ru,en}.json— новые ключиfrontend/src/types/api.ts— типы ProbeSnapshot / ProbeSamples
Оценка
- MVP (ядро + API + инструментация routing/ipmeta + таблица + sparkline): 3 дня
-
- Compare mode: +1 день
-
- Полная инструментация всех подсистем: +1 день
-
- Полировка (фильтры, scatter-plot, reset UX): +1 день
Итого 5-6 дней от старта до "готово в проде". MVP уже отвечает на исходный вопрос про ASN resolve — после 3 дней можно смотреть цифры.