Implementing a Basic Metrics Collector in Go
Modern applications rely heavily on monitoring to understand their performance, identify bottlenecks, and detect issues. This challenge asks you to build a foundational metrics collection system in Go. You will create a system capable of tracking various types of metrics (counters, gauges, histograms) and exposing them in a human-readable format, similar to how popular monitoring systems like Prometheus operate.
Problem Description
Your task is to design and implement a Go package that provides a simple, in-memory metrics collection and exposition system. This system should be able to:
- Define and Register Metrics: Allow users to define different types of metrics (Counter, Gauge, Histogram) and register them with a central collector. Each metric should have a unique name and optionally a set of labels.
- Update Metric Values: Provide methods to increment counters, set gauge values, and observe values for histograms.
- Expose Metrics: Implement a function that serializes all registered metrics into a plain-text format suitable for scraping by a monitoring system.
Key Requirements
- Metric Types: Support at least three core metric types:
- Counter: A cumulative metric that only ever goes up. Useful for counting events like requests served or errors encountered.
- Gauge: A metric that represents a single numerical value that can arbitrarily go up and down. Useful for measuring current usage like memory usage or queue length.
- Histogram: A metric that samples observations (typically request durations or response sizes) and counts them in configurable buckets. It also provides a sum of all observed values and the count of observations.
- Labels: Metrics should support key-value labels for dimensionality. For example, a counter for requests could have labels like
method="GET"andpath="/users". - Thread-Safety: The metrics collector and its associated metric types must be thread-safe to allow concurrent updates from multiple goroutines.
- Text Exposition Format: The
Exposefunction should produce output conforming to a simple text-based exposition format. Each metric should be on its own line. For metrics with labels, the labels should be appended in curly braces. For example:metric_name 10(for a counter without labels)metric_name{label1="value1",label2="value2"} 10(for a metric with labels)- For histograms, provide the count, sum, and bucket observations.
Expected Behavior
- When a metric is registered, it should be uniquely identified by its name and label set.
- Updates to metrics should be reflected in subsequent expositions.
- The exposition format should be clear and consistent.
Edge Cases to Consider
- Registering a metric with the same name and labels multiple times should ideally be handled gracefully (e.g., return an error or update the existing metric).
- Handling metric names and label values that might contain special characters.
- Concurrent access to update and expose metrics.
Examples
Example 1: Basic Counter
// Assume a collector instance `c` is created
// Register a simple counter
c.RegisterCounter("http_requests_total", nil) // nil labels for no labels
// Update the counter
c.IncrementCounter("http_requests_total", nil)
c.IncrementCounter("http_requests_total", nil)
c.IncrementCounter("http_requests_total", nil)
// Expose the metrics
// Expected Output:
// # HELP http_requests_total Total number of HTTP requests.
// # TYPE http_requests_total counter
// http_requests_total 3
Example 2: Counter with Labels
// Assume a collector instance `c` is created
// Register a counter with labels
c.RegisterCounter("http_requests_total", []string{"method", "path"})
// Update the counter with specific labels
c.IncrementCounter("http_requests_total", map[string]string{"method": "GET", "path": "/users"})
c.IncrementCounter("http_requests_total", map[string]string{"method": "POST", "path": "/users"})
c.IncrementCounter("http_requests_total", map[string]string{"method": "GET", "path": "/users"}) // Another GET to /users
c.IncrementCounter("http_requests_total", map[string]string{"method": "GET", "path": "/products"})
// Expose the metrics
// Expected Output (order of labels might vary):
// # HELP http_requests_total Total number of HTTP requests.
// # TYPE http_requests_total counter
// http_requests_total{method="GET",path="/users"} 2
// http_requests_total{method="POST",path="/users"} 1
// http_requests_total{method="GET",path="/products"} 1
Example 3: Gauge and Histogram
// Assume a collector instance `c` is created
// Register a gauge
c.RegisterGauge("current_goroutines", nil)
// Set the gauge value
c.SetGauge("current_goroutines", nil, 150)
c.SetGauge("current_goroutines", nil, 145)
// Register a histogram for request durations (in milliseconds)
// Buckets: 0.1, 0.5, 1, 5, 10 (seconds or milliseconds depending on definition, let's assume milliseconds for this example)
c.RegisterHistogram("request_duration_ms", []string{"endpoint"}, []float64{10, 50, 100, 500, 1000})
// Observe values for the histogram
c.ObserveHistogram("request_duration_ms", map[string]string{"endpoint": "/api/v1/users"}, 75) // 75ms
c.ObserveHistogram("request_duration_ms", map[string]string{"endpoint": "/api/v1/users"}, 150) // 150ms
c.ObserveHistogram("request_duration_ms", map[string]string{"endpoint": "/api/v1/users"}, 25) // 25ms
c.ObserveHistogram("request_duration_ms", map[string]string{"endpoint": "/api/v1/products"}, 300) // 300ms
// Expose the metrics
// Expected Output (simplified, assuming default HELP/TYPE lines are present):
// # HELP current_goroutines Current number of goroutines.
// # TYPE current_goroutines gauge
// current_goroutines 145
// # HELP request_duration_ms Request duration in milliseconds.
// # TYPE request_duration_ms histogram
// request_duration_ms_bucket{endpoint="/api/v1/users",le="10"} 0
// request_duration_ms_bucket{endpoint="/api/v1/users",le="50"} 1
// request_duration_ms_bucket{endpoint="/api/v1/users",le="100"} 2
// request_duration_ms_bucket{endpoint="/api/v1/users",le="500"} 3
// request_duration_ms_bucket{endpoint="/api/v1/users",le="1000"} 3
// request_duration_ms_bucket{endpoint="/api/v1/users",le="+Inf"} 3
// request_duration_ms_count{endpoint="/api/v1/users"} 3
// request_duration_ms_sum{endpoint="/api/v1/users"} 250
// request_duration_ms_bucket{endpoint="/api/v1/products",le="10"} 0
// request_duration_ms_bucket{endpoint="/api/v1/products",le="50"} 0
// request_duration_ms_bucket{endpoint="/api/v1/products",le="100"} 0
// request_duration_ms_bucket{endpoint="/api/v1/products",le="500"} 1
// request_duration_ms_bucket{endpoint="/api/v1/products",le="1000"} 1
// request_duration_ms_bucket{endpoint="/api/v1/products",le="+Inf"} 1
// request_duration_ms_count{endpoint="/api/v1/products"} 1
// request_duration_ms_sum{endpoint="/api/v1/products"} 300
Constraints
- Metrics should be stored in memory.
- The system should be able to handle at least 1000 unique metric names (name + labels combinations).
- The
Exposefunction should be able to serialize metrics within 50 milliseconds for up to 1000 registered metrics. - Label keys and values will consist of alphanumeric characters and underscores.
- Histogram buckets should be provided as a slice of
float64.
Notes
- Consider using Go's
syncpackage for thread-safety. - For histograms, you'll need to manage counts for each bucket, the total sum, and the total count of observations.
- The exposition format requires specific
HELPandTYPElines before each metric's data. You should provide meaningful descriptions for these. - You'll need a mechanism to map metric names and label sets to the actual metric data structures. A
mapmight be a good starting point, but consider how to handle thread-safe access to it. - The
Registerfunctions should handle situations where a metric with the same name and labels is already registered. Returning an error is a common approach.