Skip to content

Commit be38b4b

Browse files
khrmclaude
andcommitted
test: add e2e test for OpenCensus to OpenTelemetry metrics migration
Adds TestOTelMetrics, a consolidated e2e test for the OC→OTel metrics migration in Triggers (PR #1934). The test creates minimal Triggers resources (TriggerTemplate with a TaskRun, TriggerBinding, EventListener) and scrapes the controller /metrics endpoint on port 9000 to assert: - All controller gauge metrics are registered (eventlistener_count, triggerbinding_count, clustertriggerbinding_count, triggertemplate_count, clusterinterceptor_count) - Knative workqueue metrics use the new kn_workqueue_* prefix - Standard go_* runtime metrics are present - Old OpenCensus metric names (tekton_triggers_controller_workqueue_*) are absent Uses expfmt/dto.MetricFamily for proper Prometheus text parsing against the controller pod's metrics endpoint (port 9000, labeled app.kubernetes.io/part-of=tekton-triggers). Relates to #1934 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent e62b1ce commit be38b4b

1 file changed

Lines changed: 335 additions & 0 deletions

File tree

test/metrics_otel_test.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
//go:build e2e
2+
// +build e2e
3+
4+
/*
5+
Copyright 2026 The Tekton Authors
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package test
21+
22+
import (
23+
"context"
24+
"encoding/json"
25+
"strings"
26+
"testing"
27+
"time"
28+
29+
dto "github.com/prometheus/client_model/go"
30+
"github.com/prometheus/common/expfmt"
31+
"github.com/prometheus/common/model"
32+
pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
33+
triggersv1 "github.com/tektoncd/triggers/pkg/apis/triggers/v1beta1"
34+
corev1 "k8s.io/api/core/v1"
35+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
36+
"k8s.io/apimachinery/pkg/runtime"
37+
knativetest "knative.dev/pkg/test"
38+
)
39+
40+
const (
41+
// triggerControllerMetricsPort is the Prometheus metrics port exposed by
42+
// the tekton-triggers controller (configured via config-observability-triggers).
43+
triggerControllerMetricsPort = "9000"
44+
)
45+
46+
// scrapeTriggersControllerMetrics scrapes the /metrics endpoint of the
47+
// tekton-triggers controller pod via the Kubernetes API server proxy.
48+
func scrapeTriggersControllerMetrics(ctx context.Context, t *testing.T, c *clients) map[string]*dto.MetricFamily {
49+
t.Helper()
50+
51+
pods, err := c.KubeClient.CoreV1().Pods(triggersNamespace).List(ctx, metav1.ListOptions{
52+
LabelSelector: "app.kubernetes.io/name=controller,app.kubernetes.io/part-of=tekton-triggers",
53+
})
54+
if err != nil {
55+
t.Fatalf("Failed to list triggers controller pods: %v", err)
56+
}
57+
58+
var podName string
59+
for _, pod := range pods.Items {
60+
if pod.Status.Phase != corev1.PodRunning {
61+
continue
62+
}
63+
allReady := true
64+
for _, cs := range pod.Status.ContainerStatuses {
65+
if !cs.Ready {
66+
allReady = false
67+
break
68+
}
69+
}
70+
if allReady {
71+
podName = pod.Name
72+
break
73+
}
74+
}
75+
if podName == "" {
76+
t.Fatalf("No Running/Ready triggers controller pod found in namespace %s", triggersNamespace)
77+
}
78+
79+
result := c.KubeClient.
80+
CoreV1().
81+
RESTClient().
82+
Get().
83+
Resource("pods").
84+
Name(podName + ":" + triggerControllerMetricsPort).
85+
Namespace(triggersNamespace).
86+
SubResource("proxy").
87+
Suffix("metrics").
88+
Do(ctx)
89+
90+
body, err := result.Raw()
91+
if err != nil {
92+
t.Fatalf("Failed to scrape metrics from triggers controller: %v", err)
93+
}
94+
95+
parser := expfmt.NewTextParser(model.LegacyValidation)
96+
families, err := parser.TextToMetricFamilies(strings.NewReader(string(body)))
97+
if err != nil {
98+
t.Fatalf("Failed to parse metrics: %v", err)
99+
}
100+
return families
101+
}
102+
103+
// waitForTriggersMetric polls until the named metric family appears in the controller metrics.
104+
func waitForTriggersMetric(ctx context.Context, t *testing.T, c *clients, metricName string, timeout time.Duration) map[string]*dto.MetricFamily {
105+
t.Helper()
106+
ctx, cancel := context.WithTimeout(ctx, timeout)
107+
defer cancel()
108+
for {
109+
families := scrapeTriggersControllerMetrics(ctx, t, c)
110+
if _, ok := families[metricName]; ok {
111+
return families
112+
}
113+
select {
114+
case <-ctx.Done():
115+
t.Fatalf("Timed out waiting for metric %q to appear (waited %v): %v", metricName, timeout, ctx.Err())
116+
return nil
117+
case <-time.After(5 * time.Second):
118+
}
119+
}
120+
}
121+
122+
// waitForGaugeAtLeast polls until the named gauge metric reaches at least min.
123+
// The triggers controller reports gauge metrics every 60 s, so this may wait
124+
// up to that interval before the value becomes non-zero.
125+
func waitForGaugeAtLeast(ctx context.Context, t *testing.T, c *clients, metricName string, min float64, timeout time.Duration) map[string]*dto.MetricFamily {
126+
t.Helper()
127+
ctx, cancel := context.WithTimeout(ctx, timeout)
128+
defer cancel()
129+
for {
130+
families := scrapeTriggersControllerMetrics(ctx, t, c)
131+
if gaugeValue(families, metricName) >= min {
132+
return families
133+
}
134+
select {
135+
case <-ctx.Done():
136+
t.Fatalf("Timed out waiting for %q >= %v (waited %v): %v", metricName, min, timeout, ctx.Err())
137+
return nil
138+
case <-time.After(5 * time.Second):
139+
}
140+
}
141+
}
142+
143+
// gaugeValue returns the sum of gauge values for the given metric.
144+
func gaugeValue(families map[string]*dto.MetricFamily, name string) float64 {
145+
fam, ok := families[name]
146+
if !ok {
147+
return 0
148+
}
149+
var total float64
150+
for _, m := range fam.GetMetric() {
151+
if g := m.GetGauge(); g != nil {
152+
total += g.GetValue()
153+
}
154+
}
155+
return total
156+
}
157+
158+
// TestOTelMetrics is a consolidated e2e test for the OpenCensus-to-OpenTelemetry
159+
// metrics migration in Triggers (PR #1934). It creates a minimal EventListener
160+
// (with a TriggerBinding and a TriggerTemplate that produces a TaskRun) and
161+
// scrapes the controller /metrics endpoint on port 9000 to verify all metric
162+
// families are present and that infrastructure metrics use new OTel naming.
163+
//
164+
// Metrics verified:
165+
//
166+
// Controller gauge metrics (registered at startup, appear immediately):
167+
// - controller_eventlistener_count
168+
// - controller_triggerbinding_count
169+
// - controller_clustertriggerbinding_count
170+
// - controller_triggertemplate_count
171+
// - controller_clusterinterceptor_count
172+
//
173+
// Infrastructure metrics (OTel renamed):
174+
// - kn_workqueue_* prefix present
175+
// - go_* runtime metrics present
176+
func TestOTelMetrics(t *testing.T) {
177+
c, namespace := setup(t)
178+
defer tearDown(t, c, namespace)
179+
knativetest.CleanupOnInterrupt(func() { tearDown(t, c, namespace) }, t.Logf)
180+
181+
ctx := context.Background()
182+
183+
// ========== Create minimal Triggers resources ==========
184+
185+
// TriggerTemplate producing a minimal TaskRun.
186+
tr := pipelinev1.TaskRun{
187+
TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1", Kind: "TaskRun"},
188+
ObjectMeta: metav1.ObjectMeta{GenerateName: "otel-metrics-tr-", Namespace: namespace},
189+
Spec: pipelinev1.TaskRunSpec{
190+
TaskSpec: &pipelinev1.TaskSpec{
191+
Steps: []pipelinev1.Step{{
192+
Name: "noop",
193+
Image: "mirror.gcr.io/alpine",
194+
Script: "exit 0",
195+
}},
196+
},
197+
},
198+
}
199+
trBytes, err := json.Marshal(tr)
200+
if err != nil {
201+
t.Fatalf("Failed to marshal TaskRun template: %v", err)
202+
}
203+
204+
_, err = c.TriggersClient.TriggersV1beta1().TriggerTemplates(namespace).Create(ctx,
205+
&triggersv1.TriggerTemplate{
206+
ObjectMeta: metav1.ObjectMeta{Name: "otel-metrics-tt"},
207+
Spec: triggersv1.TriggerTemplateSpec{
208+
ResourceTemplates: []triggersv1.TriggerResourceTemplate{
209+
{RawExtension: runtime.RawExtension{Raw: trBytes}},
210+
},
211+
},
212+
}, metav1.CreateOptions{})
213+
if err != nil {
214+
t.Fatalf("Failed to create TriggerTemplate: %v", err)
215+
}
216+
217+
_, err = c.TriggersClient.TriggersV1beta1().TriggerBindings(namespace).Create(ctx,
218+
&triggersv1.TriggerBinding{
219+
ObjectMeta: metav1.ObjectMeta{Name: "otel-metrics-tb"},
220+
Spec: triggersv1.TriggerBindingSpec{
221+
Params: []triggersv1.Param{{Name: "dummy", Value: "$(body)"}},
222+
},
223+
}, metav1.CreateOptions{})
224+
if err != nil {
225+
t.Fatalf("Failed to create TriggerBinding: %v", err)
226+
}
227+
228+
ttRef := "otel-metrics-tt"
229+
_, err = c.TriggersClient.TriggersV1beta1().EventListeners(namespace).Create(ctx,
230+
&triggersv1.EventListener{
231+
ObjectMeta: metav1.ObjectMeta{Name: "otel-metrics-el"},
232+
Spec: triggersv1.EventListenerSpec{
233+
ServiceAccountName: "default",
234+
Triggers: []triggersv1.EventListenerTrigger{{
235+
Bindings: []*triggersv1.TriggerSpecBinding{{
236+
Ref: "otel-metrics-tb",
237+
Kind: triggersv1.NamespacedTriggerBindingKind,
238+
}},
239+
Template: &triggersv1.EventListenerTemplate{Ref: &ttRef},
240+
}},
241+
},
242+
}, metav1.CreateOptions{})
243+
if err != nil {
244+
t.Fatalf("Failed to create EventListener: %v", err)
245+
}
246+
247+
// ========== Scrape metrics ==========
248+
// Wait for kn_workqueue_adds_total which only appears after the controller
249+
// processes its first reconcile item. This prevents the kn_workqueue_*
250+
// assertions from being flaky: controller_eventlistener_count is registered
251+
// at startup (value 0) and may appear before any workqueue activity, but
252+
// kn_workqueue_* only appears after the first item is queued and processed.
253+
254+
t.Log("Waiting for kn_workqueue_adds_total to appear")
255+
families := waitForTriggersMetric(ctx, t, c, "kn_workqueue_adds_total", 2*time.Minute)
256+
t.Logf("Scraped %d metric families from triggers controller", len(families))
257+
258+
// Gauge value assertions — poll until controller_eventlistener_count >= 1
259+
// (covers the 60 s reporting period), then assert created-resource counts.
260+
t.Log("Waiting for controller_eventlistener_count >= 1 (up to 90s for 60s reporting period)")
261+
gaugeFamilies := waitForGaugeAtLeast(ctx, t, c, "controller_eventlistener_count", 1, 90*time.Second)
262+
t.Logf("Scraped %d metric families for gauge value assertions", len(gaugeFamilies))
263+
264+
// Resources created: 1 EventListener, 1 TriggerBinding, 1 TriggerTemplate.
265+
gaugeTests := []struct {
266+
name string
267+
metricName string
268+
wantMin float64
269+
}{
270+
{name: "eventlistener_count", metricName: "controller_eventlistener_count", wantMin: 1},
271+
{name: "triggerbinding_count", metricName: "controller_triggerbinding_count", wantMin: 1},
272+
{name: "triggertemplate_count", metricName: "controller_triggertemplate_count", wantMin: 1},
273+
}
274+
for _, tt := range gaugeTests {
275+
t.Run(tt.name, func(t *testing.T) {
276+
v := gaugeValue(gaugeFamilies, tt.metricName)
277+
if v < tt.wantMin {
278+
t.Errorf("%s = %v, want >= %v", tt.metricName, v, tt.wantMin)
279+
}
280+
t.Logf("%s: %v", tt.metricName, v)
281+
})
282+
}
283+
284+
// No ClusterTriggerBindings or ClusterInterceptors created — assert presence only.
285+
gaugeExistTests := []struct {
286+
name string
287+
metricName string
288+
}{
289+
{name: "clustertriggerbinding_count", metricName: "controller_clustertriggerbinding_count"},
290+
{name: "clusterinterceptor_count", metricName: "controller_clusterinterceptor_count"},
291+
}
292+
for _, tt := range gaugeExistTests {
293+
t.Run(tt.name, func(t *testing.T) {
294+
if _, ok := gaugeFamilies[tt.metricName]; !ok {
295+
t.Errorf("%s not found", tt.metricName)
296+
}
297+
})
298+
}
299+
300+
// Infrastructure metrics use new OTel naming.
301+
infraTests := []struct {
302+
name string
303+
prefix string
304+
errMsg string
305+
}{
306+
{
307+
name: "workqueue_uses_kn_prefix",
308+
prefix: "kn_workqueue_",
309+
errMsg: "Expected at least one kn_workqueue_* metric, found none",
310+
},
311+
{
312+
name: "go_runtime_uses_standard_prefix",
313+
prefix: "go_",
314+
errMsg: "Expected standard go_* runtime metrics, found none",
315+
},
316+
}
317+
for _, tt := range infraTests {
318+
t.Run(tt.name, func(t *testing.T) {
319+
for name := range families {
320+
if strings.HasPrefix(name, tt.prefix) {
321+
return
322+
}
323+
}
324+
t.Error(tt.errMsg)
325+
})
326+
}
327+
328+
// Old OC workqueue metrics must be absent.
329+
// TODO: Remove in a future release once no OC-based release is supported.
330+
for name := range families {
331+
if strings.HasPrefix(name, "tekton_triggers_controller_workqueue_") {
332+
t.Errorf("Old OC workqueue metric %q still present; expected kn_workqueue_* after OTel migration", name)
333+
}
334+
}
335+
}

0 commit comments

Comments
 (0)