Skip to content

Commit 468794d

Browse files
committed
litewitness: add Prometheus metrics.
Patterns heavily riffed from the sunlight codebase. Standard go and process stats are collected, and custom stats: these include the number of logs monitored, how large they are, how long requests are taking to service, etc. The prefix levels on the metrics might be a little high; what I'm following here is the sunlight project's distinction between the command's whole metrics, and the witness component. Right now, the only metrics I've introduced to litewitness are on the witness component (and that sounds doubly redundant due to the project name), but that might not remain the case forever. I disable the exposure of the metrics endpoint if the obscurity mode flag is engaged. Some metrics do contain log names, and so it matches the same enumeration concern that obscurity mode describes as its purpose. (Possibly future work: I'm not super satisfied with how sometimes the logging labels and the prometheus labels have parallel evolution, for example in `processAddCheckpointRequest`. But attempting to unify those feels like a potential portal to frameworkitis, so leaving that as a future itch to scratch. Or, perhaps not.)
1 parent 45bee0e commit 468794d

6 files changed

Lines changed: 175 additions & 11 deletions

File tree

cmd/litewitness/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ until one connects successfully. If the connection drops after establishing,
6969
litewitness exits.
7070

7171
-obscurity
72-
enable obscurity mode (disable / and /logz endpoints)
72+
enable obscurity mode (disable / and /logz and /metrics endpoints)
7373

7474
Note that the c2sp.org/tlog-witness protocol is not designed to keep the
7575
supported logs or their tree states secret. Moreover, litewitness has no access
76-
to any secrets (becuase the private key is in ssh-agent) except arguably the IP
76+
to any secrets (because the private key is in ssh-agent) except arguably the IP
7777
addresses of its clients (which are always redacted from /logz). Obscurity mode
78-
disables the `/` and `/logz` endpoints to make it harder to enumerate the logs
78+
disables the `/` and `/logz` and `/metrics` endpoints to make it harder to enumerate the logs
7979
known to the witness.
8080

8181
## witnessctl

cmd/litewitness/litewitness.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import (
2626
"syscall"
2727
"time"
2828

29+
"github.com/prometheus/client_golang/prometheus"
30+
"github.com/prometheus/client_golang/prometheus/collectors"
31+
"github.com/prometheus/client_golang/prometheus/promhttp"
2932
"golang.org/x/crypto/ssh"
3033
"golang.org/x/crypto/ssh/agent"
3134
"golang.org/x/net/http2"
@@ -128,11 +131,23 @@ func main() {
128131
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
129132
defer stop()
130133

134+
metricsRegistry := prometheus.NewRegistry()
135+
metricsRegistry.MustRegister(collectors.NewGoCollector())
136+
metricsRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
137+
litewitnessMetrics := prometheus.WrapRegistererWithPrefix("litewitness_", metricsRegistry)
138+
witnessMetrics := prometheus.WrapRegistererWithPrefix("witness_", litewitnessMetrics)
139+
witnessMetrics.MustRegister(w.Metrics()...)
140+
131141
mux := http.NewServeMux()
132142
mux.Handle("/", w)
133143
if !*obscurityFlag {
134144
mux.Handle("/logz", console)
135145
mux.Handle("/{$}", indexHandler(w))
146+
mux.Handle("/metrics", promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{
147+
ErrorLog: slog.NewLogLogger(slog.Default().Handler().WithAttrs(
148+
[]slog.Attr{slog.String("source", "metrics")},
149+
), slog.LevelWarn),
150+
}))
136151
}
137152

138153
srv := &http.Server{

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
filippo.io/edwards25519 v1.1.0 // indirect
2222
filippo.io/hpke v0.4.0 // indirect
2323
github.com/VividCortex/ewma v1.2.0 // indirect
24+
github.com/beorn7/perks v1.0.1 // indirect
2425
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
2526
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2627
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -34,7 +35,12 @@ require (
3435
github.com/mattn/go-colorable v0.1.13 // indirect
3536
github.com/mattn/go-isatty v0.0.20 // indirect
3637
github.com/mattn/go-runewidth v0.0.17 // indirect
38+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
3739
github.com/ncruces/go-strftime v0.1.9 // indirect
40+
github.com/prometheus/client_golang v1.23.2 // indirect
41+
github.com/prometheus/client_model v0.6.2 // indirect
42+
github.com/prometheus/common v0.66.1 // indirect
43+
github.com/prometheus/procfs v0.16.1 // indirect
3844
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3945
github.com/rivo/uniseg v0.4.7 // indirect
4046
github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7 // indirect
@@ -43,11 +49,13 @@ require (
4349
go.opentelemetry.io/otel v1.39.0 // indirect
4450
go.opentelemetry.io/otel/metric v1.39.0 // indirect
4551
go.opentelemetry.io/otel/trace v1.39.0 // indirect
52+
go.yaml.in/yaml/v2 v2.4.2 // indirect
4653
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
4754
golang.org/x/sys v0.39.0 // indirect
4855
golang.org/x/term v0.38.0 // indirect
4956
golang.org/x/text v0.32.0 // indirect
5057
golang.org/x/tools v0.39.0 // indirect
58+
google.golang.org/protobuf v1.36.10 // indirect
5159
k8s.io/klog/v2 v2.130.1 // indirect
5260
modernc.org/libc v1.65.7 // indirect
5361
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82 h1:ZYps1v
1010
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82/go.mod h1:ac5Gah0LmA0/YD4SHdO2M+WUjScWsc99zrAfJK4QViY=
1111
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
1212
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
13+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
14+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
1315
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
1416
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
1517
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -44,10 +46,20 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
4446
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
4547
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
4648
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
49+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
50+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
4751
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
4852
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
4953
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5054
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
55+
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
56+
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
57+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
58+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
59+
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
60+
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
61+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
62+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
5163
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
5264
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
5365
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -71,6 +83,8 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
7183
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
7284
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
7385
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
86+
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
87+
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
7488
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
7589
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
7690
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
@@ -91,6 +105,9 @@ golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
91105
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
92106
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
93107
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
108+
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
109+
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
110+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
94111
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
95112
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
96113
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=

internal/witness/metrics.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package witness
2+
3+
import (
4+
"reflect"
5+
"time"
6+
7+
"github.com/prometheus/client_golang/prometheus"
8+
)
9+
10+
type metrics struct {
11+
KnownLogs prometheus.Gauge
12+
LogSize *prometheus.GaugeVec
13+
AddCheckpointCount *prometheus.CounterVec
14+
15+
ReqCount *prometheus.CounterVec
16+
ReqInFlight *prometheus.GaugeVec
17+
ReqDuration *prometheus.SummaryVec
18+
}
19+
20+
func initMetrics() metrics {
21+
return metrics{
22+
KnownLogs: prometheus.NewGauge(prometheus.GaugeOpts{
23+
Name: "known_logs",
24+
Help: "Number of logs known to the witness.",
25+
}),
26+
LogSize: prometheus.NewGaugeVec(
27+
prometheus.GaugeOpts{
28+
Name: "log_tree_size",
29+
Help: "Latest cosigned tree size for each log, by origin.",
30+
// n.b. yes "tree size" is the term for number of entries, per language in c2sp.org/tlog-checkpoint .
31+
},
32+
[]string{"origin"},
33+
),
34+
AddCheckpointCount: prometheus.NewCounterVec(
35+
prometheus.CounterOpts{
36+
Name: "add_checkpoint_requests_total",
37+
Help: "Total add-checkpoint requests processed, by log origin.",
38+
},
39+
// error is empty on success;
40+
// origin is empty for requests that fail before the origin is parsed (e.g. malformed input);
41+
// progress is true if the new checkpoint is for a larger tree, false if same, or empty if earlier errors.
42+
[]string{"error", "origin", "progress"},
43+
),
44+
45+
ReqInFlight: prometheus.NewGaugeVec(
46+
prometheus.GaugeOpts{
47+
Name: "http_in_flight_requests",
48+
Help: "HTTP requests currently being served, by endpoint.",
49+
},
50+
[]string{"endpoint"},
51+
),
52+
ReqCount: prometheus.NewCounterVec(
53+
prometheus.CounterOpts{
54+
Name: "http_requests_total",
55+
Help: "HTTP requests served, by endpoint and response code.",
56+
},
57+
[]string{"endpoint", "code"},
58+
),
59+
ReqDuration: prometheus.NewSummaryVec(
60+
prometheus.SummaryOpts{
61+
Name: "http_request_duration_seconds",
62+
Help: "HTTP request latency in seconds, by endpoint.",
63+
Objectives: map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001},
64+
MaxAge: 1 * time.Minute,
65+
AgeBuckets: 6,
66+
},
67+
[]string{"endpoint"},
68+
),
69+
}
70+
}
71+
72+
// Metrics returns all Prometheus collectors owned by this Witness.
73+
// The caller should register them to a [prometheus.Registry].
74+
func (w *Witness) Metrics() []prometheus.Collector {
75+
var collectors []prometheus.Collector
76+
for i := 0; i < reflect.ValueOf(w.metrics).NumField(); i++ {
77+
collectors = append(collectors, reflect.ValueOf(w.metrics).Field(i).Interface().(prometheus.Collector))
78+
}
79+
return collectors
80+
}

internal/witness/witness.go

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ import (
1515
"sync"
1616

1717
"filippo.io/torchwood"
18+
"github.com/prometheus/client_golang/prometheus"
19+
"github.com/prometheus/client_golang/prometheus/promhttp"
1820
"golang.org/x/mod/sumdb/note"
1921
"golang.org/x/mod/sumdb/tlog"
2022
"zombiezen.com/go/sqlite"
2123
"zombiezen.com/go/sqlite/sqlitex"
2224
)
2325

2426
type Witness struct {
25-
s *torchwood.CosignatureSigner
26-
mux *http.ServeMux
27-
log *slog.Logger
27+
s *torchwood.CosignatureSigner
28+
mux *http.ServeMux
29+
log *slog.Logger
30+
metrics metrics
2831

2932
dmMu sync.Mutex
3033
db *sqlite.Conn
@@ -74,12 +77,24 @@ func NewWitness(dbPath, name string, key crypto.Signer, log *slog.Logger) (*Witn
7477
}
7578

7679
w := &Witness{
77-
db: db,
78-
s: s,
79-
log: log,
80-
mux: http.NewServeMux(),
80+
db: db,
81+
s: s,
82+
log: log,
83+
metrics: initMetrics(),
84+
mux: http.NewServeMux(),
8185
}
82-
w.mux.Handle("POST /add-checkpoint", http.HandlerFunc(w.serveAddCheckpoint))
86+
87+
// Give initial values to metrics that are valid on startup
88+
if n, err := w.countKnownLogs(); err == nil {
89+
w.metrics.KnownLogs.Set(float64(n))
90+
}
91+
92+
labels := prometheus.Labels{"endpoint": "add-checkpoint"}
93+
var addCheckpoint http.Handler = http.HandlerFunc(w.serveAddCheckpoint)
94+
addCheckpoint = promhttp.InstrumentHandlerCounter(w.metrics.ReqCount.MustCurryWith(labels), addCheckpoint)
95+
addCheckpoint = promhttp.InstrumentHandlerDuration(w.metrics.ReqDuration.MustCurryWith(labels), addCheckpoint)
96+
addCheckpoint = promhttp.InstrumentHandlerInFlight(w.metrics.ReqInFlight.With(labels), addCheckpoint)
97+
w.mux.Handle("POST /add-checkpoint", addCheckpoint)
8398
return w, nil
8499
}
85100

@@ -148,6 +163,14 @@ func (w *Witness) serveAddCheckpoint(rw http.ResponseWriter, r *http.Request) {
148163
fmt.Fprintf(rw, "%d\n", err.known)
149164
return
150165
}
166+
167+
// Metrics sidequest: ensure known logs counter is synced with reality.
168+
// (At the moment of writing, there's a dearth of more convenient time to notice this;
169+
// when this number changes, it's due to an external poke of the db.)
170+
if n, err := w.countKnownLogs(); err == nil {
171+
w.metrics.KnownLogs.Set(float64(n))
172+
}
173+
151174
switch err {
152175
case errUnknownLog:
153176
http.Error(rw, err.Error(), http.StatusNotFound)
@@ -172,12 +195,15 @@ func (w *Witness) serveAddCheckpoint(rw http.ResponseWriter, r *http.Request) {
172195
}
173196

174197
func (w *Witness) processAddCheckpointRequest(body []byte, bastion string) (cosig []byte, err error) {
198+
labels := prometheus.Labels{"error": "", "origin": "", "progress": ""}
175199
l := w.log.With("request", string(body))
176200
defer func() {
177201
if err != nil {
178202
l = l.With("error", err)
203+
labels["error"] = err.Error()
179204
}
180205
l.Debug("processed add-checkpoint request")
206+
w.metrics.AddCheckpointCount.With(labels).Inc()
181207
}()
182208
body, noteBytes, ok := bytes.Cut(body, []byte("\n\n"))
183209
if !ok {
@@ -212,6 +238,7 @@ func (w *Witness) processAddCheckpointRequest(body []byte, bastion string) (cosi
212238
return nil, errBadRequest
213239
}
214240
l = l.With("origin", origin)
241+
labels["origin"] = origin
215242
bastions, err := w.getBastions(origin)
216243
if err != nil {
217244
return nil, err
@@ -237,6 +264,10 @@ func (w *Witness) processAddCheckpointRequest(body []byte, bastion string) (cosi
237264
return nil, err
238265
}
239266
l = l.With("size", c.N)
267+
labels["progress"] = "false"
268+
if c.N > oldSize {
269+
labels["progress"] = "true"
270+
}
240271
if err := w.checkConsistency(c.Origin, oldSize, c.N, c.Hash, proof); err != nil {
241272
return nil, err
242273
}
@@ -308,9 +339,22 @@ func (w *Witness) persistTreeHead(origin string, oldSize, newSize int64, newHash
308339
}
309340
return &conflictError{knownSize}
310341
}
342+
if err == nil {
343+
w.metrics.LogSize.WithLabelValues(origin).Set(float64(newSize))
344+
}
311345
return err
312346
}
313347

348+
func (w *Witness) countKnownLogs() (int, error) {
349+
var n int
350+
err := w.dbExec("SELECT COUNT(*) AS n FROM log",
351+
func(stmt *sqlite.Stmt) error {
352+
n = int(stmt.GetInt64("n"))
353+
return nil
354+
})
355+
return n, err
356+
}
357+
314358
func (w *Witness) getLog(origin string) (treeSize int64, treeHash tlog.Hash, err error) {
315359
found := false
316360
err = w.dbExec("SELECT tree_size, tree_hash FROM log WHERE origin = ?",

0 commit comments

Comments
 (0)