Skip to content

Commit d6d21d0

Browse files
committed
up: support labeled HTTP metrics
1 parent d8cc4ed commit d6d21d0

7 files changed

Lines changed: 145 additions & 13 deletions

File tree

docs/observability.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,37 @@ func onResponse(w *up.Writer, chunk *up.ResponseChunk) {
7474
}
7575
```
7676

77+
For labeled metrics, define tag keys at config time and pass values in the
78+
same order when recording:
79+
80+
```go
81+
var tokenUsage up.MetricID
82+
83+
func init() {
84+
up.RegisterWithConfig("my-filter",
85+
func(h up.ConfigHandle) error {
86+
var err error
87+
tokenUsage, err = h.DefineHistogram("gen_ai.client.token.usage",
88+
"gen_ai.operation.name",
89+
"gen_ai.provider.name",
90+
"gen_ai.token.type",
91+
)
92+
return err
93+
},
94+
onRequest, onResponse,
95+
)
96+
}
97+
98+
func onResponse(w *up.Writer, chunk *up.ResponseChunk) {
99+
if chunk.EndStream {
100+
w.RecordHistogramLabels(tokenUsage, 42, "chat", "openai", "input")
101+
}
102+
}
103+
```
104+
105+
The label values must match the metric's tag-key order and count. Keep labels
106+
low-cardinality; do not use request IDs, user IDs, raw paths, or API keys.
107+
77108
**Counter** — monotonically increasing, never decreases, reset on Envoy restart.
78109
Good for total requests, errors, retries.
79110

up/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ and individual headers via `r.Header(name)` or all headers via `r.AllHeaders()`.
7272
- `w.SetUpstreamOverrideHost(host)` — override the upstream host for this request
7373
- `w.AddSpanTag(key, value)` — annotate the active tracing span
7474
- `w.IncrementCounter(id, delta)` — increment an Envoy counter defined at config time
75+
- `w.IncrementCounterLabels(id, delta, labels...)` — increment a labeled Envoy counter
7576
- `w.IncrementGauge(id, delta)` — increment an Envoy gauge defined at config time
7677
- `w.DecrementGauge(id, delta)` — decrement an Envoy gauge defined at config time
7778
- `w.SetGauge(id, value)` — set an Envoy gauge to an absolute value
7879
- `w.RecordHistogram(id, value)` — record a histogram observation
80+
- `w.RecordHistogramLabels(id, value, labels...)` — record a labeled histogram observation
7981
- `w.GetBufferedBody()` — read the buffered request body (requires `RegisterWithMutableBody`)
8082
- `w.SetBufferedBody(body)` — replace the buffered request body
8183
- `w.HTTPCallout(req, callback)` — make an async HTTP callout to an Envoy cluster

up/async.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,9 @@ type filterStateMutation struct {
168168
// Multiple increments to the same MetricID are applied in order; they are
169169
// not coalesced.
170170
type counterMutation struct {
171-
id MetricID
172-
delta uint64
171+
id MetricID
172+
delta uint64
173+
labels []string
173174
}
174175

175176
// gaugeMutation is a deferred gauge operation (set, increment, or decrement).
@@ -189,8 +190,9 @@ const (
189190

190191
// histogramMutation is a deferred RecordHistogram operation.
191192
type histogramMutation struct {
192-
id MetricID
193-
value uint64
193+
id MetricID
194+
value uint64
195+
labels []string
194196
}
195197

196198
// upstreamOverrideMutation is a deferred SetUpstreamOverrideHost operation.

up/filter.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ func (f *filter) flush(continueReq bool) {
312312
}
313313
f.filterState = f.filterState[:0]
314314
for _, m := range f.counters {
315-
f.handle.IncrementCounterValue(shared.MetricID(m.id), m.delta)
315+
f.handle.IncrementCounterValue(shared.MetricID(m.id), m.delta, m.labels...)
316316
}
317317
f.counters = f.counters[:0]
318318
for _, m := range f.gauges {
@@ -327,7 +327,7 @@ func (f *filter) flush(continueReq bool) {
327327
}
328328
f.gauges = f.gauges[:0]
329329
for _, m := range f.histograms {
330-
f.handle.RecordHistogramValue(shared.MetricID(m.id), m.value)
330+
f.handle.RecordHistogramValue(shared.MetricID(m.id), m.value, m.labels...)
331331
}
332332
f.histograms = f.histograms[:0]
333333
for _, m := range f.dynamicMetadata {

up/metrics_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package up
2+
3+
import (
4+
"testing"
5+
6+
"github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/dio/transit/up/testutil"
10+
)
11+
12+
func TestWriter_MetricLabelsDirectWrite(t *testing.T) {
13+
h := testutil.NewFilterHandle()
14+
w := NewWriter(h)
15+
16+
w.IncrementCounterLabels(11, 2, "chat", "openai")
17+
w.RecordHistogramLabels(12, 42, "chat", "openai", "input")
18+
19+
require.Equal(t, []testutil.MetricRecord{
20+
{ID: shared.MetricID(11), Value: 2, Labels: []string{"chat", "openai"}},
21+
}, h.Counters)
22+
require.Equal(t, []testutil.MetricRecord{
23+
{ID: shared.MetricID(12), Value: 42, Labels: []string{"chat", "openai", "input"}},
24+
}, h.Histograms)
25+
}
26+
27+
func TestWriter_MetricLabelsQueuedFlush(t *testing.T) {
28+
h := testutil.NewFilterHandle()
29+
f := &filter{handle: h}
30+
w := &Writer{f: f}
31+
32+
counterLabels := []string{"chat", "openai"}
33+
histogramLabels := []string{"chat", "openai", "input"}
34+
w.IncrementCounterLabels(21, 3, counterLabels...)
35+
w.RecordHistogramLabels(22, 84, histogramLabels...)
36+
37+
counterLabels[0] = "mutated"
38+
histogramLabels[0] = "mutated"
39+
f.flush(false)
40+
41+
require.Equal(t, []testutil.MetricRecord{
42+
{ID: shared.MetricID(21), Value: 3, Labels: []string{"chat", "openai"}},
43+
}, h.Counters)
44+
require.Equal(t, []testutil.MetricRecord{
45+
{ID: shared.MetricID(22), Value: 84, Labels: []string{"chat", "openai", "input"}},
46+
}, h.Histograms)
47+
}
48+
49+
func TestWriter_MetricNoLabelMethodsRemainUnlabeled(t *testing.T) {
50+
h := testutil.NewFilterHandle()
51+
w := NewWriter(h)
52+
53+
w.IncrementCounter(31, 4)
54+
w.RecordHistogram(32, 168)
55+
56+
require.Equal(t, []testutil.MetricRecord{
57+
{ID: shared.MetricID(31), Value: 4},
58+
}, h.Counters)
59+
require.Equal(t, []testutil.MetricRecord{
60+
{ID: shared.MetricID(32), Value: 168},
61+
}, h.Histograms)
62+
}

up/testutil/testutil.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ type LocalResponse struct {
2020
Detail string
2121
}
2222

23+
// MetricRecord records a metric mutation emitted by a fake filter handle.
24+
type MetricRecord struct {
25+
ID shared.MetricID
26+
Value uint64
27+
Labels []string
28+
}
29+
2330
// FilterHandleOption configures a FakeFilterHandle.
2431
type FilterHandleOption func(*FakeFilterHandle)
2532

@@ -99,6 +106,8 @@ type FakeFilterHandle struct {
99106

100107
// LocalResponses records every SendLocalResponse call.
101108
LocalResponses []LocalResponse
109+
Counters []MetricRecord
110+
Histograms []MetricRecord
102111
ContinuedReq int
103112
ContinueRequestC chan struct{}
104113
LocalResponseC chan struct{}
@@ -399,9 +408,12 @@ func (h *FakeFilterHandle) ResetHttpStream(_ uint64)
399408
func (h *FakeFilterHandle) SetDownstreamWatermarkCallbacks(_ shared.DownstreamWatermarkCallbacks) {}
400409
func (h *FakeFilterHandle) ClearDownstreamWatermarkCallbacks() {}
401410

402-
// -- Metrics (no-ops) --
411+
// -- Metrics --
403412

404-
func (h *FakeFilterHandle) RecordHistogramValue(_ shared.MetricID, _ uint64, _ ...string) shared.MetricsResult {
413+
func (h *FakeFilterHandle) RecordHistogramValue(id shared.MetricID, value uint64, labels ...string) shared.MetricsResult {
414+
h.mu.Lock()
415+
defer h.mu.Unlock()
416+
h.Histograms = append(h.Histograms, MetricRecord{ID: id, Value: value, Labels: append([]string(nil), labels...)})
405417
return shared.MetricsSuccess
406418
}
407419
func (h *FakeFilterHandle) SetGaugeValue(_ shared.MetricID, _ uint64, _ ...string) shared.MetricsResult {
@@ -413,7 +425,10 @@ func (h *FakeFilterHandle) IncrementGaugeValue(_ shared.MetricID, _ uint64, _ ..
413425
func (h *FakeFilterHandle) DecrementGaugeValue(_ shared.MetricID, _ uint64, _ ...string) shared.MetricsResult {
414426
return shared.MetricsSuccess
415427
}
416-
func (h *FakeFilterHandle) IncrementCounterValue(_ shared.MetricID, _ uint64, _ ...string) shared.MetricsResult {
428+
func (h *FakeFilterHandle) IncrementCounterValue(id shared.MetricID, value uint64, labels ...string) shared.MetricsResult {
429+
h.mu.Lock()
430+
defer h.mu.Unlock()
431+
h.Counters = append(h.Counters, MetricRecord{ID: id, Value: value, Labels: append([]string(nil), labels...)})
417432
return shared.MetricsSuccess
418433
}
419434

up/writer.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,17 @@ func (w *Writer) SetResponseBody(data []byte) {
428428

429429
// IncrementCounter queues (or immediately applies) a counter increment.
430430
func (w *Writer) IncrementCounter(id MetricID, delta uint64) {
431+
w.IncrementCounterLabels(id, delta)
432+
}
433+
434+
// IncrementCounterLabels queues (or immediately applies) a counter increment
435+
// with label values matching the tag keys used when the counter was defined.
436+
func (w *Writer) IncrementCounterLabels(id MetricID, delta uint64, labelValues ...string) {
431437
if w.queued() {
432-
w.f.counters = append(w.f.counters, counterMutation{id: id, delta: delta})
438+
w.f.counters = append(w.f.counters, counterMutation{id: id, delta: delta, labels: cloneLabels(labelValues)})
433439
return
434440
}
435-
w.f.handle.IncrementCounterValue(shared.MetricID(id), delta)
441+
w.f.handle.IncrementCounterValue(shared.MetricID(id), delta, labelValues...)
436442
}
437443

438444
// IncrementGauge queues (or immediately applies) a gauge increment.
@@ -464,11 +470,25 @@ func (w *Writer) SetGauge(id MetricID, value uint64) {
464470

465471
// RecordHistogram queues (or immediately applies) a histogram observation.
466472
func (w *Writer) RecordHistogram(id MetricID, value uint64) {
473+
w.RecordHistogramLabels(id, value)
474+
}
475+
476+
// RecordHistogramLabels queues (or immediately applies) a histogram
477+
// observation with label values matching the tag keys used when the histogram
478+
// was defined.
479+
func (w *Writer) RecordHistogramLabels(id MetricID, value uint64, labelValues ...string) {
467480
if w.queued() {
468-
w.f.histograms = append(w.f.histograms, histogramMutation{id: id, value: value})
481+
w.f.histograms = append(w.f.histograms, histogramMutation{id: id, value: value, labels: cloneLabels(labelValues)})
469482
return
470483
}
471-
w.f.handle.RecordHistogramValue(shared.MetricID(id), value)
484+
w.f.handle.RecordHistogramValue(shared.MetricID(id), value, labelValues...)
485+
}
486+
487+
func cloneLabels(labels []string) []string {
488+
if len(labels) == 0 {
489+
return nil
490+
}
491+
return append([]string(nil), labels...)
472492
}
473493

474494
// Go upgrades this request to asynchronous mode. fn runs in a new goroutine

0 commit comments

Comments
 (0)