Hone logo
Hone
Problems

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:

  1. 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.
  2. Update Metric Values: Provide methods to increment counters, set gauge values, and observe values for histograms.
  3. 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" and path="/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 Expose function 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 Expose function 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 sync package 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 HELP and TYPE lines 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 map might be a good starting point, but consider how to handle thread-safe access to it.
  • The Register functions should handle situations where a metric with the same name and labels is already registered. Returning an error is a common approach.
Loading editor...
go