Skip to content

Commit 2f0c4d8

Browse files
committed
feat(stovepipe): report how long a build failure went undetected
Emit the elapsed time from the commit a failed build validated against, as a histogram tagged with the queue and the build strategy. A histogram because the distribution over failures is the point: how long a break typically survives, not how long the last one did. The observation belongs to the stage that records the failure. A failure is the moment a break becomes known, and an elapsed time is only meaningful against it — unlike the last-known-green age, there is no later moment to sample it from, so it cannot be moved off the delivery path onto a clock. 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 are counted as unmeasurable rather than timed, which keeps the ordinary case out of the error series.
1 parent 3f5e7ad commit 2f0c4d8

6 files changed

Lines changed: 268 additions & 34 deletions

File tree

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
@@ -165,6 +165,7 @@ func TestLatencyBuckets_Sorted(t *testing.T) {
165165
"FastLatencyBuckets": FastLatencyBuckets,
166166
"StorageLatencyBuckets": StorageLatencyBuckets,
167167
"LongLatencyBuckets": LongLatencyBuckets,
168+
"ChangeAgeBuckets": ChangeAgeBuckets,
168169
}
169170
for name, buckets := range sets {
170171
t.Run(name, func(t *testing.T) {

service/stovepipe/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ func registerPrimaryControllers(
421421
}
422422
count++
423423

424-
buildSignalController := buildsignal.NewController(logger, scope, store, brf, registry, stovepipemq.TopicKeyBuildSignal, "stovepipe-buildsignal")
424+
buildSignalController := buildsignal.NewController(logger, scope, store, brf, scf, registry, stovepipemq.TopicKeyBuildSignal, "stovepipe-buildsignal")
425425
if err := c.Register(buildSignalController); err != nil {
426426
return count, fmt.Errorf("failed to register buildsignal controller: %w", err)
427427
}

stovepipe/controller/buildsignal/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ go_library(
1313
"//stovepipe/core/messagequeue:go_default_library",
1414
"//stovepipe/entity:go_default_library",
1515
"//stovepipe/extension/buildrunner:go_default_library",
16+
"//stovepipe/extension/sourcecontrol:go_default_library",
1617
"//stovepipe/extension/storage:go_default_library",
1718
"@com_github_uber_go_tally//:go_default_library",
1819
"@org_uber_go_zap//:go_default_library",
@@ -33,6 +34,8 @@ go_test(
3334
"//stovepipe/entity:go_default_library",
3435
"//stovepipe/extension/buildrunner:go_default_library",
3536
"//stovepipe/extension/buildrunner/mock:go_default_library",
37+
"//stovepipe/extension/sourcecontrol:go_default_library",
38+
"//stovepipe/extension/sourcecontrol/mock:go_default_library",
3639
"//stovepipe/extension/storage:go_default_library",
3740
"//stovepipe/extension/storage/mock:go_default_library",
3841
"@com_github_stretchr_testify//assert:go_default_library",

stovepipe/controller/buildsignal/buildsignal.go

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818
// releases the queue's build slot, projects the outcome onto the request, and
1919
// publishes the request id to record. See
2020
// doc/rfc/stovepipe/steps/buildsignal.md.
21+
//
22+
// It reads source control for one thing only: a failure is the moment a break
23+
// becomes known, so the stage that records the failure is the only place the time
24+
// it went undetected can be measured.
2125
package buildsignal
2226

2327
import (
2428
"context"
2529
"errors"
2630
"fmt"
31+
"time"
2732

2833
"github.com/uber-go/tally"
2934
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
@@ -33,6 +38,7 @@ import (
3338
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
3439
"github.com/uber/submitqueue/stovepipe/entity"
3540
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
41+
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
3642
"github.com/uber/submitqueue/stovepipe/extension/storage"
3743
"go.uber.org/zap"
3844
)
@@ -57,39 +63,49 @@ var (
5763
// the request, and publishes the request id to record. Implements
5864
// consumer.Controller.
5965
type Controller struct {
60-
logger *zap.SugaredLogger
61-
metricsScope tally.Scope
62-
stores storage.Factory
63-
buildRunners buildrunner.Factory
64-
registry consumer.TopicRegistry
65-
topicKey consumer.TopicKey
66-
consumerGroup string
66+
logger *zap.SugaredLogger
67+
metricsScope tally.Scope
68+
stores storage.Factory
69+
buildRunners buildrunner.Factory
70+
sourceControls sourcecontrol.Factory
71+
registry consumer.TopicRegistry
72+
topicKey consumer.TopicKey
73+
consumerGroup string
6774
}
6875

6976
// Verify Controller implements consumer.Controller interface at compile time.
7077
var _ consumer.Controller = (*Controller)(nil)
7178

72-
// _opName is the metric operation name shared by every emit in this file.
73-
const _opName = "buildsignal"
79+
const (
80+
// _opName is the metric operation name for this stage's own handling counters.
81+
_opName = "buildsignal"
82+
83+
// _opBuildFailure is the metric operation name for the failure-detection
84+
// observation. It is named for what is measured rather than for this stage, so
85+
// the series an operator alerts on does not move if the stage does.
86+
_opBuildFailure = "build_failure"
87+
)
7488

7589
// NewController creates a new buildsignal controller.
7690
func NewController(
7791
logger *zap.SugaredLogger,
7892
scope tally.Scope,
7993
stores storage.Factory,
8094
buildRunners buildrunner.Factory,
95+
sourceControls sourcecontrol.Factory,
8196
registry consumer.TopicRegistry,
8297
topicKey consumer.TopicKey,
8398
consumerGroup string,
8499
) *Controller {
85100
return &Controller{
86-
logger: logger.Named("buildsignal_controller"),
87-
metricsScope: scope.SubScope("buildsignal_controller"),
88-
stores: stores,
89-
buildRunners: buildRunners,
90-
registry: registry,
91-
topicKey: topicKey,
92-
consumerGroup: consumerGroup,
101+
logger: logger.Named("buildsignal_controller"),
102+
metricsScope: scope.SubScope("buildsignal_controller"),
103+
stores: stores,
104+
buildRunners: buildRunners,
105+
sourceControls: sourceControls,
106+
registry: registry,
107+
topicKey: topicKey,
108+
consumerGroup: consumerGroup,
93109
}
94110
}
95111

@@ -270,10 +286,70 @@ func (c *Controller) markOutcome(ctx context.Context, store storage.Storage, req
270286
metrics.NamedCounter(c.metricsScope, _opName, "outcomes", 1,
271287
metrics.NewTag("state", string(state)),
272288
)
289+
// Reported from the write that records the failure, so only the writer of an
290+
// outcome reports it: a redelivery returns above without a second sample.
291+
if state == entity.RequestStateFailed {
292+
c.reportDetectionLatency(ctx, *request)
293+
}
273294
return nil
274295
}
275296
}
276297

298+
// reportDetectionLatency records how long the break this build failed on went
299+
// undetected, measured from the commit timestamp of the base it validated against. A
300+
// histogram rather than a gauge because the distribution over failures is the point:
301+
// how long a break typically survives, not how long the last one did.
302+
//
303+
// The observation is best-effort — every way it can fail is counted and swallowed,
304+
// never affecting the outcome that was just recorded. It costs a source-control call
305+
// on the delivery path, which this measurement cannot trade away the way a
306+
// current-state signal can: there is no later moment to sample an elapsed time from,
307+
// because it is only meaningful against the failure that just became known. The call
308+
// is confined to failures and made after the outcome is durable.
309+
func (c *Controller) reportDetectionLatency(ctx context.Context, request entity.Request) {
310+
queueTag := metrics.NewTag("queue", request.Queue)
311+
strategyTag := metrics.NewTag("strategy", string(request.BuildStrategy))
312+
313+
// Only a strategy that validates a delta pins a base commit, so a full build has
314+
// no baseline to measure from. Its failures are counted rather than timed: absent
315+
// here is the ordinary case, not a fault.
316+
if request.BaseURI == "" {
317+
metrics.NamedCounter(c.metricsScope, _opBuildFailure, "detection_missing", 1, queueTag, strategyTag)
318+
return
319+
}
320+
321+
sourceControl, err := c.sourceControls.For(sourcecontrol.Config{QueueName: request.Queue})
322+
if err != nil {
323+
c.detectionError(queueTag, "resolve_source_control", request.Queue, err)
324+
return
325+
}
326+
327+
info, err := sourceControl.ChangeInfo(ctx, request.BaseURI)
328+
if err != nil || info.CreatedAt.IsZero() {
329+
c.detectionError(queueTag, "get_change_info", request.Queue, err)
330+
return
331+
}
332+
333+
// A commit dated in the future means the provider's clock disagrees with ours;
334+
// a negative latency would corrupt the distribution rather than describe it.
335+
latency := time.Since(info.CreatedAt)
336+
if latency < 0 {
337+
c.detectionError(queueTag, "future_change", request.Queue, nil)
338+
return
339+
}
340+
341+
metrics.NamedHistogram(c.metricsScope, _opBuildFailure, "time_to_detection", metrics.ChangeAgeBuckets,
342+
queueTag, strategyTag,
343+
).RecordDuration(latency)
344+
}
345+
346+
// detectionError counts an observation that could not be made, tagged with the step
347+
// that failed so an unmeasurable failure can be told apart from a broken dependency.
348+
func (c *Controller) detectionError(queueTag metrics.Tag, step, queue string, err error) {
349+
metrics.NamedCounter(c.metricsScope, _opBuildFailure, "detection_errors", 1, queueTag, metrics.NewTag("step", step))
350+
c.logger.Errorw("failed to observe build failure detection latency", "queue", queue, "step", step, "error", err)
351+
}
352+
277353
// releaseBuildSlot CAS-decrements the queue's in_flight_count, reopening the process
278354
// concurrency gate now that this request's build is over. It decrements relatively
279355
// (preserving concurrent updates), clamps at zero, and retries on version conflicts.

0 commit comments

Comments
 (0)