diff --git a/docs/observability/metrics.md b/docs/observability/metrics.md new file mode 100644 index 00000000..7579020b --- /dev/null +++ b/docs/observability/metrics.md @@ -0,0 +1,137 @@ +# observability/metrics + +Tango's metrics library. A thin, opinionated wrapper over `tally.Scope` that pins the naming, and tag conventions Tango consumers emit so dashboards can query by tag rather than by name merging. + +## Why + +Tango is a library. Consumers wire it into their own `fx` graph with their own `tally.Scope`, and often deploy multiple flavors (long-running server, batch job, one-shot CLI) side-by-side. Without a shared convention every deployment invents its own metric names and tag layout, and dashboards devolve into per-name merges. + +This package owns: + +- op-as-subscope emission (`e.Inc("get_target_graph", "requests")` lands under `.get_target_graph.requests`) +- shared metric-name, op-name, and tag-key constants +- default histogram buckets sized for RPC latency and large target counts +- an in-flight gauge helper that owns the atomic counter callers otherwise reinvent +- a failure classifier that reuses the errors package +- context-based emitter propagation so downstream helpers do not need the emitter threaded through every signature + +## Layout + +``` +observability/metrics/ +├── emitter.go — Emitter, TrackInFlight +├── options.go — Option, WithTags, WithDurationBuckets, WithValueBuckets +├── buckets.go — default histogram buckets +├── names.go — Op*, metric name, Tag*, Result* constants +├── failure.go — RecordFailure +└── context.go — WithEmitter, FromContext +``` + +## Emitter + +`Emitter` is an interface. `New(scope)` returns the default tally-backed implementation; `New(nil)` returns one backed by `tally.NoopScope` so callers do not need to special-case unwired metrics. This also serves as a noop fallback. + +```go +type Emitter interface { + Inc(op, name string, opts ...Option) + Gauge(op, name string, v float64, opts ...Option) + RecordDur(op, name string, d time.Duration, opts ...Option) + RecordCount(op, name string, v int64, opts ...Option) + Tagged(tags map[string]string) Emitter +} + +type defaultEmitter struct { + scope tally.Scope + baseTags map[string]string + durationBuckets tally.DurationBuckets + valueBuckets tally.ValueBuckets +} +``` + +`New(scope)` returns the default tally-backed implementation; `New(nil)` returns one backed by `tally.NoopScope` so callers do not need to special-case unwired metrics. + +`Tagged` returns a child Emitter that adds tags to every subsequent emission. The parent is unchanged. The child shares the in-flight counter map, so a `TrackInFlight` increment on a parent and its decrement on a tagged child balance to zero. + +Options are variadic and compose. `WithTags` merges into the base tag set with later-wins semantics on key collision. `WithDurationBuckets` / `WithValueBuckets` override the emitter's default histogram buckets on a single call. + +## Op-as-subscope + +Every emit call takes an op name as its first argument. `e.Inc("get_target_graph", "requests")` emits under `.get_target_graph.requests`. Consumers that own their own ops (an extension RPC, a background job) should declare `const opXYZ = "xyz"` next to the emit site rather than adding to `names.go`. + +## TrackInFlight + +Gauges are update-only, so an in-flight counter needs an owning atomic somewhere. Rather than force every call site to allocate its own `atomic.Int64` and remember to update the gauge on both the increment and the decrement, the Emitter owns a per-op `*int64` and exposes: + +```go +defer c.emitter.TrackInFlight(metrics.OpGetTargetGraph)() +``` + +The counter map lives on the root Emitter, so a `Tagged` child shares the counter with its parent — a paired increment/decrement across a `Tagged` boundary always refers to the same gauge series. The emitted gauge carries `TagOperation=op` so a single time-series carries the count for every operation, split by op in the dashboard. + +## RecordFailure + +Every RPC calls one of: + +- `e.Inc(op, Requests, WithTags({result: success}))` on the happy path +- `metrics.RecordFailure(e, op, err)` on the sad path + +Both write to the same `.requests` counter, distinguished only by tag. `RecordFailure` derives `failure_type` and `failure_source` from the error via the `observability/errors` package (separate design doc). Unclassified errors fall back to `unknown` / `infra`; `context.Canceled` / `context.DeadlineExceeded` shortcut to user-side types. + +`RecordFailure` is a no-op on nil. + +## Context propagation + +Emitter is passed via contexts so downstream helpers don't need it threaded through every signature. + +```go +type emitterKey struct{} +var noopEmitter = New(nil) + +func WithEmitter(ctx context.Context, e *Emitter) context.Context { + return context.WithValue(ctx, emitterKey{}, e) +} +func FromContext(ctx context.Context) *Emitter { // should never return nil + e, ok := ctx.Value(emitterKey{}).(*Emitter) + if !ok { return noopEmitter } + return e +} +``` + +The RPC handler applies request-scope tags once and installs the tagged Emitter on the context. Helpers pull it back out with `FromContext`, which never returns nil — a missing key falls back to a package-level noop. + +## Constants + +All shared constants live in `names.go`: operation names (`OpGetTargetGraph`, `OpGetChangedTargets`, ...), metric names (`Requests`, `TotalDuration`, `InFlightRequests`, ...), tag keys (`TagRepo`, `TagEmitter`, `TagResult`, `TagFailureType`, `TagFailureSource`, ...), and `Result*` values (`ResultSuccess`, `ResultFail`, `ResultHit`, `ResultMiss`). + +Failure tag values come from `observability/errors`. Packages that introduce a new failure source declare the constant next to the failure site. + +## Usage + +```go +func NewController(appCtx context.Context, p Params) pb.TangoYARPCServer { + emitter := metrics.New(p.Scope).Tagged(map[string]string{ + metrics.TagEmitter: "server", + }) + return &controller{emitter: emitter, /* ... */} +} + +func (c *controller) GetChangedTargets(req *pb.GetChangedTargetsRequest, stream pb...) (retErr error) { + defer c.emitter.TrackInFlight(metrics.OpGetChangedTargets)() + e := c.emitter.Tagged(map[string]string{ + metrics.TagRepo: common.ToShortRemote(req.GetFirstRevision().GetRemote()), + }) + ctx := metrics.WithEmitter(stream.Context(), e) + start := time.Now() + defer func() { + e.RecordDur(metrics.OpGetChangedTargets, metrics.TotalDuration, time.Since(start)) + if retErr != nil { + metrics.RecordFailure(e, metrics.OpGetChangedTargets, retErr) + } else { + e.Inc(metrics.OpGetChangedTargets, metrics.Requests, + metrics.WithTags(map[string]string{metrics.TagResult: string(metrics.ResultSuccess)})) + } + }() + // ... downstream helpers pull the tagged emitter via metrics.FromContext(ctx) + return nil +} +``` diff --git a/observability/metrics/BUILD.bazel b/observability/metrics/BUILD.bazel new file mode 100644 index 00000000..b4ec2ff6 --- /dev/null +++ b/observability/metrics/BUILD.bazel @@ -0,0 +1,28 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "metrics", + srcs = [ + "buckets.go", + "emitter.go", + "names.go", + "options.go", + ], + importpath = "github.com/uber/tango/observability/metrics", + visibility = ["//visibility:public"], + deps = ["@com_github_uber_go_tally//:tally"], +) + +go_test( + name = "metrics_test", + srcs = [ + "emitter_test.go", + "inflight_test.go", + ], + embed = [":metrics"], + deps = [ + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@com_github_uber_go_tally//:tally", + ], +) diff --git a/observability/metrics/buckets.go b/observability/metrics/buckets.go new file mode 100644 index 00000000..69247518 --- /dev/null +++ b/observability/metrics/buckets.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metrics + +import ( + "time" + + "github.com/uber-go/tally" +) + +var _defaultDurationBuckets = tally.MustMakeExponentialDurationBuckets(time.Millisecond, 2.0, 27) + +var _defaultCountBuckets = tally.MustMakeExponentialValueBuckets(1.0, 2.0, 24) diff --git a/observability/metrics/emitter.go b/observability/metrics/emitter.go new file mode 100644 index 00000000..08c9e254 --- /dev/null +++ b/observability/metrics/emitter.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package metrics is a thin wrapper over tally.Scope that pins the naming, +// bucket, and tag conventions defined in the Tango Metrics Inventory. +package metrics + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/uber-go/tally" +) + +// Emitter is the interface for emitting metrics. +type Emitter interface { + Inc(op, name string, opts ...Option) + Gauge(op, name string, v float64, opts ...Option) + RecordDur(op, name string, d time.Duration, opts ...Option) + RecordCount(op, name string, v int64, opts ...Option) + TrackInFlight(op string) func() + Tagged(tags map[string]string) Emitter +} + +type defaultEmitter struct { + scope tally.Scope + baseTags map[string]string + durationBuckets tally.DurationBuckets + valueBuckets tally.ValueBuckets + inFlight *sync.Map +} + +// New returns a tally-backed Emitter. A nil scope falls back to +// tally.NoopScope so callers do not need to special-case unwired metrics. +func New(scope tally.Scope) Emitter { + if scope == nil { + scope = tally.NoopScope + } + return &defaultEmitter{ + scope: scope, + durationBuckets: _defaultDurationBuckets, + valueBuckets: _defaultCountBuckets, + inFlight: &sync.Map{}, + } +} + +func (e *defaultEmitter) Tagged(tags map[string]string) Emitter { + if len(tags) == 0 { + return e + } + merged := make(map[string]string, len(e.baseTags)+len(tags)) + for k, v := range e.baseTags { + merged[k] = v + } + for k, v := range tags { + merged[k] = v + } + return &defaultEmitter{ + scope: e.scope, + baseTags: merged, + durationBuckets: e.durationBuckets, + valueBuckets: e.valueBuckets, + inFlight: e.inFlight, + } +} + +func (e *defaultEmitter) Inc(op, name string, opts ...Option) { + o := e.applyOptions(opts) + e.subscope(op, o.tags).Counter(name).Inc(1) +} + +func (e *defaultEmitter) Gauge(op, name string, v float64, opts ...Option) { + o := e.applyOptions(opts) + e.subscope(op, o.tags).Gauge(name).Update(v) +} + +func (e *defaultEmitter) RecordDur(op, name string, d time.Duration, opts ...Option) { + o := e.applyOptions(opts) + buckets := o.durationBuckets + if buckets == nil { + buckets = e.durationBuckets + } + e.subscope(op, o.tags).Histogram(name, buckets).RecordDuration(d) +} + +func (e *defaultEmitter) RecordCount(op, name string, v int64, opts ...Option) { + o := e.applyOptions(opts) + buckets := o.valueBuckets + if buckets == nil { + buckets = e.valueBuckets + } + e.subscope(op, o.tags).Histogram(name, buckets).RecordValue(float64(v)) +} + +func (e *defaultEmitter) subscope(op string, callTags map[string]string) tally.Scope { + s := e.scope.SubScope(op) + if len(e.baseTags) == 0 && len(callTags) == 0 { + return s + } + merged := make(map[string]string, len(e.baseTags)+len(callTags)) + for k, v := range e.baseTags { + merged[k] = v + } + for k, v := range callTags { + merged[k] = v + } + return s.Tagged(merged) +} + +func (e *defaultEmitter) applyOptions(opts []Option) emitOpts { + var o emitOpts + for _, opt := range opts { + opt.apply(&o) + } + return o +} + +func (e *defaultEmitter) TrackInFlight(op string) func() { + v, _ := e.inFlight.LoadOrStore(op, new(int64)) + counter := v.(*int64) + e.emitInFlight(op, atomic.AddInt64(counter, 1)) + return func() { + e.emitInFlight(op, atomic.AddInt64(counter, -1)) + } +} + +func (e *defaultEmitter) emitInFlight(op string, n int64) { + e.scope.Tagged(map[string]string{TagOperation: op}).Gauge(InFlightRequests).Update(float64(n)) +} diff --git a/observability/metrics/emitter_test.go b/observability/metrics/emitter_test.go new file mode 100644 index 00000000..1e7e38fc --- /dev/null +++ b/observability/metrics/emitter_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metrics + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" +) + +func counterValue(t *testing.T, s tally.TestScope, name string, tags map[string]string) int64 { + t.Helper() + for _, c := range s.Snapshot().Counters() { + if c.Name() == name && tagsEqual(c.Tags(), tags) { + return c.Value() + } + } + return 0 +} + +func gaugeValue(t *testing.T, s tally.TestScope, name string, tags map[string]string) (float64, bool) { + t.Helper() + for _, g := range s.Snapshot().Gauges() { + if g.Name() == name && tagsEqual(g.Tags(), tags) { + return g.Value(), true + } + } + return 0, false +} + +func histogramDurationSamples(t *testing.T, s tally.TestScope, name string, tags map[string]string) int { + t.Helper() + total := 0 + for _, h := range s.Snapshot().Histograms() { + if h.Name() != name || !tagsEqual(h.Tags(), tags) { + continue + } + for _, n := range h.Durations() { + total += int(n) + } + } + return total +} + +func histogramValueSamples(t *testing.T, s tally.TestScope, name string, tags map[string]string) int { + t.Helper() + total := 0 + for _, h := range s.Snapshot().Histograms() { + if h.Name() != name || !tagsEqual(h.Tags(), tags) { + continue + } + for _, n := range h.Values() { + total += int(n) + } + } + return total +} + +func tagsEqual(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func TestNewNilScopeIsNoop(t *testing.T) { + e := New(nil) + require.NotNil(t, e) + e.Inc("op", "n") + e.Gauge("op", "n", 1) + e.RecordDur("op", "n", time.Millisecond) + e.RecordCount("op", "n", 42) +} + +func TestIncEmitsUnderOpSubscope(t *testing.T) { + s := tally.NewTestScope("root", nil) + e := New(s) + e.Inc("get_target_graph", "requests") + assert.Equal(t, int64(1), counterValue(t, s, "root.get_target_graph.requests", map[string]string{})) +} + +func TestIncMergesTags(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s).Tagged(map[string]string{TagEmitter: "service", TagRepo: "acme/monorepo"}) + e.Inc("op", "requests", WithTags(map[string]string{TagResult: string(ResultSuccess)})) + assert.Equal(t, int64(1), counterValue(t, s, "op.requests", map[string]string{ + TagEmitter: "service", + TagRepo: "acme/monorepo", + TagResult: string(ResultSuccess), + })) + e.Inc("op", "requests", + WithTags(map[string]string{TagResult: string(ResultSuccess)}), + WithTags(map[string]string{TagResult: string(ResultFail)}), + ) + assert.Equal(t, int64(1), counterValue(t, s, "op.requests", map[string]string{ + TagEmitter: "service", + TagRepo: "acme/monorepo", + TagResult: string(ResultFail), + })) +} + +func TestTaggedDoesNotMutateParent(t *testing.T) { + s := tally.NewTestScope("", nil) + parent := New(s).Tagged(map[string]string{TagEmitter: "service"}) + child := parent.Tagged(map[string]string{TagRepo: "acme"}) + + child.Inc("op", "requests") + parent.Inc("op", "requests") + + assert.Equal(t, int64(1), counterValue(t, s, "op.requests", map[string]string{ + TagEmitter: "service", + TagRepo: "acme", + })) + assert.Equal(t, int64(1), counterValue(t, s, "op.requests", map[string]string{ + TagEmitter: "service", + })) +} + +func TestTaggedEmptyReturnsSameEmitter(t *testing.T) { + e := New(nil) + assert.Same(t, e, e.Tagged(nil)) + assert.Same(t, e, e.Tagged(map[string]string{})) +} + +func TestGaugeUpdatesValue(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + e.Gauge("op", "in_flight_requests", 3, WithTags(map[string]string{TagOperation: "op"})) + v, ok := gaugeValue(t, s, "op.in_flight_requests", map[string]string{TagOperation: "op"}) + require.True(t, ok) + assert.Equal(t, float64(3), v) +} + +func TestRecordDurUsesDefaultBuckets(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + e.RecordDur("op", "total_duration", 5*time.Millisecond) + assert.Equal(t, 1, histogramDurationSamples(t, s, "op.total_duration", map[string]string{})) +} + +func TestRecordDurBucketsOverride(t *testing.T) { + custom := tally.MustMakeLinearDurationBuckets(time.Millisecond, time.Millisecond, 5) + s := tally.NewTestScope("", nil) + e := New(s) + e.RecordDur("op", "n", 2*time.Millisecond, WithDurationBuckets(custom)) + assert.Equal(t, 1, histogramDurationSamples(t, s, "op.n", map[string]string{})) +} + +func TestRecordCountUsesDefaultBuckets(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + e.RecordCount("op", "targets_count", 128) + assert.Equal(t, 1, histogramValueSamples(t, s, "op.targets_count", map[string]string{})) +} + +func TestRecordCountBucketsOverride(t *testing.T) { + custom := tally.MustMakeLinearValueBuckets(0, 1, 5) + s := tally.NewTestScope("", nil) + e := New(s) + e.RecordCount("op", "n", 2, WithValueBuckets(custom)) + assert.Equal(t, 1, histogramValueSamples(t, s, "op.n", map[string]string{})) +} diff --git a/observability/metrics/inflight_test.go b/observability/metrics/inflight_test.go new file mode 100644 index 00000000..52400165 --- /dev/null +++ b/observability/metrics/inflight_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metrics + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" +) + +func TestTrackInFlightBalances(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + release1 := e.TrackInFlight(OpGetTargetGraph) + release2 := e.TrackInFlight(OpGetTargetGraph) + + v, ok := gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + require.True(t, ok) + assert.Equal(t, float64(2), v) + + release1() + release2() + + v, _ = gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + assert.Equal(t, float64(0), v) +} + +func TestTrackInFlightSeparateOps(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + defer e.TrackInFlight(OpGetTargetGraph)() + defer e.TrackInFlight(OpGetChangedTargets)() + defer e.TrackInFlight(OpGetChangedTargets)() + + v1, _ := gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + v2, _ := gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetChangedTargets}) + assert.Equal(t, float64(1), v1) + assert.Equal(t, float64(2), v2) +} + +// A Tagged child must share the parent's counter — otherwise gauges leak if a +// handler tags between the increment and the deferred release. +func TestTrackInFlightSharedAcrossTaggedChildren(t *testing.T) { + s := tally.NewTestScope("", nil) + parent := New(s) + child := parent.Tagged(map[string]string{TagRepo: "acme"}) + + release := parent.TrackInFlight(OpGetTargetGraph) + release2 := child.TrackInFlight(OpGetTargetGraph) + + v, _ := gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + assert.Equal(t, float64(2), v) + + release() + release2() + + v, _ = gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + assert.Equal(t, float64(0), v) +} + +func TestTrackInFlightConcurrent(t *testing.T) { + s := tally.NewTestScope("", nil) + e := New(s) + + const workers = 32 + const iterations = 500 + + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + e.TrackInFlight(OpGetTargetGraph)() + } + }() + } + wg.Wait() + + v, _ := gaugeValue(t, s, InFlightRequests, map[string]string{TagOperation: OpGetTargetGraph}) + assert.Equal(t, float64(0), v) +} diff --git a/observability/metrics/names.go b/observability/metrics/names.go new file mode 100644 index 00000000..463a7230 --- /dev/null +++ b/observability/metrics/names.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metrics + +// Operation names. Internal-only ops should be declared next to their emit +// site rather than added here. +const ( + OpGetTargetGraph = "get_target_graph" + OpGetChangedTargets = "get_changed_targets" + OpGetChangedTargetGraph = "get_changed_target_graph" + OpGetGraph = "get_graph" + OpCompareTargetGraphs = "compare_target_graphs" + OpNativeOrchestrator = "native_orchestrator" + OpGraphRunner = "graph_runner" +) + +// Metric names. +const ( + Requests = "requests" + TreehashCacheLookup = "treehash_cache_lookup" + GraphCacheLookup = "graph_cache_lookup" + ChangedTargetsCacheLookup = "changed_targets_cache_lookup" + GraphCacheFetchDuration = "graph_cache_fetch_duration" + ChangedTargetsCacheFetchDuration = "changed_targets_cache_fetch_duration" + CompareDuration = "compare_duration" + ChangedTargetsCount = "changed_targets_count" + TotalDuration = "total_duration" + Patch = "patch" + PatchDuration = "patch_duration" + BazelQueryDuration = "bazel_query_duration" + GitFileHashesDuration = "git_file_hashes_duration" + TargetHashDuration = "target_hash_duration" + TargetsCount = "targets_count" + StorageUploadDuration = "storage_upload_duration" + InFlightRequests = "in_flight_requests" + WorkspacesInFlight = "in_flight_workspaces" +) + +// Tag keys. +const ( + TagRepo = "repo" + TagEmitter = "emitter" + TagResult = "result" + TagOperation = "operation" + TagFailureType = "failure_type" + TagFailureReason = "failure_reason" +) + +// Result is the value type for TagResult. +type Result string + +// Result values for TagResult. +const ( + ResultUnknown Result = "unknown" + ResultSuccess Result = "success" + ResultFail Result = "fail" + ResultHit Result = "hit" + ResultMiss Result = "miss" +) diff --git a/observability/metrics/options.go b/observability/metrics/options.go new file mode 100644 index 00000000..76e30007 --- /dev/null +++ b/observability/metrics/options.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metrics + +import "github.com/uber-go/tally" + +// Option customizes a single emission. Options compose; later WithTags +// override earlier on key collision. +type Option interface { + apply(*emitOpts) +} + +type emitOpts struct { + tags map[string]string + durationBuckets tally.DurationBuckets + valueBuckets tally.ValueBuckets +} + +type optFunc func(*emitOpts) + +func (f optFunc) apply(o *emitOpts) { f(o) } + +// WithTags attaches key/value tags to a single emission. +func WithTags(tags map[string]string) Option { + return optFunc(func(o *emitOpts) { + if len(tags) == 0 { + return + } + if o.tags == nil { + o.tags = make(map[string]string, len(tags)) + } + for k, v := range tags { + o.tags[k] = v + } + }) +} + +// WithDurationBuckets overrides the emitter's default duration buckets for +// a single RecordDur call. +func WithDurationBuckets(b tally.DurationBuckets) Option { + return optFunc(func(o *emitOpts) { o.durationBuckets = b }) +} + +// WithValueBuckets overrides the emitter's default value buckets for a +// single RecordCount call. +func WithValueBuckets(b tally.ValueBuckets) Option { + return optFunc(func(o *emitOpts) { o.valueBuckets = b }) +}