Skip to content

Commit 5de7bf0

Browse files
authored
feat: add monitor subcommand with Prometheus metrics (#7)
1 parent abbd18b commit 5de7bf0

10 files changed

Lines changed: 805 additions & 69 deletions

File tree

ohttp-probe/README.md

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ go build .
1717

1818
## Commands
1919

20-
The probe has three subcommands. Each has its own `-h` for command-specific
20+
The probe has four subcommands. Each has its own `-h` for command-specific
2121
flags. There is no `-mode` flag — relay vs direct-to-gateway is chosen by
2222
which URL you pass to `-relay-url`.
2323

@@ -84,6 +84,48 @@ Error categories in the summary:
8484
- `decrypt` — content-type mismatches, OHTTP/BHTTP unmarshal, HPKE decapsulate failures
8585
- `inner HTTP` — successful OHTTP round-trip where the decrypted inner response is non-2xx
8686

87+
### `monitor`
88+
89+
Long-running probe loop intended for in-cluster deployment. Fetches the
90+
gateway's key config once at startup, then on every `-delay` runs one probe
91+
per `-target-url` (concurrently, one goroutine per target) and records
92+
latency and outcome to Prometheus metrics on a side server. Latency
93+
observation is the outer round-trip only — the startup key fetch is *not*
94+
included. If keys rotate during the probe's lifetime, probes start failing
95+
(`transport_err`) the same way real clients with stale keys do; restart the
96+
pod to refresh the config.
97+
98+
No log-on-failure by default — alerting is consumer-side, driven off the
99+
counter; `-v` opts into per-iteration stderr lines for local diagnosis.
100+
101+
```sh
102+
./ohttp-probe monitor \
103+
-relay-url https://gateway.example.com/gateway \
104+
-keys-url https://gateway.example.com/ohttp-keys \
105+
-target-url https://api.example.com/health \
106+
-target-url https://other-api.example.com/health \
107+
-delay 30s \
108+
-metrics-addr :9090
109+
```
110+
111+
Metrics on `<metrics-addr>/metrics`:
112+
113+
| Metric | Type | Labels |
114+
|---|---|---|
115+
| `ohttp_probe_duration_seconds` | Histogram | `target` |
116+
| `ohttp_probe_requests_total` | Counter | `target`, `outcome` |
117+
118+
`outcome``{ok, transport_err, decrypt_err}`. Inner non-2xx (e.g. backend
119+
`/health` returns 503) is bucketed as `transport_err` — the probe didn't
120+
return a healthy result. All three outcome series are pre-registered at 0
121+
so absent series don't surprise alerting on a fresh probe.
122+
123+
`/healthz` on the same address responds 200 for liveness probes.
124+
125+
Cluster-side labels (`env`, `region`, `cluster`) are intentionally omitted
126+
from the probe metrics — scrape-side relabeling (e.g. Prometheus
127+
`relabel_configs`) handles those.
128+
87129
## Shared concepts
88130

89131
Three URL flags appear across the subcommands. The relationship between
@@ -99,7 +141,7 @@ client → POST <relay-url> → gateway → GET <target-url>
99141
|---|---|
100142
| `-relay-url` | URL the OHTTP request is POSTed to. Privacy relay, gateway `/gateway` endpoint, or any RFC 9458 server. |
101143
| `-keys-url` | URL of the gateway's OHTTP key config (e.g. `https://<gateway>/ohttp-keys`). |
102-
| `-target-url` | Full URL of the inner target the BHTTP request is forwarded to. Repeatable in `probe`; single in `load`. |
144+
| `-target-url` | Full URL of the inner target the BHTTP request is forwarded to. Repeatable in `probe` and `monitor`; single in `load`. |
103145
| `-gateway-url` | Echo-only: gateway base URL. Probe POSTs to `<gateway-url>/gateway-echo`; keys are read from `<gateway-url>/ohttp-keys`. Repeatable. |
104146

105147
Inner request method is always `GET` — the tool tests OHTTP setup, not
@@ -114,7 +156,7 @@ arbitrary endpoint behavior.
114156
3. **Send**`POST <gateway-url>/gateway-echo` with `Content-Type: message/ohttp-req`
115157
4. **Decrypt** — the encrypted response is decapsulated and compared to the original payload
116158

117-
### Probe (`probe`) and load (`load`)
159+
### Probe (`probe`), load (`load`), monitor (`monitor`)
118160

119161
1. **Fetch keys**`GET <keys-url>` returns the gateway's HPKE public key config
120162
2. **Build inner request** — a BHTTP-encoded `GET` of `-target-url`
@@ -124,6 +166,6 @@ arbitrary endpoint behavior.
124166

125167
## Exit codes
126168

127-
- `0` — all probes passed
128-
- `1` — one or more probes failed
169+
- `0` — all probes passed (or `monitor` shut down cleanly)
170+
- `1` — one or more probes failed (or `monitor`'s metrics server failed to start)
129171
- `2` — flag parse error / missing required argument

ohttp-probe/echo.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"flag"
78
"fmt"
89
"net/http"
910
"os"
1011
"time"
1112
)
1213

13-
// runEcho parses the echo subcommand's flags and probes each -gateway-url
14-
// in sequence. Returns process exit code: 0 if all probed successfully, 1
15-
// if any failed, 2 on flag-parse / argument errors.
14+
// runEcho probes each -gateway-url in sequence. Exits 0 if all succeed,
15+
// 1 if any fail, 2 on flag/arg errors.
1616
func runEcho(ctx context.Context, args []string) int {
1717
fs := flag.NewFlagSet("echo", flag.ContinueOnError)
1818
fs.Usage = func() {
@@ -35,6 +35,9 @@ Flags:
3535
verbose := fs.Bool("v", false, "verbose output")
3636

3737
if err := fs.Parse(args); err != nil {
38+
if errors.Is(err, flag.ErrHelp) {
39+
return 0
40+
}
3841
return 2
3942
}
4043
if len(gateways) == 0 {
@@ -62,9 +65,8 @@ Flags:
6265
return exit
6366
}
6467

65-
// probeEcho fetches the gateway's OHTTP key config, encapsulates payload,
66-
// POSTs to <gateway>/gateway-echo, and asserts the decapsulated response
67-
// matches.
68+
// probeEcho POSTs an HPKE-encrypted payload to <gateway>/gateway-echo and
69+
// asserts the decapsulated response matches the original.
6870
func probeEcho(ctx context.Context, client *http.Client, gateway string, payload []byte, verbose bool) error {
6971
config, err := fetchKeys(ctx, client, joinURL(gateway, pathOHTTPKeys), verbose)
7072
if err != nil {

ohttp-probe/go.mod

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,26 @@ go 1.26.1
55
require (
66
github.com/chris-wood/ohttp-go v0.0.0-20260205154755-776f22a178b8
77
github.com/cloudflare/circl v1.6.3
8+
github.com/prometheus/client_golang v1.20.5
89
github.com/tsenart/vegeta/v12 v12.13.0
910
)
1011

1112
require (
13+
github.com/beorn7/perks v1.0.1 // indirect
14+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1215
github.com/influxdata/tdigest v0.0.1 // indirect
1316
github.com/josharian/intern v1.0.0 // indirect
17+
github.com/klauspost/compress v1.17.9 // indirect
1418
github.com/mailru/easyjson v0.7.7 // indirect
19+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
20+
github.com/prometheus/client_model v0.6.1 // indirect
21+
github.com/prometheus/common v0.55.0 // indirect
22+
github.com/prometheus/procfs v0.15.1 // indirect
1523
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
16-
github.com/stretchr/testify v1.9.0 // indirect
1724
golang.org/x/crypto v0.49.0 // indirect
1825
golang.org/x/net v0.51.0 // indirect
1926
golang.org/x/sync v0.20.0 // indirect
2027
golang.org/x/sys v0.42.0 // indirect
2128
golang.org/x/text v0.35.0 // indirect
29+
google.golang.org/protobuf v1.34.2 // indirect
2230
)

ohttp-probe/go.sum

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
13
github.com/bmizerany/perks v0.0.0-20230307044200-03f9df79da1e h1:mWOqoK5jV13ChKf/aF3plwQ96laasTJgZi4f1aSOu+M=
24
github.com/bmizerany/perks v0.0.0-20230307044200-03f9df79da1e/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q=
35
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
6+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
7+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
48
github.com/chris-wood/ohttp-go v0.0.0-20260205154755-776f22a178b8 h1:M3jyHgFxzohWoxsZB1rTlfEN9qG8xMthhfBFP1kZtqo=
59
github.com/chris-wood/ohttp-go v0.0.0-20260205154755-776f22a178b8/go.mod h1:P/sVWl8F9KHJ1esPj/g1A5h8vfA3Ps9n6JOMNf6TszU=
610
github.com/cloudflare/circl v1.3.3-0.20230418220640-795540340d5c/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
@@ -18,10 +22,24 @@ github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/Z
1822
github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
1923
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
2024
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
25+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
26+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
27+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
28+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
2129
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
2230
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
31+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
32+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
2333
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2434
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35+
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
36+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
37+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
38+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
39+
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
40+
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
41+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
42+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
2543
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+14TwAoKa61q6dX8jtwOf6DH8=
2644
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
2745
github.com/streadway/quantile v0.0.0-20220407130108-4246515d968d h1:X4+kt6zM/OVO6gbJdAfJR60MGPsqCzbtXNnjoGqdfAs=
@@ -78,6 +96,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
7896
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca h1:PupagGYwj8+I4ubCxcmcBRk3VlUWtTg5huQpZR9flmE=
7997
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
8098
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
99+
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
100+
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
81101
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
82102
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
83103
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

ohttp-probe/load.go

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"flag"
78
"fmt"
89
"io"
@@ -15,16 +16,14 @@ import (
1516
vegeta "github.com/tsenart/vegeta/v12/lib"
1617
)
1718

18-
// Error prefixes attached to ohttpRoundTripper failures. The prefixes survive
19-
// http.Client error wrapping so the summary bucketing can scan
20-
// vegeta.Result.Error and keep transport vs decrypt failures distinguishable.
19+
// Error prefixes survive http.Client wrapping so the summary bucketing can
20+
// scan vegeta.Result.Error to distinguish transport from decrypt failures.
2121
const (
2222
errPrefixTransport = "ohttp-transport: "
2323
errPrefixDecrypt = "ohttp-decrypt: "
2424
)
2525

26-
// prefixForKind maps a doOHTTPRoundTrip error kind to its vegeta-error prefix.
27-
// errKindNone returns "" — caller should only invoke this when err != nil.
26+
// prefixForKind maps an error kind to its vegeta-error prefix.
2827
func prefixForKind(kind ohttpErrKind) string {
2928
switch kind {
3029
case errKindTransport:
@@ -51,12 +50,10 @@ type loadSummary struct {
5150
Latencies vegeta.LatencyMetrics
5251
}
5352

54-
// ohttpRoundTripper turns each outbound inner request into an OHTTP-wrapped
55-
// POST: marshal to BHTTP, HPKE-encapsulate with the pre-fetched key config,
56-
// POST the ciphertext to relayURL, and synthesize an *http.Response from the
57-
// decapsulated inner BHTTP response. Transport and decrypt failures surface
58-
// as errors with distinct prefixes; inner non-2xx flows through as a normal
59-
// response so the status code shows up in vegeta.Result.Code.
53+
// ohttpRoundTripper wraps each outbound request in OHTTP, POSTs to relayURL,
54+
// and synthesizes an *http.Response from the decapsulated inner BHTTP. Inner
55+
// non-2xx surfaces in vegeta.Result.Code; transport/decrypt failures get
56+
// prefixed in the error string.
6057
type ohttpRoundTripper struct {
6158
inner *http.Client
6259
relayURL string
@@ -91,9 +88,8 @@ func (t *ohttpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
9188
return synth, nil
9289
}
9390

94-
// runLoad parses the load subcommand's flags and drives the OHTTP probe path
95-
// at constant QPS for a fixed duration. Returns process exit code: 0 on
96-
// success (no failures), 1 if any requests failed, 2 on flag errors.
91+
// runLoad drives the OHTTP path at constant QPS for a fixed duration.
92+
// Exits 0 on no failures, 1 if any failed, 2 on flag/arg errors.
9793
func runLoad(ctx context.Context, args []string) int {
9894
fs := flag.NewFlagSet("load", flag.ContinueOnError)
9995
fs.Usage = func() {
@@ -119,6 +115,9 @@ Flags:
119115
verbose := fs.Bool("v", false, "verbose output")
120116

121117
if err := fs.Parse(args); err != nil {
118+
if errors.Is(err, flag.ErrHelp) {
119+
return 0
120+
}
122121
return 2
123122
}
124123
if *relayURL == "" || *keysURL == "" || *targetURL == "" {
@@ -149,8 +148,8 @@ Flags:
149148
return 0
150149
}
151150

152-
// executeLoad does the actual vegeta run. Split from runLoad so tests can
153-
// drive the load logic directly without going through flag parsing.
151+
// executeLoad runs the vegeta attack. Split from runLoad so tests can drive
152+
// it directly without flag parsing.
154153
func executeLoad(ctx context.Context, client *http.Client, relayURL, keysURL, targetURL string, qps int, duration time.Duration, verbose bool) error {
155154
fmt.Fprintf(os.Stderr, "load: fetching keys from %s\n", keysURL)
156155
config, err := fetchKeys(ctx, client, keysURL, verbose)
@@ -231,8 +230,8 @@ func (c *bucketCounts) classify(res *vegeta.Result) {
231230
case strings.Contains(res.Error, errPrefixDecrypt):
232231
c.decrypt++
233232
case res.Error != "":
234-
// Shouldn't happen — ohttpRoundTripper always prefixes its errors.
235-
// Bucket as transport so unexpected failures aren't silently dropped.
233+
// Catch-all: ohttpRoundTripper should always prefix; bucket
234+
// unexpected unprefixed errors as transport.
236235
c.transport++
237236
case res.Code < 200 || res.Code >= 300:
238237
c.inner++

ohttp-probe/main.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ func main() {
3232
os.Exit(runProbe(rootContext(), args))
3333
case "load":
3434
os.Exit(runLoad(rootContext(), args))
35+
case "monitor":
36+
os.Exit(runMonitor(rootContext(), args))
3537
case "-h", "--help", "help":
3638
usage()
3739
os.Exit(0)
@@ -54,22 +56,20 @@ Commands:
5456
gateway) against one or more inner target URLs.
5557
load Drive the probe path at a fixed QPS for a fixed duration against
5658
a single target.
59+
monitor Long-running probe loop that exposes Prometheus metrics on a
60+
side server. Intended for in-cluster deployment.
5761
5862
Run "ohttp-probe <command> -h" for command-specific flags.
5963
`)
6064
}
6165

62-
// rootContext returns a context that's cancelled by SIGINT. The cancel is
63-
// owned by the process; subcommands rely on the context to interrupt
64-
// in-flight requests on Ctrl-C.
66+
// rootContext returns a context cancelled by SIGINT.
6567
func rootContext() context.Context {
6668
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
6769
return ctx
6870
}
6971

70-
// urlList is a repeatable string flag that accumulates one URL per
71-
// occurrence. Used by probe (-target-url), echo (-gateway-url) — anywhere
72-
// the design takes a list of URLs.
72+
// urlList is a repeatable string flag, accumulating one URL per occurrence.
7373
type urlList []string
7474

7575
func (u *urlList) String() string { return strings.Join(*u, ",") }
@@ -108,15 +108,12 @@ func validateURL(raw string) error {
108108
return nil
109109
}
110110

111-
// joinURL appends path to base, taking care of slashes. Used by echo to
112-
// build /gateway-echo and /ohttp-keys URLs from a gateway base.
111+
// joinURL appends path to base, normalising slashes.
113112
func joinURL(base, path string) string {
114113
u, _ := url.Parse(base)
115114
u = u.JoinPath(path)
116115

117116
return u.String()
118117
}
119118

120-
// trimRightSlash strips trailing "/" from base URLs so subsequent path joins
121-
// produce canonical output.
122119
func trimRightSlash(s string) string { return strings.TrimRight(s, "/") }

0 commit comments

Comments
 (0)