Skip to content

Commit eb58e07

Browse files
authored
feat: HTTP API, Prometheus metrics stage, RPKI metrics (#32)
Add chi-based HTTP API server (--http ADDR flag) with: - GET /metrics — global Prometheus metrics (all stages, Go runtime) - GET /hc — k8s-compatible JSON health check - GET / — HTML dashboard (version, uptime, stages, links) - GET /stage/<name>/ — per-stage JSON summaries - GET /debug/pprof/* — Go pprof (optional, --pprof flag) Add metrics stage: counts BGP messages with Prometheus counters. - messages_total{dir,type} for every (direction, message-type) combination, enabling PromQL aggregations like sum by (dir) or {type="update"} - match{filter} counters for user-defined filter expressions - --output FILE writes final counter values on exit for batch/MRT analysis Add Prometheus metrics to RPKI stage: - counters: messages_total, valid_total, invalid_total, not_found_total - gauges: roa4_prefixes, roa6_prefixes (live ROA cache sizes) - JSON summary at /stage/rpki/ Add MetricPrefix() and HTTPSlug() helper methods on StageBase so stages derive consistent, sanitized metric names and HTTP paths without duplicating logic. Stage paths moved under /stage/<name> to avoid conflicts with global /metrics endpoint.
1 parent c4c34a8 commit eb58e07

14 files changed

Lines changed: 750 additions & 25 deletions

File tree

core/attach.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ func (b *Bgpipe) AttachStages() error {
124124
})
125125
}
126126

127+
if err := b.attachHTTPStages(); err != nil {
128+
return err
129+
}
130+
127131
return nil
128132
}
129133

core/bgpipe.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"net/http"
89
"os"
910
"os/signal"
1011
"slices"
@@ -16,6 +17,7 @@ import (
1617
"github.com/bgpfix/bgpfix/dir"
1718
"github.com/bgpfix/bgpfix/msg"
1819
"github.com/bgpfix/bgpfix/pipe"
20+
"github.com/go-chi/chi/v5"
1921
"github.com/knadh/koanf/v2"
2022
"github.com/rs/zerolog"
2123
"github.com/rs/zerolog/log"
@@ -34,8 +36,11 @@ type Bgpipe struct {
3436
K *koanf.Koanf // global config
3537
Pipe *pipe.Pipe // bgpfix pipe
3638
Stages []*StageBase // pipe stages
39+
HTTP *http.Server // optional shared HTTP server
40+
StartTime time.Time // when the pipeline started
3741

38-
repo map[string]NewStage // maps cmd to new stage func
42+
repo map[string]NewStage // maps cmd to new stage func
43+
httpmux *chi.Mux // shared HTTP routes
3944

4045
wg_lwrite sync.WaitGroup // stages that write to pipe L
4146
wg_lread sync.WaitGroup // stages that read from pipe L
@@ -102,6 +107,14 @@ func (b *Bgpipe) Run() error {
102107
// attach our b.Start
103108
b.Pipe.Options.OnStart(b.onStart)
104109

110+
// record start time and start optional HTTP API
111+
b.StartTime = time.Now()
112+
if err := b.startHTTP(); err != nil {
113+
b.Error().Err(err).Msg("could not start HTTP API")
114+
return err
115+
}
116+
defer b.stopHTTP()
117+
105118
// handle signals
106119
go b.handleSignals()
107120

core/config.go

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import (
77
"slices"
88
"strings"
99

10-
"net/http"
11-
_ "net/http/pprof"
12-
1310
"github.com/bgpfix/bgpfix/filter"
1411
"github.com/knadh/koanf/providers/posflag"
1512
"github.com/rs/zerolog"
@@ -33,11 +30,8 @@ func (b *Bgpipe) Configure() error {
3330
zerolog.SetGlobalLevel(lvl)
3431
}
3532

36-
// pprof?
37-
if v := k.String("pprof"); len(v) > 0 {
38-
go func() {
39-
b.Fatal().Err(http.ListenAndServe(v, nil)).Msg("pprof failed")
40-
}()
33+
if err := b.configureHTTP(); err != nil {
34+
return err
4135
}
4236

4337
// capabilities?
@@ -69,7 +63,8 @@ func (b *Bgpipe) addFlags() {
6963
f.BoolP("version", "v", false, "print detailed version info and quit")
7064
f.BoolP("explain", "n", false, "print the pipeline as configured and quit")
7165
f.StringP("log", "l", "info", "log level (debug/info/warn/error/disabled)")
72-
f.String("pprof", "", "bind pprof to given listen address")
66+
f.String("http", "", "bind HTTP API + Prometheus /metrics to given address")
67+
f.Bool("pprof", false, "enable pprof at /debug/pprof/ (requires --http)")
7368
f.StringSliceP("events", "e", []string{"PARSE", "ESTABLISHED", "EOR"}, "log given events (\"all\" means all events)")
7469
f.StringSliceP("kill", "k", nil, "kill session on any of these events")
7570
f.BoolP("stdin", "i", false, "read JSON from stdin")

core/http.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package core
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"html"
10+
"net"
11+
"net/http"
12+
"net/http/pprof"
13+
"strings"
14+
"time"
15+
16+
vmmetrics "github.com/VictoriaMetrics/metrics"
17+
"github.com/go-chi/chi/v5"
18+
)
19+
20+
func (b *Bgpipe) configureHTTP() error {
21+
addr := strings.TrimSpace(b.K.String("http"))
22+
if addr == "" {
23+
b.HTTP = nil
24+
b.httpmux = nil
25+
return nil
26+
}
27+
28+
m := chi.NewRouter()
29+
b.httpmux = m
30+
b.HTTP = &http.Server{
31+
Addr: addr,
32+
Handler: m,
33+
ReadHeaderTimeout: 5 * time.Second,
34+
}
35+
36+
return nil
37+
}
38+
39+
func (b *Bgpipe) startHTTP() error {
40+
if b.HTTP == nil {
41+
return nil
42+
}
43+
44+
ln, err := net.Listen("tcp", b.HTTP.Addr)
45+
if err != nil {
46+
return fmt.Errorf("could not bind --http %s: %w", b.HTTP.Addr, err)
47+
}
48+
49+
go func() {
50+
err := b.HTTP.Serve(ln)
51+
if err == nil || errors.Is(err, http.ErrServerClosed) {
52+
return
53+
}
54+
b.Cancel(fmt.Errorf("http server failed: %w", err))
55+
}()
56+
57+
b.Info().Str("addr", ln.Addr().String()).Msg("HTTP API listening")
58+
return nil
59+
}
60+
61+
func (b *Bgpipe) stopHTTP() {
62+
if b.HTTP == nil {
63+
return
64+
}
65+
66+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
67+
defer cancel()
68+
if err := b.HTTP.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
69+
b.Warn().Err(err).Msg("HTTP API shutdown error")
70+
}
71+
}
72+
73+
func (b *Bgpipe) attachHTTPStages() error {
74+
if b.httpmux == nil {
75+
return nil
76+
}
77+
78+
m := b.httpmux
79+
used := make(map[string]struct{})
80+
81+
// mount per-stage routes
82+
for _, s := range b.Stages {
83+
if s == nil {
84+
continue
85+
}
86+
87+
r := chi.NewRouter()
88+
if err := s.Stage.RouteHTTP(r); err != nil {
89+
return s.Errorf("could not register HTTP API: %w", err)
90+
}
91+
if len(r.Routes()) == 0 {
92+
continue
93+
}
94+
95+
base := s.HTTPSlug()
96+
if _, exists := used[base]; exists {
97+
base = fmt.Sprintf("%s-%d", base, s.Index)
98+
}
99+
used[base] = struct{}{}
100+
101+
s.HTTPPath = "/stage/" + base
102+
m.Mount(s.HTTPPath, r)
103+
104+
s.Info().Str("http", s.HTTPPath).Msg("stage HTTP API mounted")
105+
}
106+
107+
// GET /metrics — Prometheus
108+
m.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
109+
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
110+
vmmetrics.WritePrometheus(w, true)
111+
})
112+
113+
// GET /hc — k8s health check
114+
m.Get("/hc", func(w http.ResponseWriter, r *http.Request) {
115+
w.Header().Set("Content-Type", "application/json")
116+
json.NewEncoder(w).Encode(map[string]any{
117+
"status": "ok",
118+
"version": b.Version,
119+
"stages": b.StageCount(),
120+
"uptime": time.Since(b.StartTime).Truncate(time.Second).String(),
121+
})
122+
})
123+
124+
// GET / — web dashboard
125+
m.Get("/", b.httpDashboard)
126+
127+
// pprof?
128+
if b.K.Bool("pprof") {
129+
m.HandleFunc("/debug/pprof/", pprof.Index)
130+
m.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
131+
m.HandleFunc("/debug/pprof/profile", pprof.Profile)
132+
m.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
133+
m.HandleFunc("/debug/pprof/trace", pprof.Trace)
134+
b.Info().Msg("pprof enabled at /debug/pprof/")
135+
}
136+
137+
return nil
138+
}
139+
140+
func (b *Bgpipe) httpDashboard(w http.ResponseWriter, r *http.Request) {
141+
uptime := time.Since(b.StartTime).Truncate(time.Second)
142+
143+
// collect stage info
144+
type stageInfo struct {
145+
Index int
146+
Name string
147+
Cmd string
148+
Dir string
149+
HTTPPath string
150+
}
151+
var stages []stageInfo
152+
for _, s := range b.Stages {
153+
if s == nil {
154+
continue
155+
}
156+
stages = append(stages, stageInfo{
157+
Index: s.Index,
158+
Name: s.Name,
159+
Cmd: s.Cmd,
160+
Dir: s.StringLR(),
161+
HTTPPath: s.HTTPPath,
162+
})
163+
}
164+
165+
// render pipeline text (like --explain)
166+
var pipeR, pipeL bytes.Buffer
167+
b.StageDump(1, &pipeR) // DIR_R = 1
168+
b.StageDump(2, &pipeL) // DIR_L = 2
169+
170+
var buf bytes.Buffer
171+
fmt.Fprintf(&buf, `<!DOCTYPE html>
172+
<html lang="en">
173+
<head>
174+
<meta charset="utf-8">
175+
<meta name="viewport" content="width=device-width, initial-scale=1">
176+
<title>bgpipe %s</title>
177+
<style>
178+
:root { --bg: #0d1117; --fg: #c9d1d9; --accent: #58a6ff; --card: #161b22; --border: #30363d; --dim: #8b949e; --green: #3fb950; }
179+
* { margin: 0; padding: 0; box-sizing: border-box; }
180+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--fg); min-height: 100vh; padding: 2rem; }
181+
.container { max-width: 900px; margin: 0 auto; }
182+
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
183+
h1 span { color: var(--accent); }
184+
.subtitle { color: var(--dim); font-size: 0.875rem; margin-bottom: 1.5rem; }
185+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
186+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
187+
.card .label { color: var(--dim); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
188+
.card .value { font-size: 1.25rem; font-weight: 600; margin-top: 0.25rem; }
189+
.card .value.ok { color: var(--green); }
190+
h2 { font-size: 1rem; color: var(--dim); margin-bottom: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
191+
.pipeline { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 0.8125rem; white-space: pre; overflow-x: auto; color: var(--dim); line-height: 1.5; }
192+
table { width: 100%%; border-collapse: collapse; margin-bottom: 1.5rem; }
193+
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
194+
th { color: var(--dim); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
195+
a { color: var(--accent); text-decoration: none; }
196+
a:hover { text-decoration: underline; }
197+
.links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
198+
.links a { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 1rem; font-size: 0.875rem; }
199+
.links a:hover { border-color: var(--accent); text-decoration: none; }
200+
</style>
201+
</head>
202+
<body>
203+
<div class="container">
204+
<h1><span>bgpipe</span> dashboard</h1>
205+
<p class="subtitle">BGP pipeline processor</p>
206+
207+
<div class="grid">
208+
<div class="card"><div class="label">Version</div><div class="value">%s</div></div>
209+
<div class="card"><div class="label">Uptime</div><div class="value">%s</div></div>
210+
<div class="card"><div class="label">Stages</div><div class="value">%d</div></div>
211+
<div class="card"><div class="label">Status</div><div class="value ok">Running</div></div>
212+
</div>
213+
214+
<h2>Pipeline</h2>
215+
<div class="pipeline">`, html.EscapeString(b.Version),
216+
html.EscapeString(b.Version),
217+
html.EscapeString(uptime.String()),
218+
b.StageCount())
219+
220+
fmt.Fprintf(&buf, "--&gt; Messages flowing right --&gt;\n%s\n&lt;-- Messages flowing left &lt;--\n%s",
221+
html.EscapeString(pipeR.String()),
222+
html.EscapeString(pipeL.String()))
223+
224+
fmt.Fprintf(&buf, `</div>
225+
226+
<h2>Stages</h2>
227+
<table>
228+
<tr><th>#</th><th>Name</th><th>Command</th><th>Direction</th><th>HTTP</th></tr>`)
229+
230+
for _, s := range stages {
231+
httpCol := "-"
232+
if s.HTTPPath != "" {
233+
httpCol = fmt.Sprintf(`<a href="%s/">%s/</a>`, s.HTTPPath, s.HTTPPath)
234+
}
235+
fmt.Fprintf(&buf, "\n <tr><td>%d</td><td>%s</td><td>%s</td><td><code>%s</code></td><td>%s</td></tr>",
236+
s.Index,
237+
html.EscapeString(s.Name),
238+
html.EscapeString(s.Cmd),
239+
html.EscapeString(s.Dir),
240+
httpCol)
241+
}
242+
243+
fmt.Fprintf(&buf, `
244+
</table>
245+
246+
<h2>Links</h2>
247+
<div class="links">
248+
<a href="/metrics">Prometheus Metrics</a>
249+
<a href="/hc">Health Check</a>`)
250+
251+
if b.K.Bool("pprof") {
252+
fmt.Fprintf(&buf, `
253+
<a href="/debug/pprof/">pprof</a>`)
254+
}
255+
256+
fmt.Fprintf(&buf, `
257+
<a href="https://bgpipe.org">Documentation</a>
258+
</div>
259+
</div>
260+
</body>
261+
</html>`)
262+
263+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
264+
w.Write(buf.Bytes())
265+
}

0 commit comments

Comments
 (0)