Skip to content

Commit 00882d5

Browse files
authored
feat(stovepipe): report build failure detection latency (#573)
## Summary Emit how long a break went undetected: when a validation build fails, record the elapsed time from the commit timestamp of the base it validated against, as a histogram tagged with the queue and the build strategy. A histogram rather than a gauge because the distribution over failures is the point — what an operator wants is how long a break typically survives, not how long the last one did. The buckets (`ChangeAgeBuckets`) span minutes to a month, since a break caught in minutes and one that survived a fortnight are both ordinary observations. The observation lives in `buildsignal`, the stage that records the failure. A failure is the moment a break becomes known, and an elapsed time is only meaningful against it, so unlike the last-known-green age in #572 there is no later moment to sample it from — it cannot be moved off the delivery path onto the periodic schedule. The cost is a source-control call on that path, so it is confined to failures, made after the outcome is durable, and swallows every fault: a failed observation is counted with the step that failed and never disturbs the outcome already written. A full build pins no base commit, so its failures have nothing to measure from. That is the ordinary case for the strategy rather than a fault, so those failures are counted as unmeasurable (`detection_missing`) instead of landing in the error series. Rebased onto the restructured #572, so the emits follow the same conventions as the last-green observation there: an operation name for what is measured rather than for the stage, `detection_errors` tagged with the step that failed, a separate counter for "nothing to measure", and failures logged. The source-control factory is a required constructor dependency and wired in `service/stovepipe/server/main.go`. ## Test Plan - `make test` — table-driven unit tests cover the measured path, the no-baseline (full build) case, and every step that can fail to observe: source control not resolving, `ChangeInfo` failing, an undated change, and a change dated in the future. - `make lint`, `make check-gazelle`, `make check-tidy`, `make build`. - Deploy and add a query for `build_failure.time_to_detection`, confirming `detection_errors` stays flat and `detection_missing` tracks only full-build failures. ## Issues ## Stack 1. #572 1. @ #573
1 parent de75bef commit 00882d5

5 files changed

Lines changed: 263 additions & 13 deletions

File tree

platform/metrics/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,15 @@ defer func() { op.Complete(retErr) }()
7777
metrics.NamedCounter(c.scope, "publish", "attempts", 1, metrics.NewTag("topic", c.topic))
7878
```
7979

80-
## Latency Buckets
80+
## Duration Buckets
8181

82-
There is no default bucket set. The package exports three common sets:
82+
There is no default bucket set. The package exports four common sets:
8383

8484
| Set | Range | Use for |
8585
|-----|-------|---------|
8686
| `FastLatencyBuckets` | ~100µs – 5s | Fast in-process work such as scoring, cache lookups, and CPU-bound operations |
8787
| `StorageLatencyBuckets` | ~1ms – 1m | Storage and message-queue round trips such as database reads, writes, publishing, and consuming |
8888
| `LongLatencyBuckets` | ~5ms – 4h | Long-running pipeline work and external calls such as builds, merges, pushes, and provider calls |
89+
| `ChangeAgeBuckets` | ~1m – 30d | Elapsed time measured from a source-control change's commit timestamp rather than from work this system started |
8990

9091
Pass one of these sets or a custom `tally.DurationBuckets` to `Begin` or `NamedHistogram`.

platform/metrics/metrics.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,28 @@ var (
106106
2 * time.Hour,
107107
4 * time.Hour,
108108
}
109+
110+
// ChangeAgeBuckets suits durations measured from a source-control change's
111+
// commit timestamp. They span minutes to a month because that is the honest
112+
// range of such a signal: a break caught in minutes and one that survived a
113+
// fortnight are both ordinary observations, and collapsing the tail would hide
114+
// exactly the cases worth seeing.
115+
ChangeAgeBuckets = tally.DurationBuckets{
116+
1 * time.Minute,
117+
5 * time.Minute,
118+
15 * time.Minute,
119+
30 * time.Minute,
120+
1 * time.Hour,
121+
2 * time.Hour,
122+
4 * time.Hour,
123+
8 * time.Hour,
124+
12 * time.Hour,
125+
24 * time.Hour,
126+
48 * time.Hour,
127+
7 * 24 * time.Hour,
128+
14 * 24 * time.Hour,
129+
30 * 24 * time.Hour,
130+
}
109131
)
110132

111133
// Op tracks the lifecycle of a named operation. It captures the start time on

platform/metrics/metrics_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func TestLatencyBuckets_Sorted(t *testing.T) {
163163
"FastLatencyBuckets": FastLatencyBuckets,
164164
"StorageLatencyBuckets": StorageLatencyBuckets,
165165
"LongLatencyBuckets": LongLatencyBuckets,
166+
"ChangeAgeBuckets": ChangeAgeBuckets,
166167
}
167168
for name, buckets := range sets {
168169
t.Run(name, func(t *testing.T) {

stovepipe/controller/record/record.go

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,18 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
119119

120120
switch request.State {
121121
case entity.RequestStateSucceeded, entity.RequestStateFailed:
122-
fact, err := c.recordFact(ctx, store, request)
122+
fact, created, err := c.recordFact(ctx, store, request)
123123
if err != nil {
124124
return err
125125
}
126126
if !fact.IsGreen() {
127127
metrics.NamedCounter(c.metricsScope, _opName, "not_green", 1)
128+
// Only the writer of the fact reports the latency: a redelivery adopts
129+
// the stored fact instead, and a second sample would count one break
130+
// twice in the distribution.
131+
if created {
132+
c.reportFailureDetectionLatency(ctx, request)
133+
}
128134
return nil
129135
}
130136
if err := c.advanceLastGreen(ctx, store, request); err != nil {
@@ -159,8 +165,10 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
159165
// facts are first-writer-wins, so an identity already claimed by this same request —
160166
// a redelivery after the write but before the bookmark advanced — yields the stored
161167
// fact instead. Every decision downstream reads that stored fact rather than the
162-
// request, so a redelivery cannot reach a different verdict than the original.
163-
func (c *Controller) recordFact(ctx context.Context, store storage.Storage, request entity.Request) (entity.ValidationFact, error) {
168+
// request, so a redelivery cannot reach a different verdict than the original. The
169+
// second return reports whether this call is the one that wrote the fact, which is
170+
// how a caller tells the original delivery from a redelivery.
171+
func (c *Controller) recordFact(ctx context.Context, store storage.Storage, request entity.Request) (entity.ValidationFact, bool, error) {
164172
factStore := store.GetValidationFactStore()
165173

166174
fact := entity.ValidationFact{
@@ -181,29 +189,100 @@ func (c *Controller) recordFact(ctx context.Context, store storage.Storage, requ
181189
"uri", request.URI,
182190
"degree", fact.Degree,
183191
)
184-
return fact, nil
192+
return fact, true, nil
185193

186194
case errors.Is(err, storage.ErrAlreadyExists):
187195
stored, getErr := factStore.Get(ctx, request.URI, wholeRepositoryProject)
188196
if getErr != nil {
189197
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
190-
return entity.ValidationFact{}, fmt.Errorf("failed to load the existing fact for uri %s: %w", request.URI, getErr)
198+
return entity.ValidationFact{}, false, fmt.Errorf("failed to load the existing fact for uri %s: %w", request.URI, getErr)
191199
}
192200
if stored.RequestID != request.ID {
193201
// Two requests validating one URI would break the dedup ingest
194202
// enforces, so this is a broken invariant rather than a race to
195203
// resolve. Non-retryable: the stored fact is immutable.
196204
metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1)
197-
return entity.ValidationFact{}, fmt.Errorf(
205+
return entity.ValidationFact{}, false, fmt.Errorf(
198206
"fact for uri %s is owned by request %s, not %s", request.URI, stored.RequestID, request.ID)
199207
}
200208
metrics.NamedCounter(c.metricsScope, _opName, "fact_exists", 1)
201-
return stored, nil
209+
return stored, false, nil
202210

203211
default:
204212
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
205-
return entity.ValidationFact{}, fmt.Errorf("failed to create the fact for uri %s: %w", request.URI, err)
213+
return entity.ValidationFact{}, false, fmt.Errorf("failed to create the fact for uri %s: %w", request.URI, err)
214+
}
215+
}
216+
217+
// reportFailureDetectionLatency records how long the break this build failed on went
218+
// undetected, measured from the commit timestamp of the base it validated against. A
219+
// histogram rather than a gauge because the distribution over failures is the point:
220+
// how long a break typically survives, not how long the last one did.
221+
//
222+
// Unlike the last-green age, there is no later moment to sample this from — an elapsed
223+
// time is only meaningful against the failure that just became known — so the
224+
// source-control lookup cannot be moved off the delivery path onto a clock. It is
225+
// confined to failures and made once the fact is durable, and every way it can fail is
226+
// counted and swallowed so a reporting fault cannot retry an outcome already recorded.
227+
func (c *Controller) reportFailureDetectionLatency(ctx context.Context, request entity.Request) {
228+
queueTag := metrics.NewTag("queue", request.Queue)
229+
strategyTag := metrics.NewTag("strategy", string(request.BuildStrategy))
230+
231+
// Only a strategy that validates a delta pins a base commit, so a full build has
232+
// no baseline to measure from. Its failures are counted rather than timed: absent
233+
// here is the ordinary case, not a fault.
234+
if request.BaseURI == "" {
235+
metrics.NamedCounter(c.metricsScope, _opName, "failure_detection_missing", 1, queueTag, strategyTag)
236+
return
206237
}
238+
239+
sourceControl, err := c.sourceControls.For(sourcecontrol.Config{QueueName: request.Queue})
240+
if err != nil {
241+
c.failureDetectionUnobserved(request, "resolve_source_control", err)
242+
return
243+
}
244+
245+
info, err := sourceControl.ChangeInfo(ctx, request.BaseURI)
246+
if err != nil {
247+
c.failureDetectionUnobserved(request, "get_change_info", err)
248+
return
249+
}
250+
251+
// SourceControl must report a positive creation timestamp, so a missing one is a
252+
// broken extension contract rather than a lookup failure. Measuring from 1970
253+
// would drop a decades-long sample into the distribution.
254+
if info.CreatedAt <= 0 {
255+
c.failureDetectionUnobserved(request, "undated_change", nil)
256+
return
257+
}
258+
259+
// A base dated in the future means the provider's clock disagrees with ours; a
260+
// negative latency would corrupt the distribution rather than describe it.
261+
latency := time.Since(time.UnixMilli(info.CreatedAt))
262+
if latency < 0 {
263+
c.failureDetectionUnobserved(request, "future_change", nil)
264+
return
265+
}
266+
267+
metrics.NamedHistogram(c.metricsScope, _opName, "failure_detection_latency", metrics.ChangeAgeBuckets,
268+
queueTag, strategyTag,
269+
).RecordDuration(latency)
270+
}
271+
272+
// failureDetectionUnobserved counts a latency that could not be observed, tagged with
273+
// the step that failed so an unmeasurable failure can be told apart from a broken
274+
// dependency.
275+
func (c *Controller) failureDetectionUnobserved(request entity.Request, step string, err error) {
276+
metrics.NamedCounter(c.metricsScope, _opName, "failure_detection_errors", 1,
277+
metrics.NewTag("queue", request.Queue),
278+
metrics.NewTag("step", step),
279+
)
280+
c.logger.Warnw("failed to observe how long the build failure went undetected",
281+
"queue", request.Queue,
282+
"base_uri", request.BaseURI,
283+
"step", step,
284+
"error", err,
285+
)
207286
}
208287

209288
// degreeFor maps a request's build outcome onto a whole-repository degree. Only the

stovepipe/controller/record/record_test.go

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,18 @@ import (
3636
)
3737

3838
const (
39-
testQueue = "monorepo/main"
40-
testID = "request/monorepo/main/7"
41-
testURI = "git://remote/monorepo/main/head-sha"
39+
testQueue = "monorepo/main"
40+
testID = "request/monorepo/main/7"
41+
testURI = "git://remote/monorepo/main/head-sha"
42+
testBaseURI = "git://remote/monorepo/main/base-sha"
43+
)
44+
45+
// Metric names as they appear in a snapshot, so a case asserts on the series an
46+
// operator queries rather than on how the emit is composed.
47+
const (
48+
failureDetectionLatency = "record_controller.record.failure_detection_latency+queue=monorepo/main,strategy=incremental_since_green"
49+
failureDetectionMissing = "record_controller.record.failure_detection_missing+queue=monorepo/main,strategy=full"
50+
failureDetectionErrors = "record_controller.record.failure_detection_errors+queue=monorepo/main,step="
4251
)
4352

4453
var testChangeTime = time.Unix(1_700_000_000, 0).UTC()
@@ -138,6 +147,24 @@ func requestWithState(state entity.RequestState) entity.Request {
138147
}
139148
}
140149

150+
// failedRequest returns a failed request validated incrementally against
151+
// testBaseURI — the shape that has a detection latency to report.
152+
func failedRequest() entity.Request {
153+
request := requestWithState(entity.RequestStateFailed)
154+
request.BaseURI = testBaseURI
155+
request.BuildStrategy = entity.BuildStrategyIncrementalSinceGreen
156+
return request
157+
}
158+
159+
// totalSamples sums the samples across a duration histogram's buckets.
160+
func totalSamples(buckets map[time.Duration]int64) int64 {
161+
var sum int64
162+
for _, count := range buckets {
163+
sum += count
164+
}
165+
return sum
166+
}
167+
141168
// queueRow returns the testQueue's row holding the given bookmark.
142169
func queueRow(lastGreenURI, lastGreenRequestID string, version int32) entity.Queue {
143170
return entity.Queue{
@@ -283,6 +310,126 @@ func TestProcess_RecordsBrokenFactWithoutAdvancing(t *testing.T) {
283310
assert.False(t, fact.IsGreen())
284311
}
285312

313+
func TestProcess_ReportsFailureDetectionLatency(t *testing.T) {
314+
ctrl := gomock.NewController(t)
315+
c, m := newController(t, ctrl)
316+
317+
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(failedRequest(), nil)
318+
var fact entity.ValidationFact
319+
m.expectFactCreated(&fact)
320+
m.sourceControl.EXPECT().ChangeInfo(gomock.Any(), testBaseURI).
321+
Return(sourcecontrol.ChangeInfo{CreatedAt: time.Now().Add(-time.Hour).UnixMilli()}, nil)
322+
323+
require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID))))
324+
325+
histogram, ok := m.metricsScope.Snapshot().Histograms()[failureDetectionLatency]
326+
require.True(t, ok)
327+
assert.EqualValues(t, 1, totalSamples(histogram.Durations()))
328+
}
329+
330+
// TestProcess_FullBuildFailureHasNoBaseline covers a full build: it pins no base
331+
// commit, so there is nothing to measure the latency from. That is the ordinary case
332+
// for the strategy, not a fault, so it must not land among the errors.
333+
func TestProcess_FullBuildFailureHasNoBaseline(t *testing.T) {
334+
ctrl := gomock.NewController(t)
335+
c, m := newController(t, ctrl)
336+
337+
request := failedRequest()
338+
request.BaseURI = ""
339+
request.BuildStrategy = entity.BuildStrategyFull
340+
341+
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(request, nil)
342+
var fact entity.ValidationFact
343+
m.expectFactCreated(&fact)
344+
345+
require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID))))
346+
347+
snapshot := m.metricsScope.Snapshot()
348+
assert.Empty(t, snapshot.Histograms(), "a build with no baseline has no latency to report")
349+
counter, ok := snapshot.Counters()[failureDetectionMissing]
350+
require.True(t, ok)
351+
assert.EqualValues(t, 1, counter.Value())
352+
}
353+
354+
// TestProcess_RedeliveredFailureIsNotResampled covers a redelivery that adopts a
355+
// broken fact it already wrote: one break must contribute one sample, or the
356+
// distribution counts the flakiest deliveries twice.
357+
func TestProcess_RedeliveredFailureIsNotResampled(t *testing.T) {
358+
ctrl := gomock.NewController(t)
359+
c, m := newController(t, ctrl)
360+
361+
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(failedRequest(), nil)
362+
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(storage.ErrAlreadyExists)
363+
m.factStore.EXPECT().Get(gomock.Any(), testURI, wholeRepositoryProject).
364+
Return(entity.ValidationFact{URI: testURI, Degree: entity.DegreeBroken, RequestID: testID}, nil)
365+
366+
require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID))))
367+
assert.Empty(t, m.metricsScope.Snapshot().Histograms())
368+
}
369+
370+
// TestProcess_UnobservableDetectionLatencyDoesNotFailRecord covers the observation's
371+
// error posture: every way it can fail is counted with the step that failed and
372+
// swallowed, because a reporting fault must not disturb the fact already recorded.
373+
func TestProcess_UnobservableDetectionLatencyDoesNotFailRecord(t *testing.T) {
374+
tests := []struct {
375+
name string
376+
step string
377+
setup func(c *Controller, m recordMocks)
378+
}{
379+
{
380+
name: "source control cannot be resolved",
381+
step: "resolve_source_control",
382+
setup: func(c *Controller, _ recordMocks) {
383+
c.sourceControls = failingSourceControlFactory{}
384+
},
385+
},
386+
{
387+
name: "the base change cannot be looked up",
388+
step: "get_change_info",
389+
setup: func(_ *Controller, m recordMocks) {
390+
m.sourceControl.EXPECT().ChangeInfo(gomock.Any(), testBaseURI).
391+
Return(sourcecontrol.ChangeInfo{}, errors.New("boom"))
392+
},
393+
},
394+
{
395+
name: "the base change is undated",
396+
step: "undated_change",
397+
setup: func(_ *Controller, m recordMocks) {
398+
m.sourceControl.EXPECT().ChangeInfo(gomock.Any(), testBaseURI).
399+
Return(sourcecontrol.ChangeInfo{CreatedAt: 0}, nil)
400+
},
401+
},
402+
{
403+
name: "the base change is dated in the future",
404+
step: "future_change",
405+
setup: func(_ *Controller, m recordMocks) {
406+
m.sourceControl.EXPECT().ChangeInfo(gomock.Any(), testBaseURI).
407+
Return(sourcecontrol.ChangeInfo{CreatedAt: time.Now().Add(time.Hour).UnixMilli()}, nil)
408+
},
409+
},
410+
}
411+
412+
for _, tt := range tests {
413+
t.Run(tt.name, func(t *testing.T) {
414+
ctrl := gomock.NewController(t)
415+
c, m := newController(t, ctrl)
416+
tt.setup(c, m)
417+
418+
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(failedRequest(), nil)
419+
var fact entity.ValidationFact
420+
m.expectFactCreated(&fact)
421+
422+
require.NoError(t, c.Process(context.Background(), delivery(t, ctrl, recordPayload(t, testID))))
423+
424+
snapshot := m.metricsScope.Snapshot()
425+
assert.Empty(t, snapshot.Histograms(), "no latency may be reported when it cannot be observed")
426+
counter, ok := snapshot.Counters()[failureDetectionErrors+tt.step]
427+
require.True(t, ok)
428+
assert.EqualValues(t, 1, counter.Value())
429+
})
430+
}
431+
}
432+
286433
func TestProcess_AdoptsExistingFactFromSameRequest(t *testing.T) {
287434
tests := []struct {
288435
name string

0 commit comments

Comments
 (0)