Open-source project
uber-go/tally avatar
uber-go/tally

tally: buffered metrics in Go with reporters you write yourself

A Go metrics interface with fast buffered metrics and third party reporters

909 stars129 forksGoMIT

At a glance

What is it?
tally is a Go metrics interface that lets application code emit counters, gauges, timers and histograms without worrying about how often they are shipped. A scope buffers values on an interval and hands aggregated data to a reporter you implement, and four reporters ship in the box for M3, Prometheus, statsd and fan-out to several backends.
Who is it for?
tally suits a Go service that wants one metrics interface across several backends, or that wants to own its reporter implementation, since the StatsReporter interface is small and the timing of emission is entirely under your control.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The scope buffers, the reporter ships

tally is described as fast, buffered, hierarchical stats collection in Go, and the repository description calls it a Go metrics interface with fast buffered metrics and third party reporters.

The core idea is a separation between the metric API your code uses and the transport that gets the data out. The abstraction has three parts.

A Scope keeps track of metrics and their common metadata. The metric types are counters, gauges, timers and histograms. A Reporter is implemented by you and accepts aggregated values from the scope, forwarding them to your metrics ingestion pipeline.

That word aggregated matters. The scope does not forward every value you record. It accumulates them and hands the reporter a summary on an interval, which is what makes the hot path cheap. The documentation states the abstract plainly: tally provides a common interface for emitting metrics, while letting you not worry about the velocity of metrics emission.

By default it buffers counters, gauges and histograms at a specified interval but does not buffer timer values. The stated reason is that timer values can have all their values sampled if desired, and if not they can be sampled as summaries or histograms independently by a reporter.

That single sentence explains a lot of the library's design. Timer recording is the case where you might want the distribution rather than a mean, and different ingestion pipelines handle that differently, so tally leaves the decision to the reporter rather than deciding it for you at the scope level.

The Scope interface itself is small: Counter, Gauge, Timer and Histogram accessors by name, plus Tagged to return a child scope with extra tags, SubScope to return a child scope with an additional name prefix, and Capabilities to describe what the reporter can do.

Acquiring a scope and caching the metric handles

Creating a root scope takes a reporter, a set of tags and a reporting interval.

go
reporter = NewMyStatsReporter()  // Implement as you will
tags := map[string]string{
	"dc": "east-1",
	"type": "master",
}
reportEvery := time.Second

scope := tally.NewRootScope(tally.ScopeOptions{
	Tags: tags,
	Reporter: reporter,
}, reportEvery)

Two details in that snippet carry design decisions.

The first is that NewRootScope returns a Scope and an io.Closer. The documentation explains why: the second return value is used to stop the scope's goroutine reporting values from the scope to its reporter, and keeping it out of the Scope interface reduces the footprint of Scope from the public API, for those implementing it themselves to use in Go packages that take a tally Scope.

That is a subtle but important choice. If Scope had embedded the closer, every package that accepts a Scope would be accepting a resource with a lifecycle it does not own. Splitting it means a function that takes a Scope can be handed a child scope created by Tagged or SubScope without those functions having to reason about shutdown.

The second detail is in the usage example, where the comment says cache me next to each metric acquisition.

go
// Get a counter, increment a counter
reqCounter := scope.Counter("requests")  // cache me
reqCounter.Inc(1)

queueGauge := scope.Gauge("queue_length")  // cache me
queueGauge.Update(42)

Looking a metric up by name on every use is the obvious thing to do and it is what the library asks you not to do. The scope handles registration in a race-safe way, and the returned handle is the cheap thing to hold. The scope is also hierarchical, so Tagged returns a child scope carrying the parent's tags plus new ones, and SubScope appends a name prefix, which is how you get namespaced metric families without string concatenation at every call site.

Four reporters, and the one capability flag that changes behaviour

The reporters already available are listed alphabetically in the documentation, and their differences are instructive.

The m3 reporter reports m3 metrics, and timers are not sampled and forwarded directly. The multi reporter reports to multiple reporters, so you can multi-write metrics to other reporters simply. The prometheus reporter reports prometheus metrics, with timers by default made summaries and an option to make them histograms instead. The statsd reporter reports statsd metrics, with no support for tags.

Three observations follow.

First, the timer decision is per reporter, and it is the one that changes your data shape. The prometheus reporter defaults to summaries and offers histograms as an option, which matters because summaries cannot be merged across instances while histograms can. A team running three replicas behind a load balancer will get different numbers depending on that setting.

Second, the statsd reporter has no support for tags, so the hierarchical Tagged and SubScope machinery still works for namespacing but the tag metadata does not reach the backend. Any reporter you write yourself has the same constraint if your ingestion pipeline cannot carry labels.

Third, the multi reporter is the escape hatch. It lets you ship to several backends at once, which is what you want during a migration from one metrics system to another, and it is why the reporter interface is small enough to implement per backend.

The Capabilities interface is the other piece worth understanding, and it has two methods.

go
type Capabilities interface {
	// Reporting returns whether the reporter has the ability to actively report.
	Reporting() bool

	// Tagging returns whether the reporter has the capability for tagged metrics.
	Tagging() bool
}

A scope consults this to decide how to behave, so a reporter that cannot report actively can degrade to a pull style, and a reporter that cannot tag will have tags dropped rather than silently mislabelling metrics. This is the library telling you the truth about what your backend can accept, instead of you finding out when the dashboards are wrong.

Implementing your own reporter is the intended extension point

The documentation walks through the interface you implement, and the shape of it tells you how much work a custom backend is.

There is a BaseStatsReporter with two methods, Capabilities and Flush, and the comment describes it as implementing the shared reporter methods.

The StatsReporter interface embeds that and adds one method per metric type. ReportCounter takes a name, tags and an int64 value. ReportGauge takes a name, tags and a float64 value. ReportTimer takes a name, tags and an interval duration. ReportHistogramValueSamples takes a name, tags, buckets, a lower and upper bound and an int64 sample count, and ReportHistogramDurationSamples is the same with time.Duration bounds.

That is five methods plus two from the base interface. The Flush method on the base is the one that matters operationally, because it is how you force out buffered values rather than waiting for the interval.

Note what is not in the interface. There is no method for logging, no error return on the report methods, and no per-metric metadata beyond name and tags. The interface assumes the reporter cannot fail, or that failure is not actionable. If your ingestion pipeline can be down and you need to react to that, you are outside what this interface expresses.

The histogram reporting deserves a note because of how it is shaped. Samples are reported per bucket with explicit lower and upper bounds rather than as a whole distribution, and the bucket set is chosen by the caller at the scope level through the Histogram method, which takes a Buckets value. The scope interface documents the available bucket constructors: DefaultBuckets or nil for the configured defaults, ValueBuckets and DurationBuckets for explicit sets, MustMakeLinearValueBuckets and MustMakeLinearDurationBuckets for a linear range, and MustMakeExponentialValueBuckets and MustMakeExponentialDurationBuckets for an exponential range.

That is a full set of bucket construction options, and it means the bucket boundaries are a property of the scope rather than the reporter. The reporter receives the buckets it was given and the samples in each, and does not decide where the edges are.

The benchmark table and what it claims about the hot path

The performance section states the intent plainly: this stuff needs to be fast, and with that in mind the library avoids locks and unnecessary memory allocations.

The published benchmark numbers are in the documentation, and they are worth reading for the shape rather than the absolute values, since they are from the project's own hardware.

code
BenchmarkCounterInc-8               	200000000	         7.68 ns/op
BenchmarkReportCounterNoData-8      	300000000	         4.88 ns/op
BenchmarkReportCounterWithData-8    	100000000	        21.6 ns/op
BenchmarkGaugeSet-8                 	100000000	        16.0 ns/op
BenchmarkTimerInterval-8            	50000000	        37.7 ns/op
BenchmarkTimerReport-8              	300000000	         5.69 ns/op

The pattern is informative. Incrementing a counter costs single digit nanoseconds because the work is a buffered update with no lock on the hot path. The report path costs more, and notably more with data than without, because with data the scope has to walk and aggregate the values it holds rather than report an unchanged value. The timer interval benchmark is the most expensive operation listed, which fits with the fact that timers are the one metric type that can carry the full set of observed values rather than only a summary.

Note that the Makefile sets BENCH_FLAGS to include -cpuprofile and -memprofile and -benchmem by default, so the profiles come with the benchmark run rather than being a separate step. The repository also has scope_benchmark_test.go and stats_benchmark_test.go, so the numbers are produced by tests in the tree rather than by a separate script.

The honest caveat is that these are the project's own numbers on its own machine, and the documentation does not present the hardware. Treat them as evidence that the design avoids locks and allocations on the hot path, which is a structural claim, rather than as a target latency you should plan around.

Module path, tooling and the state of the release line

There is a version detail in this repository that will bite you if you do not read it.

The go.mod declares the module as github.com/uber-go/tally/v7, with the major version suffix that Go requires for a module at version 7 or above, and it requires Go 1.20. But the installation section of the documentation gives the command go get -u github.com/uber-go/tally, without the /v7 suffix.

Those two are not the same import path. With Go modules, an import of github.com/uber-go/tally and github.com/uber-go/tally/v7 are distinct modules, and mixing an unversioned import with a v7 dependency fails to resolve. Since the module path in go.mod is the authoritative one, the import path in your code has to carry the /v7 suffix, and a go get without it will not fetch the version this tree represents.

The dependency list is otherwise lean and tells you what the library pulls in. There is the statsd client from cactus/go-statsd-client at v5, the Prometheus client library and client model, murmur3 for hashing, uber-go/atomic, and the standard yaml and validator packages. On the test side there is golang/mock, stretchr/testify, and uber-go/goleak, the goroutine leak detector, which is the right tool for a library that runs a reporting goroutine per scope.

The Makefile shows the quality gate: all runs lint and test, and lint is gofmt, golint, gomodtidy, staticcheck and license, in that order. Each check runs the tool and fails the build if the output is non-empty, with ignore patterns for the vendored m3/thrift and thirdparty trees. There is a check_license.sh script at the root, and the license target is part of the default path.

On release cadence, the listed tags are v4.1.17 from 2025-04-02, v4.1.16 from 2024-03-22 and v4.1.12 from 2024-03-08, while the module path says v7 and the last push was on 2026-10-13. The tag list and the module path are therefore not describing the same line, which tells you the version in the go.mod is the current one and the older tags are history from before the major version bumps. Plan to pin a specific version rather than tracking a branch.

The licence is MIT, with the LICENSE file at the root. That is a permissive licence with no copyleft obligations, and for an interface library that most teams adopt without modification, it is about as low friction as it gets. This is not legal advice.

Editorial conclusion

tally suits a Go service that wants one metrics interface across several backends, or that wants to own its reporter implementation, since the StatsReporter interface is small and the timing of emission is entirely under your control. It does not suit a team that wants a zero-code Prometheus setup, since the built in Prometheus reporter still needs a scope wired up and a scrape endpoint exposed, and it does not suit an application whose metrics are best left as raw events rather than periodic aggregates. Before adopting it, check the module path against the version you pin, because the go.mod declares a v7 module path while the install command in the documentation names the unversioned path, and decide up front whether timers should be sampled or summarised, since that choice is fixed per reporter.

Frequently asked questions

What is the tally Go metrics library?

It is a Go metrics interface that provides a common way to emit metrics while letting you not worry about the velocity of metrics emission. A scope buffers counters, gauges and histograms on an interval, and a reporter you implement forwards the aggregated values to your ingestion pipeline.

Which reporters ship with tally?

Four, listed alphabetically: the m3 reporter, which does not sample timers and forwards them directly; the multi reporter, which writes to multiple reporters; the prometheus reporter, which makes timers summaries by default with an option for histograms; and the statsd reporter, which has no support for tags.

What import path should I use for tally?

The go.mod in the repository declares the module as github.com/uber-go/tally/v7, so the import path carries the /v7 suffix, and it requires Go 1.20. The installation line in the documentation shows the unversioned path, which does not match the module declaration.

How do I implement a custom reporter for tally?

Implement the StatsReporter interface, which embeds BaseStatsReporter with Capabilities and Flush, and adds ReportCounter, ReportGauge, ReportTimer, ReportHistogramValueSamples and ReportHistogramDurationSamples. The documentation also shows how to implement your own Scope with different buffering semantics if the default does not suit.

How does tally handle timers differently from other metrics?

By default it buffers counters, gauges and histograms at the specified interval but does not buffer timer values, so timer values can have all their values sampled if desired, or be sampled as summaries or histograms independently by a reporter. The prometheus reporter makes timers summaries by default, with an option to use histograms instead.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. Releases
  5. uber-go/tally on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/uber-go-tally.svg)](https://hysenlabs.com/projects/uber-go-tally)

Was this useful?