Skip to content

Commit 100ff13

Browse files
committed
refactor: use operation metrics for controller results
1 parent c8306ad commit 100ff13

6 files changed

Lines changed: 85 additions & 68 deletions

File tree

platform/consumer/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (c *MyController) Process(ctx context.Context, delivery consumer.Delivery)
118118

119119
When the consumer is wired with `errs.AlwaysRetryableProcessor` (DLQ reconciliation), the framework overrides this: every non-nil error is forced retryable so the DLQ message comes back for another attempt. See `submitqueue/orchestrator/controller/dlq/README.md`.
120120

121-
The consumer records `process.controller_latency` after the error processor runs. Every series has `result=success|error|cancel`; error and cancellation series also include `error_origin=user|infra`, `retryable=true|false`, and `dependency=true|false`. These dimensions therefore describe the processed error that drives ack, nack, or reject behavior rather than the controller's raw return value.
121+
The consumer records controller operations with `process.start` and `process.finish`. The finish histogram records both latency and completion count with `result=success|error|cancel`; error and cancellation series also include `origin=infra|infra_retryable|user` and `dependency=yes|no`. These dimensions are added after error processing, so they describe the classified error that drives ack, nack, or reject behavior rather than the controller's raw return value. The lifecycle histogram count replaces separate received, processed, and controller-error counters.
122122

123123
## Lifecycle
124124

platform/consumer/consumer.go

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21-
"strconv"
2221
"sync"
2322
"time"
2423

@@ -342,9 +341,6 @@ func (m *consumer) processPartition(ctx context.Context, controller Controller,
342341
func (m *consumer) processDelivery(ctx context.Context, controller Controller, delivery extqueue.Delivery, controllerScope tally.Scope) {
343342
const opName = "process"
344343

345-
start := time.Now()
346-
metrics.NamedCounter(controllerScope, opName, "messages_received", 1)
347-
348344
msg := delivery.Message()
349345
topicKey := controller.TopicKey()
350346

@@ -360,27 +356,25 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
360356
wrapped := &deliveryWrapper{delivery: delivery}
361357

362358
// Call controller with wrapped delivery
359+
start := time.Now()
360+
op := metrics.Begin(controllerScope, opName, metrics.LongLatencyBuckets)
363361
err := controller.Process(ctx, wrapped)
364362

365363
elapsed := time.Since(start)
366364

365+
var completionTags []metrics.Tag
367366
if err != nil {
368367
// Single explicit classification pass through the configured
369368
// ErrorProcessor. Primary consumers use a classifier-based processor
370369
// (preserves controller framework wraps); DLQ consumers use the
371370
// always-retryable processor (forces redelivery on any error).
372371
err = m.processor.Process(err)
372+
completionTags = controllerClassificationTags(err)
373373
}
374374

375-
// Record controller latency only after classification so error dimensions
376-
// reflect the verdict used for ack/nack/reject behavior.
377-
metrics.NamedHistogram(
378-
controllerScope,
379-
opName,
380-
"controller_latency",
381-
metrics.LongLatencyBuckets,
382-
controllerResultTags(err)...,
383-
).RecordDuration(elapsed)
375+
// Complete only after classification so the finish histogram carries the
376+
// verdict used for ack/nack/reject behavior.
377+
op.Complete(err, completionTags...)
384378

385379
if err != nil {
386380
// By convention, Controller can only return context.Canceled if it is
@@ -399,8 +393,6 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
399393
"elapsed_ms", elapsed.Milliseconds(),
400394
)
401395

402-
metrics.NamedCounter(controllerScope, opName, "non_retryable_errors", 1)
403-
404396
// Reject moves to DLQ (or acks if DLQ disabled)
405397
if rejectErr := delivery.Reject(ctx, err.Error()); rejectErr != nil {
406398
m.logger.Errorw("failed to reject non-retryable message",
@@ -431,8 +423,6 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
431423
"elapsed_ms", elapsed.Milliseconds(),
432424
)
433425

434-
metrics.NamedCounter(controllerScope, opName, "controller_errors", 1)
435-
436426
// Nack with no delay - let visibility timeout handle retry delay
437427
nackStart := time.Now()
438428
if nackErr := delivery.Nack(ctx, 0); nackErr != nil {
@@ -470,7 +460,6 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
470460
return
471461
}
472462

473-
metrics.NamedCounter(controllerScope, opName, "messages_processed", 1)
474463
metrics.NamedCounter(controllerScope, opName, "ack_count", 1)
475464
metrics.NamedHistogram(controllerScope, opName, "ack_nack_latency", metrics.StorageLatencyBuckets,
476465
metrics.NewTag("operation", "ack"),
@@ -487,27 +476,22 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
487476
)
488477
}
489478

490-
func controllerResultTags(err error) []metrics.Tag {
491-
result := "success"
492-
if err == nil {
493-
return []metrics.Tag{metrics.NewTag("result", result)}
494-
}
495-
496-
result = "error"
497-
if errors.Is(err, context.Canceled) {
498-
result = "cancel"
499-
}
500-
479+
func controllerClassificationTags(err error) []metrics.Tag {
501480
origin := "infra"
502-
if errs.IsUserError(err) {
481+
if errs.IsRetryable(err) {
482+
origin = "infra_retryable"
483+
} else if errs.IsUserError(err) {
503484
origin = "user"
504485
}
505486

487+
dependency := "no"
488+
if errs.IsDependencyError(err) {
489+
dependency = "yes"
490+
}
491+
506492
return []metrics.Tag{
507-
metrics.NewTag("result", result),
508-
metrics.NewTag("error_origin", origin),
509-
metrics.NewTag("retryable", strconv.FormatBool(errs.IsRetryable(err))),
510-
metrics.NewTag("dependency", strconv.FormatBool(errs.IsDependencyError(err))),
493+
metrics.NewTag("origin", origin),
494+
metrics.NewTag("dependency", dependency),
511495
}
512496
}
513497

platform/consumer/consumer_test.go

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -459,10 +459,9 @@ func TestConsumer_ObservabilityTags(t *testing.T) {
459459
verdict: errs.InfraDependencyRetryable,
460460
}),
461461
expectedTags: map[string]string{
462-
"result": "error",
463-
"error_origin": "infra",
464-
"retryable": "true",
465-
"dependency": "true",
462+
"result": "error",
463+
"origin": "infra_retryable",
464+
"dependency": "yes",
466465
},
467466
expectAckCount: false,
468467
},
@@ -474,10 +473,9 @@ func TestConsumer_ObservabilityTags(t *testing.T) {
474473
verdict: errs.InfraRetryable,
475474
}),
476475
expectedTags: map[string]string{
477-
"result": "cancel",
478-
"error_origin": "infra",
479-
"retryable": "true",
480-
"dependency": "false",
476+
"result": "cancel",
477+
"origin": "infra_retryable",
478+
"dependency": "no",
481479
},
482480
expectAckCount: false,
483481
},
@@ -530,18 +528,27 @@ func TestConsumer_ObservabilityTags(t *testing.T) {
530528

531529
var foundLatency bool
532530
for _, histogram := range histograms {
533-
if strings.Contains(histogram.Name(), "controller_latency") {
531+
if strings.Contains(histogram.Name(), "process.finish") {
534532
foundLatency = true
535533
tags := histogram.Tags()
536534
for key, value := range tt.expectedTags {
537535
assert.Equal(t, value, tags[key])
538536
}
539-
assert.NotContains(t, tags, "success")
540537
}
541538
}
542-
assert.True(t, foundLatency, "Should have controller_latency metric")
539+
assert.True(t, foundLatency, "Should have process.finish metric")
543540

544541
counters := snapshot.Counters()
542+
for _, duplicate := range []string{
543+
"messages_received",
544+
"messages_processed",
545+
"non_retryable_errors",
546+
"controller_errors",
547+
} {
548+
for _, counter := range counters {
549+
assert.NotContains(t, counter.Name(), duplicate)
550+
}
551+
}
545552
if tt.expectAckCount {
546553
var foundAck bool
547554
for _, counter := range counters {
@@ -558,52 +565,50 @@ func TestConsumer_ObservabilityTags(t *testing.T) {
558565
}
559566
}
560567

561-
func TestControllerResultTags(t *testing.T) {
568+
func TestControllerClassificationTags(t *testing.T) {
562569
tests := []struct {
563570
name string
564571
err error
565572
expected map[string]string
566573
}{
567574
{
568-
name: "success",
569-
expected: map[string]string{"result": "success"},
575+
name: "infra error",
576+
err: fmt.Errorf("failed"),
577+
expected: map[string]string{
578+
"origin": "infra",
579+
"dependency": "no",
580+
},
570581
},
571582
{
572583
name: "user error",
573584
err: errs.NewUserError(fmt.Errorf("invalid request")),
574585
expected: map[string]string{
575-
"result": "error",
576-
"error_origin": "user",
577-
"retryable": "false",
578-
"dependency": "false",
586+
"origin": "user",
587+
"dependency": "no",
579588
},
580589
},
581590
{
582591
name: "retryable dependency error",
583592
err: errs.NewRetryableDependencyError(fmt.Errorf("database unavailable")),
584593
expected: map[string]string{
585-
"result": "error",
586-
"error_origin": "infra",
587-
"retryable": "true",
588-
"dependency": "true",
594+
"origin": "infra_retryable",
595+
"dependency": "yes",
589596
},
590597
},
591598
{
592599
name: "cancellation",
593600
err: errs.NewRetryableError(context.Canceled),
594601
expected: map[string]string{
595-
"result": "cancel",
596-
"error_origin": "infra",
597-
"retryable": "true",
598-
"dependency": "false",
602+
"origin": "infra_retryable",
603+
"dependency": "no",
599604
},
600605
},
601606
}
602607

603608
for _, tt := range tests {
604609
t.Run(tt.name, func(t *testing.T) {
605610
actual := make(map[string]string)
606-
for _, tag := range controllerResultTags(tt.err) {
611+
for _, tag := range controllerClassificationTags(tt.err) {
607612
actual[tag.Key] = tag.Value
608613
}
609614
assert.Equal(t, tt.expected, actual)

platform/metrics/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The `metrics` package provides reusable helpers for emitting counters and histog
88

99
**Operation lifecycle**`Begin` and `Complete` tie operation metrics together. `Begin` captures the start time and emits `{name}.start`; `Complete` records duration and count on `{name}.finish`.
1010

11-
**Result tagging** — the finish histogram is tagged with `result=success`, `result=error`, or `result=cancel`. Cancellation is detected with `errors.Is(err, context.Canceled)`. Error classification tags are intentionally omitted because many call sites complete before classification occurs.
11+
**Result tagging** — the finish histogram is tagged with `result=success`, `result=error`, or `result=cancel`. Cancellation is detected with `errors.Is(err, context.Canceled)`. Callers that accumulate tags while the operation runs can pass them to `Complete`.
1212

1313
**Consistent naming** — named helpers follow the `{name}.{sub}` sub-scope pattern, producing metric paths such as `process.start` and `publish.attempts`.
1414

@@ -19,7 +19,7 @@ For any operation with a clear start and end, use `Begin` and `Complete`:
1919
| Function | Emits |
2020
|----------|-------|
2121
| `Begin(scope, name, buckets, ...tags)` | `{name}.start` counter +1 and returns an `Op` |
22-
| `op.Complete(err)` | `{name}.finish` histogram tagged with `result=success\|error\|cancel` |
22+
| `op.Complete(err, ...tags)` | `{name}.finish` histogram tagged with `result=success\|error\|cancel` and any completion tags |
2323

2424
`buckets` is required at `Begin` because operations differ widely in expected latency. The finish histogram records both the duration distribution and the number of completed operations, so `Complete` does not emit a separate counter.
2525

@@ -33,6 +33,14 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
3333
}
3434
```
3535

36+
Tags passed to `Begin` apply to both lifecycle metrics. Tags known only after execution, such as an error classification, can be attached to the finish histogram:
37+
38+
```go
39+
err := controller.Process(ctx, delivery)
40+
err = classifier.Process(err)
41+
op.Complete(err, metrics.NewTag("origin", "infra_retryable"))
42+
```
43+
3644
## Named Helpers
3745

3846
For ad-hoc metrics that do not fit the operation lifecycle:

platform/metrics/metrics.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ func Begin(scope tally.Scope, name string, buckets tally.Buckets, tags ...Tag) O
141141
}
142142

143143
// Complete records elapsed time on the {name}.finish histogram, tagged with
144-
// result=success|error|cancel. The histogram records both duration and count.
145-
// Cancellation is detected through the error chain.
146-
func (o Op) Complete(err error) {
144+
// result=success|error|cancel and any additional tags accumulated while the
145+
// operation ran. The histogram records both duration and count. Cancellation
146+
// is detected through the error chain.
147+
func (o Op) Complete(err error, tags ...Tag) {
147148
result := "success"
148149
if err != nil {
149150
result = "error"
@@ -152,7 +153,7 @@ func (o Op) Complete(err error) {
152153
}
153154
}
154155

155-
o.scope.
156+
tagged(o.scope, tags).
156157
Tagged(map[string]string{"result": result}).
157158
Histogram("finish", o.buckets).
158159
RecordDuration(time.Since(o.start))

platform/metrics/metrics_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ func TestBegin_WithTags(t *testing.T) {
106106
assert.True(t, ok, "expected tagged finish histogram, got keys: %v", histogramKeys(histograms))
107107
}
108108

109+
func TestOp_CompleteWithTags(t *testing.T) {
110+
scope := tally.NewTestScope("", nil)
111+
op := Begin(scope, "process", FastLatencyBuckets)
112+
op.Complete(
113+
fmt.Errorf("failed"),
114+
NewTag("origin", "infra_retryable"),
115+
NewTag("dependency", "no"),
116+
)
117+
118+
snapshot := scope.Snapshot()
119+
counters := snapshot.Counters()
120+
_, ok := counters["process.start+"]
121+
assert.True(t, ok, "finish-only tags should not be applied to start")
122+
123+
histograms := snapshot.Histograms()
124+
_, ok = histograms["process.finish+dependency=no,origin=infra_retryable,result=error"]
125+
assert.True(t, ok, "expected finish-only tags, got keys: %v", histogramKeys(histograms))
126+
}
127+
109128
func TestNamedCounter(t *testing.T) {
110129
scope := tally.NewTestScope("", nil)
111130
NamedCounter(scope, "publish", "attempts", 5)

0 commit comments

Comments
 (0)