Skip to content

Commit 50ff8b4

Browse files
committed
feat: add instrumentation in e2e for GH API calls
- Introduced a mechanism to collect GitHub API call metrics from controller logs during E2E tests. - Added logic to parse structured log entries for API calls at the end of each test run. - Stored the collected data, including operation, duration, and status code, in a structured JSON report. - Updated the CI workflow to archive the generated reports for analysis and monitoring. Signed-off-by: Chmouel Boudjnah <[email protected]>
1 parent bacf698 commit 50ff8b4

File tree

4 files changed

+390
-1
lines changed

4 files changed

+390
-1
lines changed

hack/gh-workflow-ci.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# Helper script for GitHub Actions CI, used from e2e tests.
44
set -exufo pipefail
55

6+
export PAC_API_INSTRUMENTATION_DIR=/tmp/api-instrumentation
7+
68
create_pac_github_app_secret() {
79
# Read from environment variables instead of arguments
810
local app_private_key="${PAC_GITHUB_PRIVATE_KEY}"
@@ -93,7 +95,7 @@ run_e2e_tests() {
9395

9496
output_logs() {
9597
if command -v "snazy" >/dev/null 2>&1; then
96-
snazy --skip-line-regexp="^(Reconcile (succeeded|error)|Updating webhook)" /tmp/logs/pac-pods.log
98+
snazy --extra-fields --skip-line-regexp="^(Reconcile (succeeded|error)|Updating webhook)" /tmp/logs/pac-pods.log
9799
else
98100
# snazy for the poors
99101
python -c "import sys,json,datetime; [print(f'• { (lambda t: datetime.datetime.fromisoformat(t.rstrip(\"Z\")).strftime(\"%H:%M:%S\") if isinstance(t,str) else datetime.datetime.fromtimestamp(t).strftime(\"%H:%M:%S\"))(json.loads(l.strip())[\"ts\"] )} {json.loads(l.strip()).get(\"msg\",\"\")}') if l.strip().startswith('{') else print(l.strip()) for l in sys.stdin]" \
@@ -127,6 +129,11 @@ collect_logs() {
127129
kubectl -n ${ns} get events >/tmp/logs/ns/${ns}/events
128130
done
129131

132+
if [[ -d ${PAC_API_INSTRUMENTATION_DIR} && -n "$(ls -A ${PAC_API_INSTRUMENTATION_DIR})" ]]; then
133+
echo "Copying API instrumentation logs from ${PAC_API_INSTRUMENTATION_DIR}"
134+
cp -a ${PAC_API_INSTRUMENTATION_DIR} /tmp/logs/$(basename ${PAC_API_INSTRUMENTATION_DIR})
135+
fi
136+
130137
for url in "${test_gitea_smee_url}" "${github_ghe_smee_url}"; do
131138
find /tmp/logs -type f -exec grep -l "${url}" {} \; | xargs -r sed -i "s|${url}|SMEE_URL|g"
132139
done

test/pkg/github/instrumentation.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
corev1 "k8s.io/api/core/v1"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
)
17+
18+
// InstrumentationAPICall represents a single GitHub API call with its metadata.
19+
type InstrumentationAPICall struct {
20+
Operation string `json:"operation"`
21+
DurationMs int64 `json:"duration_ms"`
22+
URLPath string `json:"url_path"`
23+
RateLimitRemaining string `json:"rate_limit_remaining"`
24+
StatusCode int `json:"status_code"`
25+
Provider string `json:"provider"`
26+
Repo string `json:"repo"`
27+
}
28+
29+
// TestResult represents the complete test result with GitHub API calls.
30+
type TestResult struct {
31+
TestName string `json:"test_name"`
32+
Timestamp string `json:"timestamp"`
33+
Controller string `json:"controller"`
34+
PRNumber int `json:"pr_number"`
35+
SHA string `json:"sha"`
36+
TargetNamespace string `json:"target_namespace"`
37+
GitHubAPICalls []InstrumentationAPICall `json:"github_api_calls"`
38+
TotalCalls int `json:"total_calls"`
39+
OAuth2MarkerLine int `json:"oauth2_marker_line"`
40+
}
41+
42+
func (g *PRTest) collectGitHubAPICalls(ctx context.Context, _ *testing.T) {
43+
numLines := int64(100)
44+
controllerName := "controller"
45+
if g.SecondController {
46+
controllerName = "ghe-controller"
47+
}
48+
labelselector := fmt.Sprintf("app.kubernetes.io/name=%s", controllerName)
49+
containerName := "pac-controller"
50+
51+
g.Logger.Infof("Attempting to collect GitHub API calls from controller: %s", controllerName)
52+
53+
// Get controller logs using the existing pattern
54+
logs, err := g.Cnx.Clients.Kube.CoreV1().Pods("").List(ctx, metav1.ListOptions{
55+
LabelSelector: labelselector,
56+
})
57+
if err != nil {
58+
g.Logger.Warnf("Failed to get controller pods with label selector '%s': %v", labelselector, err)
59+
return
60+
}
61+
62+
g.Logger.Infof("Found %d pods with label selector '%s'", len(logs.Items), labelselector)
63+
64+
if len(logs.Items) == 0 {
65+
g.Logger.Warnf("No controller pods found with label selector: %s", labelselector)
66+
// Try to list all pods to see what's available
67+
allPods, err := g.Cnx.Clients.Kube.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
68+
if err != nil {
69+
g.Logger.Warnf("Failed to list all pods: %v", err)
70+
return
71+
}
72+
g.Logger.Infof("Available pods: %d total", len(allPods.Items))
73+
for _, pod := range allPods.Items {
74+
if strings.Contains(pod.Name, "controller") {
75+
g.Logger.Infof(" - %s (namespace: %s, labels: %v)", pod.Name, pod.Namespace, pod.Labels)
76+
}
77+
}
78+
return
79+
}
80+
81+
// Log details about the found pods
82+
for i, pod := range logs.Items {
83+
g.Logger.Infof("Pod %d: %s (namespace: %s, status: %s)", i+1, pod.Name, pod.Namespace, pod.Status.Phase)
84+
}
85+
86+
// Get logs from the first controller pod
87+
selectedPod := logs.Items[0]
88+
g.Logger.Infof("Attempting to get logs from pod: %s in namespace: %s", selectedPod.Name, selectedPod.Namespace)
89+
90+
// Try to get logs with more specific options
91+
podLogs, err := g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).GetLogs(selectedPod.Name, &corev1.PodLogOptions{
92+
Container: containerName,
93+
TailLines: &numLines,
94+
Previous: false, // Don't get previous container logs
95+
}).Do(ctx).Raw()
96+
if err != nil {
97+
g.Logger.Warnf("Failed to get controller logs from pod %s: %v", selectedPod.Name, err)
98+
99+
// Try to get pod details to understand the issue
100+
podDetails, err := g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).Get(ctx, selectedPod.Name, metav1.GetOptions{})
101+
if err != nil {
102+
g.Logger.Warnf("Failed to get pod details for %s: %v", selectedPod.Name, err)
103+
} else {
104+
g.Logger.Infof("Pod %s status: %s, containers: %v", selectedPod.Name, podDetails.Status.Phase, podDetails.Status.ContainerStatuses)
105+
106+
// Check if the container exists and is ready
107+
for _, container := range podDetails.Status.ContainerStatuses {
108+
if container.Name == containerName {
109+
g.Logger.Infof("Container %s ready: %v, restart count: %d", container.Name, container.Ready, container.RestartCount)
110+
}
111+
}
112+
}
113+
114+
// Try alternative approach - get logs without specifying container
115+
g.Logger.Infof("Trying to get logs without specifying container name...")
116+
podLogs, err = g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).GetLogs(selectedPod.Name, &corev1.PodLogOptions{
117+
TailLines: &numLines,
118+
}).Do(ctx).Raw()
119+
if err != nil {
120+
g.Logger.Warnf("Failed to get logs without container specification: %v", err)
121+
return
122+
}
123+
g.Logger.Infof("Successfully got logs without container specification")
124+
}
125+
126+
logContent := string(podLogs)
127+
logLines := strings.Split(logContent, "\n")
128+
g.Logger.Infof("Collected %d lines from controller logs", len(logLines))
129+
130+
// Find the last OAuth2 initialization marker
131+
oauth2Marker := "github-app: initialized OAuth2 client"
132+
lastOAuth2Index := -1
133+
for i := len(logLines) - 1; i >= 0; i-- {
134+
if strings.Contains(logLines[i], oauth2Marker) {
135+
lastOAuth2Index = i
136+
break
137+
}
138+
}
139+
140+
if lastOAuth2Index == -1 {
141+
g.Logger.Infof("OAuth2 initialization marker not found in the last %d lines", numLines)
142+
return
143+
}
144+
145+
g.Logger.Infof("Found OAuth2 initialization marker at line %d (from end)", len(logLines)-lastOAuth2Index)
146+
147+
// Extract GitHub API calls only from the last OAuth2 marker onwards
148+
apiCallRegex := regexp.MustCompile(`GitHub API call completed.*`)
149+
var apiCalls []string
150+
151+
for i := lastOAuth2Index; i < len(logLines); i++ {
152+
line := logLines[i]
153+
if apiCallRegex.MatchString(line) {
154+
apiCalls = append(apiCalls, strings.TrimSpace(line))
155+
}
156+
}
157+
158+
g.Logger.Infof("Found %d GitHub API calls after the last OAuth2 initialization", len(apiCalls))
159+
160+
// Log each API call for debugging
161+
for i, match := range apiCalls {
162+
g.Logger.Infof("API Call %d: %s", i+1, match)
163+
}
164+
165+
// Check if we should output to file
166+
outputDir := os.Getenv("PAC_API_INSTRUMENTATION_DIR")
167+
if outputDir != "" {
168+
g.outputTestResultToFile(outputDir, apiCalls, lastOAuth2Index, len(logLines))
169+
}
170+
}
171+
172+
// sanitizeFilename converts a test name to a safe filename.
173+
func sanitizeFilename(name string) string {
174+
// Replace spaces and special characters with underscores
175+
re := regexp.MustCompile(`[^a-zA-Z0-9._-]`)
176+
sanitized := re.ReplaceAllString(name, "_")
177+
// Convert to lowercase
178+
return strings.ToLower(sanitized)
179+
}
180+
181+
// parseAPICallLog parses a GitHub API call log line and extracts structured data.
182+
func parseAPICallLog(logLine string) *InstrumentationAPICall {
183+
// The log line contains a JSON object, so we need to extract and parse it
184+
// Example: "API Call 1: {\"level\":\"debug\",\"ts\":\"2025-08-05T16:12:17.508Z\",...}"
185+
186+
// Find the JSON part after "API Call X: "
187+
jsonStart := strings.Index(logLine, "{")
188+
if jsonStart == -1 {
189+
return nil
190+
}
191+
192+
jsonStr := logLine[jsonStart:]
193+
194+
// Parse the JSON object
195+
var logEntry map[string]interface{}
196+
if err := json.Unmarshal([]byte(jsonStr), &logEntry); err != nil {
197+
return nil
198+
}
199+
200+
call := &InstrumentationAPICall{}
201+
202+
// Extract fields from the JSON
203+
if operation, ok := logEntry["operation"].(string); ok {
204+
call.Operation = operation
205+
}
206+
207+
if durationMs, ok := logEntry["duration_ms"].(float64); ok {
208+
call.DurationMs = int64(durationMs)
209+
}
210+
211+
if urlPath, ok := logEntry["url_path"].(string); ok {
212+
call.URLPath = urlPath
213+
}
214+
215+
if rateLimit, ok := logEntry["rate_limit_remaining"].(string); ok {
216+
call.RateLimitRemaining = rateLimit
217+
}
218+
219+
if statusCode, ok := logEntry["status_code"].(float64); ok {
220+
call.StatusCode = int(statusCode)
221+
}
222+
223+
if provider, ok := logEntry["provider"].(string); ok {
224+
call.Provider = provider
225+
}
226+
227+
if repo, ok := logEntry["repo"].(string); ok {
228+
call.Repo = repo
229+
}
230+
231+
return call
232+
}
233+
234+
// outputTestResultToFile writes the test result to a JSON file.
235+
func (g *PRTest) outputTestResultToFile(outputDir string, apiCalls []string, lastOAuth2Index, totalLines int) {
236+
// Create output directory if it doesn't exist
237+
if err := os.MkdirAll(outputDir, 0o755); err != nil {
238+
g.Logger.Warnf("Failed to create output directory %s: %v", outputDir, err)
239+
return
240+
}
241+
242+
// Parse API calls into structured data
243+
var structuredCalls []InstrumentationAPICall
244+
for _, call := range apiCalls {
245+
if parsed := parseAPICallLog(call); parsed != nil {
246+
structuredCalls = append(structuredCalls, *parsed)
247+
}
248+
}
249+
250+
// Create test result
251+
controllerName := "controller"
252+
if g.SecondController {
253+
controllerName = "ghe-controller"
254+
}
255+
256+
result := TestResult{
257+
TestName: g.Label,
258+
Timestamp: time.Now().Format(time.RFC3339),
259+
Controller: controllerName,
260+
PRNumber: g.PRNumber,
261+
SHA: g.SHA,
262+
TargetNamespace: g.TargetNamespace,
263+
GitHubAPICalls: structuredCalls,
264+
TotalCalls: len(structuredCalls),
265+
OAuth2MarkerLine: totalLines - lastOAuth2Index,
266+
}
267+
268+
// Generate filename
269+
timestamp := time.Now().Format("2006-01-02T15-04-05")
270+
sanitizedTestName := sanitizeFilename(g.Label)
271+
filename := fmt.Sprintf("%s_%s.json", timestamp, sanitizedTestName)
272+
filepath := filepath.Join(outputDir, filename)
273+
274+
// Write JSON file
275+
jsonData, err := json.MarshalIndent(result, "", " ")
276+
if err != nil {
277+
g.Logger.Warnf("Failed to marshal test result to JSON: %v", err)
278+
return
279+
}
280+
281+
if err := os.WriteFile(filepath, jsonData, 0o600); err != nil {
282+
g.Logger.Warnf("Failed to write test result to file %s: %v", filepath, err)
283+
return
284+
}
285+
286+
g.Logger.Infof("Test result written to: %s", filepath)
287+
}

0 commit comments

Comments
 (0)