Skip to content

Commit 4711818

Browse files
authored
Demonstrate using metrics (#45)
This example records the e2e latency of a route from when a request enters the filter to when it exits. Depends on #46 --------- Signed-off-by: William Zhang <wtzhang23@gmail.com> Signed-off-by: William Zhang <williamzhang@roblox.com>
1 parent 66853ba commit 4711818

6 files changed

Lines changed: 263 additions & 9 deletions

File tree

integration/envoy.yaml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
admin:
2+
address:
3+
socket_address: { address: 127.0.0.1, port_value: 9901 }
14
static_resources:
25
listeners:
36
- address:
@@ -16,7 +19,8 @@ static_resources:
1619
domains:
1720
- "*"
1821
routes:
19-
- match:
22+
- name: catch_all
23+
match:
2024
prefix: "/"
2125
route:
2226
cluster: httpbin
@@ -36,7 +40,20 @@ static_resources:
3640
dynamic_module_config:
3741
name: rust_module
3842
filter_name: passthrough
39-
- name: dynamic_modules/conditional_deply
43+
- name: dynamic_modules/metrics
44+
typed_config:
45+
# https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/dynamic_modules/v3/dynamic_modules.proto#envoy-v3-api-msg-extensions-dynamic-modules-v3-dynamicmoduleconfig
46+
"@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter
47+
dynamic_module_config:
48+
name: rust_module
49+
filter_name: metrics
50+
filter_config:
51+
"@type": "type.googleapis.com/google.protobuf.StringValue"
52+
value: |
53+
{
54+
"version": "v1.0.0"
55+
}
56+
- name: dynamic_modules/conditional_delay
4057
typed_config:
4158
# https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/dynamic_modules/v3/dynamic_modules.proto#envoy-v3-api-msg-extensions-dynamic-modules-v3-dynamicmoduleconfig
4259
"@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter

integration/go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ go 1.24.0
44

55
require (
66
github.com/mccutchen/go-httpbin/v2 v2.18.0
7-
github.com/stretchr/testify v1.10.0
7+
github.com/prometheus/client_model v0.6.2
8+
github.com/prometheus/common v0.66.1
9+
github.com/stretchr/testify v1.11.1
810
)
911

1012
require (
1113
github.com/davecgh/go-spew v1.1.1 // indirect
14+
github.com/kr/pretty v0.3.1 // indirect
15+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
1216
github.com/pmezard/go-difflib v1.0.0 // indirect
17+
go.yaml.in/yaml/v2 v2.4.2 // indirect
18+
google.golang.org/protobuf v1.36.8 // indirect
1319
gopkg.in/yaml.v3 v3.0.1 // indirect
1420
)

integration/go.sum

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
1+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
12
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
23
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
5+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
6+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
7+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
8+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
9+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
310
github.com/mccutchen/go-httpbin/v2 v2.18.0 h1:WFU1OELp3nHYLvXct/3nrGVIgxU0X+RJfDPYRBnvicY=
411
github.com/mccutchen/go-httpbin/v2 v2.18.0/go.mod h1:GBy5I7XwZ4ZLhT3hcq39I4ikwN9x4QUt6EAxNiR8Jus=
12+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
13+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
14+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
515
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
616
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7-
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
8-
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
9-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
17+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
18+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
19+
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
20+
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
21+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
22+
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
23+
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
24+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
25+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
26+
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
27+
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
28+
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
29+
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
1030
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
31+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
32+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
1133
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1234
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

integration/main_test.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bytes"
45
"cmp"
56
"encoding/json"
67
"io"
@@ -13,6 +14,8 @@ import (
1314
"time"
1415

1516
"github.com/mccutchen/go-httpbin/v2/httpbin"
17+
io_prometheus_client "github.com/prometheus/client_model/go"
18+
"github.com/prometheus/common/expfmt"
1619
"github.com/stretchr/testify/require"
1720
)
1821

@@ -35,8 +38,8 @@ func TestIntegration(t *testing.T) {
3538
// Create a directory for the access logs to be written to.
3639
accessLogsDir := cwd + "/access_logs"
3740
require.NoError(t, os.RemoveAll(accessLogsDir))
38-
require.NoError(t, os.Mkdir(accessLogsDir, 0700))
39-
require.NoError(t, os.Chmod(accessLogsDir, 0777))
41+
require.NoError(t, os.Mkdir(accessLogsDir, 0o700))
42+
require.NoError(t, os.Chmod(accessLogsDir, 0o777))
4043

4144
cmd := exec.Command(
4245
"docker",
@@ -328,4 +331,77 @@ func TestIntegration(t *testing.T) {
328331
})
329332
}
330333
})
334+
335+
t.Run("http_metrics", func(t *testing.T) {
336+
// Send test request
337+
require.Eventually(t, func() bool {
338+
req, err := http.NewRequest("GET", "http://localhost:1062/uuid", nil)
339+
require.NoError(t, err)
340+
341+
resp, err := http.DefaultClient.Do(req)
342+
if err != nil {
343+
t.Logf("Envoy not ready yet: %v", err)
344+
return false
345+
}
346+
defer func() {
347+
require.NoError(t, resp.Body.Close())
348+
}()
349+
body, err := io.ReadAll(resp.Body)
350+
if err != nil {
351+
t.Logf("Envoy not ready yet: %v", err)
352+
return false
353+
}
354+
t.Logf("response: status=%d body=%s", resp.StatusCode, string(body))
355+
return resp.StatusCode == 200
356+
}, 30*time.Second, 200*time.Millisecond)
357+
358+
// Check the metrics endpoint
359+
lastStatsOutput := ""
360+
t.Cleanup(func() {
361+
t.Logf("last stats output:\n%s", lastStatsOutput)
362+
})
363+
require.Eventually(t, func() bool {
364+
req, err := http.NewRequest("GET", "http://localhost:9901/stats/prometheus", nil)
365+
require.NoError(t, err)
366+
367+
resp, err := http.DefaultClient.Do(req)
368+
require.NoError(t, err)
369+
defer func() {
370+
require.NoError(t, resp.Body.Close())
371+
}()
372+
373+
// Check that the route_latency_ms metric is present
374+
body, err := io.ReadAll(resp.Body)
375+
require.NoError(t, err)
376+
lastStatsOutput = string(body)
377+
378+
decoder := expfmt.NewDecoder(bytes.NewReader(body), expfmt.NewFormat(expfmt.TypeTextPlain))
379+
for {
380+
var metricFamily io_prometheus_client.MetricFamily
381+
err := decoder.Decode(&metricFamily)
382+
if err == io.EOF {
383+
break
384+
}
385+
require.NoError(t, err)
386+
387+
if metricFamily.GetName() != "route_latency_ms" {
388+
continue
389+
}
390+
for _, metric := range metricFamily.GetMetric() {
391+
hist := metric.GetHistogram()
392+
require.NotNil(t, hist)
393+
labels := make(map[string]string)
394+
for _, label := range metric.GetLabel() {
395+
labels[label.GetName()] = label.GetValue()
396+
}
397+
require.Equal(t, map[string]string{"version": "v1.0.0", "route_name": "catch_all"}, labels)
398+
if hist.GetSampleCount() > 0 {
399+
return true
400+
}
401+
}
402+
}
403+
t.Logf("route_latency_ms metric not found or no samples yet")
404+
return false
405+
}, 5*time.Second, 200*time.Millisecond)
406+
})
331407
}

rust/src/http_metrics.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use std::time::Instant;
2+
3+
use envoy_proxy_dynamic_modules_rust_sdk::*;
4+
5+
/// This implements the [`envoy_proxy_dynamic_modules_rust_sdk::HttpFilterConfig`] trait.
6+
///
7+
/// The trait corresponds to a Envoy filter chain configuration.
8+
pub struct FilterConfig {
9+
config: Config,
10+
route_latency: EnvoyHistogramVecId,
11+
}
12+
13+
#[derive(serde::Deserialize)]
14+
pub struct Config {
15+
version: String,
16+
}
17+
18+
impl FilterConfig {
19+
/// This is the constructor for the [`FilterConfig`].
20+
///
21+
/// filter_config is the filter config from the Envoy config here:
22+
/// https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/dynamic_modules/v3/dynamic_modules.proto#envoy-v3-api-msg-extensions-dynamic-modules-v3-dynamicmoduleconfig
23+
pub fn new<EC: EnvoyHttpFilterConfig>(
24+
filter_config: &str,
25+
envoy_filter_config: &mut EC,
26+
) -> Option<Self> {
27+
Some(Self {
28+
config: serde_json::from_str::<Config>(filter_config).ok()?,
29+
// Handles to metrics such as counters, gauges, and histograms are allocated at filter config creation time. These handles
30+
// are opaque ids that can be used to record statistics during the lifecycle of the filter. These handles last until the
31+
// filter config is destroyed.
32+
route_latency: envoy_filter_config
33+
.define_histogram_vec("route_latency_ms", &["version", "route_name"])
34+
.unwrap(),
35+
})
36+
}
37+
}
38+
39+
impl<EHF: EnvoyHttpFilter> HttpFilterConfig<EHF> for FilterConfig {
40+
/// This is called for each new HTTP filter.
41+
fn new_http_filter(&mut self, _envoy: &mut EHF) -> Box<dyn HttpFilter<EHF>> {
42+
Box::new(Filter {
43+
version: self.config.version.clone(),
44+
start_time: None,
45+
route_name: None,
46+
route_latency: self.route_latency,
47+
})
48+
}
49+
}
50+
51+
/// This implements the [`envoy_proxy_dynamic_modules_rust_sdk::HttpFilter`] trait.
52+
///
53+
/// This is a metrics filter that records per-route metrics of the request.
54+
pub struct Filter {
55+
version: String,
56+
start_time: Option<Instant>,
57+
route_latency: EnvoyHistogramVecId,
58+
route_name: Option<String>,
59+
}
60+
61+
impl Filter {
62+
/// This records the latency of the request. Note that it uses the handle to the histogram vector that was allocated at filter config creation time.
63+
fn record_latency<EHF: EnvoyHttpFilter>(&mut self, envoy_filter: &mut EHF) {
64+
let Some(start_time) = self.start_time else {
65+
return;
66+
};
67+
let Some(route_name) = self.route_name.take() else {
68+
return;
69+
};
70+
envoy_filter
71+
.record_histogram_value_vec(
72+
self.route_latency,
73+
&[&self.version, &route_name],
74+
start_time.elapsed().as_millis() as u64,
75+
)
76+
.unwrap();
77+
}
78+
}
79+
80+
/// This implements the [`envoy_proxy_dynamic_modules_rust_sdk::HttpFilter`] trait.
81+
impl<EHF: EnvoyHttpFilter> HttpFilter<EHF> for Filter {
82+
fn on_request_headers(
83+
&mut self,
84+
envoy_filter: &mut EHF,
85+
_end_of_stream: bool,
86+
) -> abi::envoy_dynamic_module_type_on_http_filter_request_headers_status {
87+
self.start_time = Some(Instant::now());
88+
self.route_name = Some(
89+
String::from_utf8(
90+
envoy_filter
91+
.get_attribute_string(abi::envoy_dynamic_module_type_attribute_id::XdsRouteName)
92+
.unwrap_or_default()
93+
.as_slice()
94+
.to_vec(),
95+
)
96+
.unwrap(),
97+
);
98+
abi::envoy_dynamic_module_type_on_http_filter_request_headers_status::Continue
99+
}
100+
101+
fn on_response_headers(
102+
&mut self,
103+
envoy_filter: &mut EHF,
104+
end_of_stream: bool,
105+
) -> abi::envoy_dynamic_module_type_on_http_filter_response_headers_status {
106+
if end_of_stream {
107+
self.record_latency(envoy_filter);
108+
}
109+
abi::envoy_dynamic_module_type_on_http_filter_response_headers_status::Continue
110+
}
111+
112+
fn on_response_body(
113+
&mut self,
114+
envoy_filter: &mut EHF,
115+
end_of_stream: bool,
116+
) -> abi::envoy_dynamic_module_type_on_http_filter_response_body_status {
117+
if end_of_stream {
118+
self.record_latency(envoy_filter);
119+
}
120+
abi::envoy_dynamic_module_type_on_http_filter_response_body_status::Continue
121+
}
122+
123+
fn on_request_trailers(
124+
&mut self,
125+
envoy_filter: &mut EHF,
126+
) -> abi::envoy_dynamic_module_type_on_http_filter_request_trailers_status {
127+
self.record_latency(envoy_filter);
128+
abi::envoy_dynamic_module_type_on_http_filter_request_trailers_status::Continue
129+
}
130+
}

rust/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use envoy_proxy_dynamic_modules_rust_sdk::*;
22

33
mod http_access_logger;
44
mod http_header_mutation;
5+
mod http_metrics;
56
mod http_passthrough;
67
mod http_random_auth;
78
mod http_zero_copy_regex_waf;
@@ -29,7 +30,7 @@ fn init() -> bool {
2930
///
3031
/// Returns None if the filter name or config is determined to be invalid by each filter's `new` function.
3132
fn new_http_filter_config_fn<EC: EnvoyHttpFilterConfig, EHF: EnvoyHttpFilter>(
32-
_envoy_filter_config: &mut EC,
33+
envoy_filter_config: &mut EC,
3334
filter_name: &str,
3435
filter_config: &[u8],
3536
) -> Option<Box<dyn HttpFilterConfig<EHF>>> {
@@ -43,6 +44,8 @@ fn new_http_filter_config_fn<EC: EnvoyHttpFilterConfig, EHF: EnvoyHttpFilter>(
4344
.map(|config| Box::new(config) as Box<dyn HttpFilterConfig<EHF>>),
4445
"header_mutation" => http_header_mutation::FilterConfig::new(filter_config)
4546
.map(|config| Box::new(config) as Box<dyn HttpFilterConfig<EHF>>),
47+
"metrics" => http_metrics::FilterConfig::new(filter_config, envoy_filter_config)
48+
.map(|config| Box::new(config) as Box<dyn HttpFilterConfig<EHF>>),
4649
_ => panic!("Unknown filter name: {filter_name}"),
4750
}
4851
}

0 commit comments

Comments
 (0)