Skip to content

Commit 90beac3

Browse files
authored
Deepen run transition metrics (#64)
1 parent eae711e commit 90beac3

6 files changed

Lines changed: 448 additions & 126 deletions

File tree

src/github.go

Lines changed: 9 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"strings"
1212
"time"
1313

14-
"github.com/prometheus/client_golang/prometheus"
1514
"go.uber.org/zap"
1615
)
1716

@@ -82,29 +81,6 @@ type GithubPullRequest struct {
8281
Repository GithubRepo `json:"repository"`
8382
}
8483

85-
type runMetricDetails struct {
86-
repository string
87-
branch string
88-
name string
89-
status string
90-
conclusion string
91-
startedAt string
92-
endedAt string
93-
}
94-
95-
type runMetricSet struct {
96-
statusCounter *prometheus.CounterVec
97-
queuedGauge *prometheus.GaugeVec
98-
inProgressGauge *prometheus.GaugeVec
99-
completedGauge *prometheus.GaugeVec
100-
durationHistogram *prometheus.HistogramVec
101-
}
102-
103-
type runStoreMethods struct {
104-
get func(context.Context, int) (RunState, bool, error)
105-
update func(context.Context, int, RunState) error
106-
}
107-
10884
const (
10985
statusQueued = "queued"
11086
statusInProgress = "in_progress"
@@ -202,86 +178,6 @@ func shouldApplyStateTransition(previous, next RunState) bool {
202178
return true
203179
}
204180

205-
func applyGaugeDelta(details RunState, delta float64, queuedGauge, inProgressGauge, completedGauge *prometheus.GaugeVec) {
206-
switch normalizeStatus(details.Status) {
207-
case statusQueued:
208-
queuedGauge.WithLabelValues(details.Repository, details.Branch, details.Name).Add(delta)
209-
case statusInProgress:
210-
inProgressGauge.WithLabelValues(details.Repository, details.Branch, details.Name).Add(delta)
211-
case statusCompleted:
212-
completedGauge.WithLabelValues(details.Repository, details.Branch, details.Conclusion, details.Name).Add(delta)
213-
}
214-
}
215-
216-
func observeDuration(details RunState, durationHistogram *prometheus.HistogramVec) {
217-
if normalizeStatus(details.Status) != statusCompleted {
218-
return
219-
}
220-
221-
startedAt, startedOK := parseMetricTime(details.StartedAt)
222-
endedAt, endedOK := parseMetricTime(details.EndedAt)
223-
if !startedOK || !endedOK || endedAt.Before(startedAt) {
224-
return
225-
}
226-
227-
durationHistogram.WithLabelValues(
228-
details.Repository,
229-
details.Branch,
230-
details.Name,
231-
details.Status,
232-
details.Conclusion,
233-
).Observe(endedAt.Sub(startedAt).Seconds())
234-
}
235-
236-
func applyStatefulMetrics(details RunState, previous *RunState, metrics runMetricSet) {
237-
metrics.statusCounter.WithLabelValues(
238-
details.Repository,
239-
details.Branch,
240-
details.Name,
241-
details.Status,
242-
details.Conclusion,
243-
).Inc()
244-
245-
if previous != nil {
246-
applyGaugeDelta(*previous, -1, metrics.queuedGauge, metrics.inProgressGauge, metrics.completedGauge)
247-
}
248-
applyGaugeDelta(details, 1, metrics.queuedGauge, metrics.inProgressGauge, metrics.completedGauge)
249-
250-
if previous == nil || normalizeStatus(previous.Status) != statusCompleted {
251-
observeDuration(details, metrics.durationHistogram)
252-
}
253-
}
254-
255-
func getPreviousState(ctx context.Context, id int, getFn func(context.Context, int) (RunState, bool, error), entityName string) (*RunState, bool) {
256-
if stateStore == nil {
257-
return nil, true
258-
}
259-
260-
previous, found, err := getFn(ctx, id)
261-
if err != nil {
262-
logger.Error("Failed to load run state from redis", zap.String("entity", entityName), zap.Int("id", id), zap.Error(err))
263-
return nil, false
264-
}
265-
if !found {
266-
return nil, true
267-
}
268-
269-
return &previous, true
270-
}
271-
272-
func persistRunState(ctx context.Context, id int, next RunState, updateFn func(context.Context, int, RunState) error, entityName string) bool {
273-
if stateStore == nil {
274-
return true
275-
}
276-
277-
if err := updateFn(ctx, id, next); err != nil {
278-
logger.Error("Failed to update run state in redis", zap.String("entity", entityName), zap.Int("id", id), zap.Error(err))
279-
return false
280-
}
281-
282-
return true
283-
}
284-
285181
func updateTrackedRunMetrics(
286182
ctx context.Context,
287183
id int,
@@ -290,26 +186,18 @@ func updateTrackedRunMetrics(
290186
entityName string,
291187
metrics runMetricSet,
292188
) {
293-
nextState := normalizeRunState(details)
294-
295-
if stateStore == nil {
296-
applyStatefulMetrics(nextState, nil, metrics)
297-
return
189+
var storeAdapter runTransitionStore
190+
if stateStore != nil {
191+
storeAdapter = runStoreAdapter{methods: store}
298192
}
299193

300-
previousState, ok := getPreviousState(ctx, id, store.get, entityName)
301-
if !ok {
302-
return
303-
}
304-
if previousState != nil && !shouldApplyStateTransition(*previousState, nextState) {
305-
logger.Debug("Skipping stale or duplicate run transition", zap.String("entity", entityName), zap.Int("id", id), zap.String("status", nextState.Status), zap.String("conclusion", nextState.Conclusion))
306-
return
194+
processor := &runTransitionProcessor{
195+
store: storeAdapter,
196+
recorder: prometheusRunTransitionRecorder{metrics: metrics},
197+
logger: logger,
198+
entityName: entityName,
307199
}
308-
if !persistRunState(ctx, id, nextState, store.update, entityName) {
309-
return
310-
}
311-
312-
applyStatefulMetrics(nextState, previousState, metrics)
200+
processor.Apply(ctx, id, details)
313201
}
314202

315203
func workflowRunStoreMethods() runStoreMethods {

src/integration_test.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"crypto/hmac"
99
"crypto/sha256"
1010
"encoding/hex"
11+
"encoding/json"
12+
"errors"
1113
"fmt"
1214
"io"
1315
"net/http"
@@ -22,6 +24,11 @@ import (
2224
"go.uber.org/zap"
2325
)
2426

27+
const (
28+
integrationRunStartedAt = "2023-01-01T00:00:00Z"
29+
integrationRunCompletedAt = "2023-01-01T01:00:00Z"
30+
)
31+
2532
func TestIntegrationWebhookMetrics(t *testing.T) {
2633
testCases := []struct {
2734
name string
@@ -188,6 +195,58 @@ func TestIntegrationDuplicateDeliveryDoesNotInflateMetrics(t *testing.T) {
188195
}
189196
}
190197

198+
func TestIntegrationDeliveryStoreFailurePreventsWebhookProcessing(t *testing.T) {
199+
server := newIntegrationTestServerWithStateStore(t, asyncProcessorConfig{WorkerCount: 1, QueueSize: 8}, failingDeliveryStateStore{
200+
inMemoryStateStore: newInMemoryStateStore(),
201+
})
202+
defer server.Close()
203+
204+
body := mustReadFixture(t, "workflow_run.json")
205+
resp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, body, "delivery-store-failure")
206+
assertResponseStatus(t, resp, http.StatusInternalServerError)
207+
208+
metrics := mustFetchMetrics(t, server.URL)
209+
if strings.Contains(metrics, `promgithub_workflow_status{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`) {
210+
t.Fatalf("workflow metrics changed after delivery store failure:\n%s", metrics)
211+
}
212+
if strings.Contains(metrics, `promgithub_event_processed_total{event_type="workflow_run"}`) {
213+
t.Fatalf("event was enqueued after delivery store failure:\n%s", metrics)
214+
}
215+
}
216+
217+
func TestIntegrationWorkflowRunLifecycleBalancesStatefulMetrics(t *testing.T) {
218+
server := newIntegrationTestServer(t)
219+
defer server.Close()
220+
221+
queuedBody := workflowRunFixtureWithStatus(t, statusQueued, "", integrationRunStartedAt)
222+
queuedResp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, queuedBody, "delivery-lifecycle-queued")
223+
assertResponseStatus(t, queuedResp, http.StatusAccepted)
224+
waitForMetricsSubstring(t, server.URL, `promgithub_workflow_queued{branch="main",repository="user/repo",workflow_name="CI"} 1`)
225+
226+
inProgressBody := workflowRunFixtureWithStatus(t, statusInProgress, "", integrationRunStartedAt)
227+
inProgressResp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, inProgressBody, "delivery-lifecycle-in-progress")
228+
assertResponseStatus(t, inProgressResp, http.StatusAccepted)
229+
waitForMetricsSubstring(t, server.URL, `promgithub_workflow_in_progress{branch="main",repository="user/repo",workflow_name="CI"} 1`)
230+
231+
completedBody := workflowRunFixtureWithStatus(t, statusCompleted, testConclusionSuccess, integrationRunCompletedAt)
232+
completedResp := sendWebhookRequest(t, server.URL, githubEventWorkflowRun, completedBody, "delivery-lifecycle-completed")
233+
assertResponseStatus(t, completedResp, http.StatusAccepted)
234+
235+
metrics := waitForMetricsSubstring(t, server.URL, `promgithub_workflow_completed{branch="main",repository="user/repo",workflow_conclusion="success",workflow_name="CI"} 1`)
236+
expectedMetrics := []string{
237+
`promgithub_workflow_queued{branch="main",repository="user/repo",workflow_name="CI"} 0`,
238+
`promgithub_workflow_in_progress{branch="main",repository="user/repo",workflow_name="CI"} 0`,
239+
`promgithub_workflow_completed{branch="main",repository="user/repo",workflow_conclusion="success",workflow_name="CI"} 1`,
240+
`promgithub_workflow_duration_sum{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 3600`,
241+
`promgithub_workflow_duration_count{branch="main",conclusion="success",repository="user/repo",workflow_name="CI",workflow_status="completed"} 1`,
242+
}
243+
for _, expected := range expectedMetrics {
244+
if !strings.Contains(metrics, expected) {
245+
t.Fatalf("expected metrics to contain %q, got:\n%s", expected, metrics)
246+
}
247+
}
248+
}
249+
191250
func TestIntegrationAsyncQueueFullReturnsUnavailableAndExposesQueueDropMetrics(t *testing.T) {
192251
server := newIntegrationTestServerWithAsyncConfig(t, asyncProcessorConfig{WorkerCount: 1, QueueSize: 1})
193252
defer server.Close()
@@ -315,11 +374,16 @@ func newIntegrationTestServer(t *testing.T) *httptest.Server {
315374
}
316375

317376
func newIntegrationTestServerWithAsyncConfig(t *testing.T, cfg asyncProcessorConfig) *httptest.Server {
377+
t.Helper()
378+
return newIntegrationTestServerWithStateStore(t, cfg, newInMemoryStateStore())
379+
}
380+
381+
func newIntegrationTestServerWithStateStore(t *testing.T, cfg asyncProcessorConfig, store StateStore) *httptest.Server {
318382
t.Helper()
319383
resetIntegrationTestMetrics()
320384

321385
githubWebhookSecret = []byte("integration-test-secret")
322-
stateStore = newInMemoryStateStore()
386+
stateStore = store
323387
eventProcessor = newAsyncEventProcessor(cfg, zap.NewNop())
324388
eventProcessor.Start()
325389
t.Cleanup(func() {
@@ -334,6 +398,14 @@ func newIntegrationTestServerWithAsyncConfig(t *testing.T, cfg asyncProcessorCon
334398
return httptest.NewServer(router)
335399
}
336400

401+
type failingDeliveryStateStore struct {
402+
*inMemoryStateStore
403+
}
404+
405+
func (s failingDeliveryStateStore) MarkDeliveryProcessed(context.Context, string) (bool, error) {
406+
return false, errors.New("delivery store unavailable")
407+
}
408+
337409
func assertResponseStatus(t *testing.T, resp *http.Response, expectedStatus int) {
338410
t.Helper()
339411
defer func() { _ = resp.Body.Close() }()
@@ -390,6 +462,24 @@ func mustReadFixture(t *testing.T, name string) []byte {
390462
return body
391463
}
392464

465+
func workflowRunFixtureWithStatus(t *testing.T, status, conclusion, updatedAt string) []byte {
466+
t.Helper()
467+
468+
var payload GithubWorkflow
469+
if err := json.Unmarshal(mustReadFixture(t, "workflow_run.json"), &payload); err != nil {
470+
t.Fatalf("failed to unmarshal workflow fixture: %v", err)
471+
}
472+
payload.Workflow.Status = status
473+
payload.Workflow.Conclusion = conclusion
474+
payload.Workflow.UpdatedAt = updatedAt
475+
476+
body, err := json.Marshal(payload)
477+
if err != nil {
478+
t.Fatalf("failed to marshal workflow fixture: %v", err)
479+
}
480+
return body
481+
}
482+
393483
func sendWebhookRequest(t *testing.T, serverURL, eventType string, body []byte, deliveryID string) *http.Response {
394484
t.Helper()
395485
signature := webhookSignature(body, githubWebhookSecret)

src/metrics_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ func TestWorkflowGaugeTransitionIsIdempotent(t *testing.T) {
354354
updateWorkflowMetrics(context.Background(), inProgressBody)
355355

356356
payload.Workflow.Status = statusCompleted
357-
payload.Workflow.Conclusion = "success"
357+
payload.Workflow.Conclusion = testConclusionSuccess
358358
payload.Workflow.UpdatedAt = "2024-11-21T12:00:00Z"
359359
completedBody, _ := json.Marshal(payload)
360360
updateWorkflowMetrics(context.Background(), completedBody)
@@ -366,7 +366,7 @@ func TestWorkflowGaugeTransitionIsIdempotent(t *testing.T) {
366366
if got := testutil.ToFloat64(workflowInProgressGauge.WithLabelValues("user/repo", "main", "CI")); got != 0 {
367367
t.Fatalf("expected in progress gauge to be 0, got %v", got)
368368
}
369-
if got := testutil.ToFloat64(workflowCompletedGauge.WithLabelValues("user/repo", "main", "success", "CI")); got != 1 {
369+
if got := testutil.ToFloat64(workflowCompletedGauge.WithLabelValues("user/repo", "main", testConclusionSuccess, "CI")); got != 1 {
370370
t.Fatalf("expected completed gauge to be 1, got %v", got)
371371
}
372372
}
@@ -401,7 +401,7 @@ func TestJobGaugeTransitionIsIdempotent(t *testing.T) {
401401
updateJobMetrics(context.Background(), inProgressBody)
402402

403403
payload.Job.Status = statusCompleted
404-
payload.Job.Conclusion = "success"
404+
payload.Job.Conclusion = testConclusionSuccess
405405
payload.Job.CompletedAt = "2024-11-21T12:00:00Z"
406406
completedBody, _ := json.Marshal(payload)
407407
updateJobMetrics(context.Background(), completedBody)
@@ -413,7 +413,7 @@ func TestJobGaugeTransitionIsIdempotent(t *testing.T) {
413413
if got := testutil.ToFloat64(jobInProgressGauge.WithLabelValues("user/repo", "main", "CI")); got != 0 {
414414
t.Fatalf("expected in progress gauge to be 0, got %v", got)
415415
}
416-
if got := testutil.ToFloat64(jobCompletedGauge.WithLabelValues("user/repo", "main", "success", "CI")); got != 1 {
416+
if got := testutil.ToFloat64(jobCompletedGauge.WithLabelValues("user/repo", "main", testConclusionSuccess, "CI")); got != 1 {
417417
t.Fatalf("expected completed gauge to be 1, got %v", got)
418418
}
419419
}

0 commit comments

Comments
 (0)