Skip to content

Commit 402786c

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 Pipelines-as-Code (PR #2567). The test scrapes two pods: Controller (app.kubernetes.io/name=controller): - Asserts http_client_* metrics from knative k8s client OTel instrumentation - Asserts go_* runtime metrics - Checks PAC application metrics (pipelines_as_code_*) are absent/present - Asserts old OC metric names are absent Watcher (app.kubernetes.io/name=watcher): - Asserts kn_workqueue_* metrics (watcher uses knative reconciler) - Asserts go_* runtime metrics Verified locally with PAC controller and watcher deployed to kind via ko. Relates to #2567 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent e1a2f48 commit 402786c

1 file changed

Lines changed: 321 additions & 0 deletions

File tree

test/metrics_otel_test.go

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
//go:build e2e
2+
// +build e2e
3+
4+
// Copyright 2026 The Tekton Authors
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
package test
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"strings"
24+
"testing"
25+
"time"
26+
27+
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype"
28+
tgitea "github.com/openshift-pipelines/pipelines-as-code/test/pkg/gitea"
29+
30+
dto "github.com/prometheus/client_model/go"
31+
"github.com/prometheus/common/expfmt"
32+
"github.com/prometheus/common/model"
33+
corev1 "k8s.io/api/core/v1"
34+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
35+
"k8s.io/client-go/kubernetes"
36+
"k8s.io/client-go/tools/clientcmd"
37+
)
38+
39+
const (
40+
pacNamespace = "pipelines-as-code"
41+
pacMetricsPort = "9090"
42+
pacControllerSelector = "app.kubernetes.io/name=controller,app.kubernetes.io/part-of=pipelines-as-code"
43+
pacWatcherSelector = "app.kubernetes.io/name=watcher,app.kubernetes.io/part-of=pipelines-as-code"
44+
)
45+
46+
// pacKubeClient builds a kubernetes client from the default kubeconfig.
47+
func pacKubeClient(t *testing.T) kubernetes.Interface {
48+
t.Helper()
49+
rules := clientcmd.NewDefaultClientConfigLoadingRules()
50+
cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}).ClientConfig()
51+
if err != nil {
52+
t.Fatalf("Failed to build kubeconfig: %v", err)
53+
}
54+
return kubernetes.NewForConfigOrDie(cfg)
55+
}
56+
57+
// scrapePACPodMetrics scrapes /metrics from the first Running/Ready pod
58+
// matching labelSelector via the Kubernetes API proxy. Returns an error
59+
// so callers can retry on transient failures without aborting the test.
60+
func scrapePACPodMetrics(ctx context.Context, kubeClient kubernetes.Interface, labelSelector string) (map[string]*dto.MetricFamily, error) {
61+
pods, err := kubeClient.CoreV1().Pods(pacNamespace).List(ctx, metav1.ListOptions{
62+
LabelSelector: labelSelector,
63+
})
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
var podName string
69+
for _, pod := range pods.Items {
70+
if pod.Status.Phase != corev1.PodRunning {
71+
continue
72+
}
73+
podReady := len(pod.Status.ContainerStatuses) > 0
74+
for _, cs := range pod.Status.ContainerStatuses {
75+
if !cs.Ready {
76+
podReady = false
77+
break
78+
}
79+
}
80+
if podReady {
81+
podName = pod.Name
82+
break
83+
}
84+
}
85+
if podName == "" {
86+
return nil, fmt.Errorf("no Running/Ready PAC pod found for selector %q in namespace %s", labelSelector, pacNamespace)
87+
}
88+
89+
result := kubeClient.CoreV1().RESTClient().Get().
90+
Resource("pods").
91+
Name(podName + ":" + pacMetricsPort).
92+
Namespace(pacNamespace).
93+
SubResource("proxy").
94+
Suffix("metrics").
95+
Do(ctx)
96+
97+
body, err := result.Raw()
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
parser := expfmt.NewTextParser(model.LegacyValidation)
103+
families, err := parser.TextToMetricFamilies(strings.NewReader(string(body)))
104+
if err != nil {
105+
return nil, err
106+
}
107+
return families, nil
108+
}
109+
110+
// waitForControllerMetrics polls the PAC controller pod until the named
111+
// metric appears. Transient errors are logged and retried until timeout.
112+
func waitForControllerMetrics(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface, metricName string, timeout time.Duration) map[string]*dto.MetricFamily {
113+
t.Helper()
114+
return waitForPACPodMetric(ctx, t, kubeClient, pacControllerSelector, metricName, timeout)
115+
}
116+
117+
// waitForWatcherMetrics polls the PAC watcher pod until the named metric
118+
// appears. Transient errors are logged and retried until timeout.
119+
func waitForWatcherMetrics(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface, metricName string, timeout time.Duration) map[string]*dto.MetricFamily {
120+
t.Helper()
121+
return waitForPACPodMetric(ctx, t, kubeClient, pacWatcherSelector, metricName, timeout)
122+
}
123+
124+
// waitForPACPodMetric is the shared polling implementation used by
125+
// waitForControllerMetrics and waitForWatcherMetrics.
126+
func waitForPACPodMetric(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface, labelSelector, metricName string, timeout time.Duration) map[string]*dto.MetricFamily {
127+
t.Helper()
128+
ctx, cancel := context.WithTimeout(ctx, timeout)
129+
defer cancel()
130+
for {
131+
families, err := scrapePACPodMetrics(ctx, kubeClient, labelSelector)
132+
if err == nil {
133+
if _, ok := families[metricName]; ok {
134+
return families
135+
}
136+
} else {
137+
t.Logf("Retrying metrics scrape (%s): %v", labelSelector, err)
138+
}
139+
select {
140+
case <-ctx.Done():
141+
t.Fatalf("Timed out waiting for metric %q (selector=%s, waited %v): %v", metricName, labelSelector, timeout, ctx.Err())
142+
return nil
143+
case <-time.After(5 * time.Second):
144+
}
145+
}
146+
}
147+
148+
// counterValue returns the sum of all counter values for the given metric name.
149+
func counterValue(families map[string]*dto.MetricFamily, name string) float64 {
150+
fam, ok := families[name]
151+
if !ok {
152+
return 0
153+
}
154+
var total float64
155+
for _, m := range fam.GetMetric() {
156+
if c := m.GetCounter(); c != nil {
157+
total += c.GetValue()
158+
}
159+
}
160+
return total
161+
}
162+
163+
// TestOthersOTelMetricsController verifies that the PAC controller pod exposes the
164+
// expected OTel metric families after the OC→OTel migration (PR #2567):
165+
// - http_client_* and kn_k8s_client_* (knative k8s client OTel instrumentation)
166+
// - go_* runtime metrics
167+
// - PAC application metrics logged (appear only after first PipelineRun)
168+
// - Old OpenCensus metric names absent
169+
func TestOthersOTelMetricsController(t *testing.T) {
170+
ctx := context.Background()
171+
kubeClient := pacKubeClient(t)
172+
173+
t.Log("Waiting for PAC controller metrics (http_client_request_duration_seconds)")
174+
families := waitForControllerMetrics(ctx, t, kubeClient, "http_client_request_duration_seconds", 2*time.Minute)
175+
t.Logf("Scraped %d metric families from PAC controller", len(families))
176+
177+
tests := []struct {
178+
name string
179+
prefix string
180+
errMsg string
181+
}{
182+
{
183+
name: "http_client_prefix",
184+
prefix: "http_client_",
185+
errMsg: "Expected at least one http_client_* metric from knative k8s client instrumentation, found none",
186+
},
187+
{
188+
name: "kn_k8s_client_prefix",
189+
prefix: "kn_k8s_client_",
190+
errMsg: "Expected at least one kn_k8s_client_* metric from knative k8s client instrumentation, found none",
191+
},
192+
{
193+
name: "go_runtime_prefix",
194+
prefix: "go_",
195+
errMsg: "Expected standard go_* runtime metrics, found none",
196+
},
197+
}
198+
for _, tt := range tests {
199+
t.Run(tt.name, func(t *testing.T) {
200+
for name := range families {
201+
if strings.HasPrefix(name, tt.prefix) {
202+
return
203+
}
204+
}
205+
t.Error(tt.errMsg)
206+
})
207+
}
208+
209+
// Old OC metric names must be absent.
210+
// TODO: Remove in a future release once no OC-based release is supported.
211+
for name := range families {
212+
for _, prefix := range []string{"pipelines_as_code/", "tekton_pipelines_as_code_"} {
213+
if strings.HasPrefix(name, prefix) {
214+
t.Errorf("Old OC metric %q still present; expected removal after OTel migration", name)
215+
}
216+
}
217+
}
218+
219+
// PAC application metrics — counters only increment after the first PipelineRun
220+
// is processed; log presence without failing on a fresh install.
221+
appMetrics := []string{
222+
"pipelines_as_code_pipelinerun_count_total",
223+
"pipelines_as_code_pipelinerun_duration_seconds_sum_total",
224+
"pipelines_as_code_running_pipelineruns_count",
225+
"pipelines_as_code_git_provider_api_request_count_total",
226+
}
227+
for _, m := range appMetrics {
228+
if _, ok := families[m]; ok {
229+
t.Logf("%s found", m)
230+
} else {
231+
t.Logf("%s not yet present (no PipelineRuns processed yet)", m)
232+
}
233+
}
234+
}
235+
236+
// TestOthersOTelMetricsWatcher verifies that the PAC watcher pod exposes the
237+
// expected OTel metric families after the OC→OTel migration (PR #2567):
238+
// - kn_workqueue_* (knative reconciler workqueue)
239+
// - http_client_* and kn_k8s_client_* (knative k8s client OTel instrumentation)
240+
// - go_* runtime metrics
241+
func TestOthersOTelMetricsWatcher(t *testing.T) {
242+
ctx := context.Background()
243+
kubeClient := pacKubeClient(t)
244+
245+
t.Log("Waiting for PAC watcher metrics (go_goroutines)")
246+
families := waitForWatcherMetrics(ctx, t, kubeClient, "go_goroutines", 2*time.Minute)
247+
t.Logf("Scraped %d metric families from PAC watcher", len(families))
248+
249+
tests := []struct {
250+
name string
251+
prefix string
252+
errMsg string
253+
}{
254+
{
255+
name: "kn_workqueue_prefix",
256+
prefix: "kn_workqueue_",
257+
errMsg: "Expected at least one kn_workqueue_* metric on the PAC watcher, found none",
258+
},
259+
{
260+
name: "http_client_prefix",
261+
prefix: "http_client_",
262+
errMsg: "Expected at least one http_client_* metric on the PAC watcher, found none",
263+
},
264+
{
265+
name: "kn_k8s_client_prefix",
266+
prefix: "kn_k8s_client_",
267+
errMsg: "Expected at least one kn_k8s_client_* metric on the PAC watcher, found none",
268+
},
269+
{
270+
name: "go_runtime_prefix",
271+
prefix: "go_",
272+
errMsg: "Expected standard go_* runtime metrics on PAC watcher, found none",
273+
},
274+
}
275+
for _, tt := range tests {
276+
t.Run(tt.name, func(t *testing.T) {
277+
for name := range families {
278+
if strings.HasPrefix(name, tt.prefix) {
279+
return
280+
}
281+
}
282+
t.Error(tt.errMsg)
283+
})
284+
}
285+
}
286+
287+
// TestOthersOTelMetricsAfterPACRun triggers a real PAC PipelineRun via Gitea
288+
// and asserts that pipelines_as_code_pipelinerun_count_total increments by
289+
// exactly 1. The test skips automatically if Gitea is not configured
290+
// (TEST_GITEA_API_URL and related env vars are unset).
291+
func TestOthersOTelMetricsAfterPACRun(t *testing.T) {
292+
ctx := context.Background()
293+
kubeClient := pacKubeClient(t)
294+
295+
// Baseline before the PAC run to compute an exact delta.
296+
baseline, err := scrapePACPodMetrics(ctx, kubeClient, pacControllerSelector)
297+
if err != nil {
298+
t.Skipf("PAC controller metrics not reachable, skipping: %v", err)
299+
}
300+
baseCount := counterValue(baseline, "pipelines_as_code_pipelinerun_count_total")
301+
302+
// TestPR sets up Gitea, creates a repo, pushes .tekton/pr.yaml, creates a
303+
// PR, waits for PAC to process it. It calls t.Skip if Gitea is not
304+
// configured via TEST_GITEA_API_URL / TEST_GITEA_PASSWORD env vars.
305+
topts := &tgitea.TestOpts{
306+
Regexp: successRegexp,
307+
TargetEvent: triggertype.PullRequest.String(),
308+
YAMLFiles: map[string]string{".tekton/pr.yaml": "testdata/pipelinerun.yaml"},
309+
CheckForStatus: "success",
310+
}
311+
_, f := tgitea.TestPR(t, topts)
312+
defer f()
313+
314+
// Assert exact delta == 1 (one PipelineRun processed by PAC).
315+
after := waitForControllerMetrics(ctx, t, kubeClient, "pipelines_as_code_pipelinerun_count_total", 2*time.Minute)
316+
delta := counterValue(after, "pipelines_as_code_pipelinerun_count_total") - baseCount
317+
if delta != 1 {
318+
t.Errorf("pipelinerun_count_total delta = %v, want exactly 1", delta)
319+
}
320+
t.Logf("pipelines_as_code_pipelinerun_count_total delta: %v", delta)
321+
}

0 commit comments

Comments
 (0)