Kubernetes ships with built-in awareness of CPU and memory, which is fine as far as it goes, but most real-world scaling decisions depend on signals that live entirely outside that narrow window: how many messages are sitting in a queue, how long the last batch job took to finish, how many active WebSocket connections a given pod is currently holding open. When the built-in metrics aren't enough, a custom metrics exporter is what bridges that gap.
An exporter, at its core, is a small HTTP server with exactly one job: expose application state as plain text on a metrics endpoint. Prometheus scrapes that endpoint on a regular interval, stores the resulting time series, and makes the data available for queries, alerting rules, and eventually autoscaling decisions. In some cases it makes more sense to instrument an application directly, embedding the Prometheus client library and exposing metrics from within the same process, rather than standing up a separate exporter. A standalone exporter earns its keep specifically when the data source lives outside the application you control, or when you don't own the application code at all.
The format Prometheus expects is plain text: one metric per line, with a name, optional labels, and a numeric value. Client libraries handle that serialization automatically, so in practice the real decisions are what to measure and which function to call when a given value changes.
Before writing any code, it helps to settle on which of Prometheus's three main metric types actually fits the signal in question. Counters only ever increase, and are the right tool for totals like requests served, jobs processed, or errors encountered; never use one for a value that can go down. Gauges represent a current snapshot that can rise and fall freely, which covers things like queue depth, active connections, or cache size. Histograms record the distribution of observed values, most commonly request latency, letting you calculate percentiles like p99 rather than settling for a plain average. Once the type is settled, choosing a name that follows Prometheus's naming conventions saves a lot of debugging time later; a job processor, for instance, might expose a counter for jobs processed, a gauge for queue depth, and a histogram for processing duration.
The Go Prometheus client is the most common starting point for exporters in the Kubernetes ecosystem, largely because it's the same library powering most of Kubernetes's own official components. After pulling in the dependency, the first real step is declaring metrics and registering them with Prometheus's default registry, which tells the library the metrics exist so they show up in the output even before the first observation is recorded. The default registration function panics on a duplicate registration, which is a feature rather than a bug: it surfaces misconfigurations immediately at startup instead of letting them fail silently at runtime later. If the exporter is being embedded inside a library other packages will also instrument, it's worth using the error-returning variant instead and handling that error explicitly.
With metrics registered, the next job is keeping them current, either by updating values continuously as the underlying data changes, or by running an internal polling loop that periodically reads from whatever data source the application owns. A polling interval shorter than Prometheus's own scrape interval, commonly fifteen seconds in most cluster deployments, gives each scrape a comfortably fresh value rather than stale data from several cycles ago.
Wiring the collection loop together with the HTTP handler, alongside a separate health-check path that doesn't expose metric data on the same route, gives Kubernetes a clean liveness probe target. From there, a multi-stage container build keeps the final image lean and avoids shipping a full Go toolchain into production: one stage compiles a statically linked binary, and a second, minimal stage copies only that binary in. A distroless base image, with no shell and no package manager, running as a non-root user by default, satisfies most cluster security policies without any extra configuration required.
On the Kubernetes side, two manifests are enough to get an exporter running: a Deployment to manage the pod's lifecycle, with conservative resource limits appropriate for a lightweight sidecar-style process, and a Service to give Prometheus a stable address to scrape, with its metrics port named consistently so a ServiceMonitor can reference it later. If Prometheus was installed through the Prometheus Operator or its Helm chart, the operator needs to already be running before a ServiceMonitor resource will do anything, and the label selector on that ServiceMonitor has to match whatever label the cluster's Prometheus resource expects. Clusters relying on annotation-based pod discovery instead of the operator need a matching scrape rule configured on the Prometheus side, so when in doubt about which approach a given cluster uses, ServiceMonitor tends to be the more explicit and easier path to debug.
A working exporter is the foundation, not the destination. The natural next step is surfacing those same custom metrics to the Horizontal Pod Autoscaler, so a workload scales based on the signals that actually reflect its load rather than CPU alone, which requires a metrics adapter such as the Prometheus Adapter to register the custom metrics with Kubernetes's Custom Metrics API. Once that's in place, any HorizontalPodAutoscaler in the cluster can reference those metrics directly in its scaling configuration, closing the loop between what an application is actually experiencing and how many replicas Kubernetes decides to run.