Prometheus Client Libraries vs. OpenTelemetry
You are viewing the English version of this page because it has not yet been fully translated. Interested in helping out? See Contributing.
This page covers Java and Go. Examples for other languages are planned.
This guide is for developers familiar with the Prometheus client libraries who want to understand equivalent patterns in the OpenTelemetry metrics API and SDK. It covers the most common patterns, but is not exhaustive.
Conceptual differences
Before looking at code, it helps to understand a few structural differences between the two systems. The Prometheus and OpenMetrics Compatibility specification documents the complete translation rules between the two systems. This section covers the differences most relevant to writing new instrumentation code.
Registry (MeterProvider)
In Prometheus, metrics register to a registry — by default a global one. You can declare a metric anywhere in your code, and it becomes available for scraping once registered. The exporter (HTTP server or OTLP push) is attached to the registry as a separate, independent step.
In OpenTelemetry, MeterProvider and Meter are part of the metrics API. You
obtain a Meter — scoped to your library or component — from a MeterProvider,
and create instruments from that Meter. How those measurements are processed —
which exporters receive them, how they are aggregated, on what schedule — is
determined by the SDK bound to the MeterProvider and its configuration, which
is separate from the instrumentation code itself (see
API and SDK).
Like Prometheus, OpenTelemetry supports both a global MeterProvider (requiring
no explicit wiring from instrumentation code) and explicit MeterProvider
instances that can be passed to libraries which support them.
Label names (attributes)
Prometheus requires label names to be declared at metric creation time. Label
values are bound at record time, via labelValues(...).
OpenTelemetry has no upfront label declaration. Attribute keys and values are
both provided together at the time of the measurement via Attributes.
Naming conventions
Prometheus uses snake_case metric names. Counter names end in _total. By
convention, Prometheus metric names are prefixed with the application or library
name to avoid collisions (for example, smart_home_hvac_on_seconds_total),
since all metrics share a flat global namespace.
OpenTelemetry conventionally uses
dotted names. Ownership and namespacing
are captured in the instrumentation scope (the Meter name, for example
smart.home), so metric names themselves do not need a prefix (for example,
hvac.on). When exporting to Prometheus, the exporter translates names: dots
become underscores, unit abbreviations expand to full words (for example, s →
seconds), and counters receive a _total suffix. An OpenTelemetry counter
named hvac.on with unit s is exported as hvac_on_seconds_total. See the
compatibility specification
for the complete set of name translation rules. The translation strategy is
configurable — for example, to preserve UTF-8 characters or suppress unit and
type suffixes. See the
Prometheus exporter
configuration reference for details.
Stateful and callback instruments
Both systems support two recording modes:
- Prometheus distinguishes stateful instruments (
Counter,Gauge), which maintain their own accumulated value, from function-based instruments, which invoke a callback at scrape time to return the current value. The naming varies by client library (GaugeFunc/CounterFuncin Go;GaugeWithCallback/CounterWithCallbackin Java). - OpenTelemetry calls these synchronous (counter, histogram, etc.) and asynchronous (observed via a registered callback). The semantics are the same.
Note also that Prometheus Gauge covers two distinct OTel instrument types:
Gauge for non-additive values (such as temperature) and UpDownCounter for
additive values that can increase or decrease (such as active connections). See
Gauge for details.
OTel: API and SDK
OpenTelemetry separates instrumentation from configuration with a two-layer design: an API package and an SDK package. The API defines the interfaces used to record metrics. The SDK provides the implementation — the concrete provider, exporters, and processing pipeline.
Instrumentation code should depend only on the API. The SDK is configured once at application startup and wired to an API reference that gets passed to the rest of the codebase. This keeps instrumentation library code decoupled from any specific SDK version and makes it straightforward to swap in a no-op implementation for testing.
OTel: Instrumentation scope
Prometheus metrics are global: every metric in a process shares the same flat namespace, identified only by name and labels.
OpenTelemetry scopes each group of instruments to a Meter, identified by a
name and optional version (for example, smart.home). When exporting to
Prometheus, the scope name and version are added as otel_scope_name and
otel_scope_version labels on every metric point. Any additional scope
attributes are also added as labels, named otel_scope_[attr name]. These
labels appear automatically and may be unfamiliar to users coming from
Prometheus. They can be suppressed via the exporter’s without_scope_info
option — see the
Prometheus exporter
configuration reference for details. Note that suppressing scope info is only
safe when each metric name is produced by a single scope. If two scopes emit a
metric with the same name, the scope labels are the only thing distinguishing
them; without those labels, you get duplicate time series with no way to
differentiate their origin, which produces invalid output in Prometheus.
OTel: Aggregation temporality
Prometheus metrics are always cumulative. OpenTelemetry supports both cumulative and delta temporality, but the Prometheus exporter enforces cumulative for all instruments. For developers migrating from Prometheus, this is transparent — the behavior you already rely on is preserved.
OTel: Resource attributes
Prometheus identifies scrape targets using job and instance labels, which
are added by the Prometheus server at scrape time.
OpenTelemetry has a Resource — structured metadata attached to all telemetry
from a process, with attributes such as service.name and
service.instance.id. When exporting to Prometheus, the exporter maps resource
attributes to the job and instance labels, with any remaining attributes
exposed in a target_info metric (target_info is an OpenMetrics 1.0
convention — if you currently emit it manually from Prometheus, the OTel
equivalent is to set resource attributes). See the
compatibility specification
for the exact mapping rules. The target_info metric can be suppressed via
without_target_info, and specific resource attributes can be promoted to
metric-level labels via with_resource_constant_labels. See the
Prometheus exporter
configuration reference for details.
Initialization
The examples below cover the two main deployment patterns: exposing a Prometheus scrape endpoint and pushing to an OTLP endpoint.
Expose a Prometheus scrape endpoint
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Counter;
import io.prometheus.metrics.exporter.httpserver.HTTPServer;
import java.io.IOException;
public class PrometheusScrapeInit {
public static void main(String[] args) throws IOException, InterruptedException {
// Create a counter and register it with the default PrometheusRegistry.
Counter doorOpens =
Counter.builder()
.name("door_opens_total")
.help("Total number of times a door has been opened")
.labelNames("door")
.register();
// Start the HTTP server; Prometheus scrapes http://localhost:9464/metrics.
HTTPServer server = HTTPServer.builder().port(9464).buildAndStart();
Runtime.getRuntime().addShutdownHook(new Thread(server::close));
doorOpens.labelValues("front").inc();
Thread.currentThread().join(); // sleep forever
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.exporter.prometheus.PrometheusHttpServer;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
public class OtelScrapeInit {
// Preallocate attribute keys and, when values are static, entire Attributes objects.
private static final AttributeKey<String> DOOR = AttributeKey.stringKey("door");
private static final Attributes FRONT_DOOR = Attributes.of(DOOR, "front");
public static void main(String[] args) throws InterruptedException {
// Configure the SDK: register a Prometheus reader that serves /metrics.
OpenTelemetrySdk sdk =
OpenTelemetrySdk.builder()
.setMeterProvider(
SdkMeterProvider.builder()
.registerMetricReader(PrometheusHttpServer.builder().setPort(9464).build())
.build())
.build();
Runtime.getRuntime().addShutdownHook(new Thread(sdk::close));
// Instrumentation code uses the OpenTelemetry API type, not the SDK type directly.
OpenTelemetry openTelemetry = sdk;
// Metrics are served at http://localhost:9464/metrics.
Meter meter = openTelemetry.getMeter("smart.home");
LongCounter doorOpens =
meter
.counterBuilder("door.opens")
.setDescription("Total number of times a door has been opened")
.build();
doorOpens.add(1, FRONT_DOOR);
Thread.currentThread().join(); // sleep forever
}
}
Prometheus
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
// Create a counter and register it with a custom registry.
reg := prometheus.NewRegistry()
doorOpens := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "door_opens_total",
Help: "Total number of times a door has been opened",
}, []string{"door"})
reg.MustRegister(doorOpens)
// Prometheus scrapes http://localhost:9464/metrics.
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
go http.ListenAndServe(":9464", nil) //nolint:errcheck
doorOpens.WithLabelValues("front").Inc()
select {} // sleep forever
}
OpenTelemetry
package main
import (
"context"
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
func main() {
ctx := context.Background()
// Configure the SDK: register a Prometheus reader that serves /metrics.
exporter, err := prometheus.New()
if err != nil {
panic(err)
}
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter))
defer provider.Shutdown(ctx) //nolint:errcheck
// Metrics are served at http://localhost:9464/metrics.
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9464", nil) //nolint:errcheck
// Instrumentation code uses the API, not the SDK, directly.
meter := provider.Meter("smart.home")
doorOpens, err := meter.Int64Counter("door.opens",
metric.WithDescription("Total number of times a door has been opened"))
if err != nil {
panic(err)
}
doorOpens.Add(ctx, 1, metric.WithAttributes(attribute.String("door", "front")))
select {} // sleep forever
}
Push metrics to an OTLP endpoint
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Counter;
import io.prometheus.metrics.exporter.opentelemetry.OpenTelemetryExporter;
public class PrometheusOtlpInit {
public static void main(String[] args) throws Exception {
// Create a counter and register it with the default PrometheusRegistry.
Counter doorOpens =
Counter.builder()
.name("door_opens_total")
.help("Total number of times a door has been opened")
.labelNames("door")
.register();
// Start the OTLP exporter. It reads from the default PrometheusRegistry and
// pushes metrics to the configured endpoint on a fixed interval.
OpenTelemetryExporter exporter =
OpenTelemetryExporter.builder()
.protocol("http/protobuf")
.endpoint("http://localhost:4318")
.intervalSeconds(60)
.buildAndStart();
Runtime.getRuntime().addShutdownHook(new Thread(exporter::close));
doorOpens.labelValues("front").inc();
Thread.currentThread().join(); // sleep forever
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
import java.time.Duration;
public class OtelOtlpInit {
public static void main(String[] args) throws InterruptedException {
// Configure the SDK: export metrics over OTLP/HTTP on a fixed interval.
OpenTelemetrySdk sdk =
OpenTelemetrySdk.builder()
.setMeterProvider(
SdkMeterProvider.builder()
.registerMetricReader(
PeriodicMetricReader.builder(
OtlpHttpMetricExporter.builder()
.setEndpoint("http://localhost:4318")
.build())
.setInterval(Duration.ofSeconds(60))
.build())
.build())
.build();
Runtime.getRuntime().addShutdownHook(new Thread(sdk::close));
// Instrumentation code uses the OpenTelemetry API type, not the SDK type directly.
OpenTelemetry openTelemetry = sdk;
Meter meter = openTelemetry.getMeter("smart.home");
LongCounter doorOpens =
meter
.counterBuilder("door.opens")
.setDescription("Total number of times a door has been opened")
.build();
doorOpens.add(1);
Thread.currentThread().join(); // sleep forever
}
}
Prometheus
The Prometheus Go client library does not include an OTLP push exporter.
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
func main() {
ctx := context.Background()
// Configure the SDK: export metrics over OTLP/HTTP on a fixed interval.
// The endpoint defaults to localhost:4318 and can be configured via
// the OTEL_EXPORTER_OTLP_ENDPOINT environment variable.
exporter, err := otlpmetrichttp.New(ctx)
if err != nil {
panic(err)
}
provider := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)),
)
defer provider.Shutdown(ctx) //nolint:errcheck
meter := provider.Meter("smart.home")
doorOpens, err := meter.Int64Counter("door.opens",
metric.WithDescription("Total number of times a door has been opened"))
if err != nil {
panic(err)
}
doorOpens.Add(ctx, 1, metric.WithAttributes(attribute.String("door", "front")))
select {} // sleep forever
}
Counter
A counter records monotonically increasing values. Prometheus Counter maps to
the OpenTelemetry Counter instrument.
- Unit encoding: Prometheus encodes the unit in the metric name
(
hvac_on_seconds_total). OpenTelemetry separates the name (hvac.on) from the unit (s), and the Prometheus exporter appends the unit suffix automatically.
Counter
The Prometheus Counter includes two series-management features that have no
OpenTelemetry equivalent:
- Series pre-initialization: Prometheus clients can pre-initialize label
value combinations so they appear in scrape output with value 0 before any
recording occurs. OpenTelemetry has no equivalent; data points first appear on
the first
add()call. - Pre-bound series: Prometheus clients let you cache the result of
labelValues()to pre-bind to a specific label value combination. Subsequent calls go directly to the data point, skipping the internal series lookup. OpenTelemetry has no equivalent, though it is under discussion.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Counter;
public class PrometheusCounter {
public static void counterUsage() {
Counter hvacOnTime =
Counter.builder()
.name("hvac_on_seconds_total")
.help("Total time the HVAC system has been running, in seconds")
.labelNames("zone")
.register();
// Pre-bind to label value sets: subsequent calls go directly to the data point,
// skipping the internal series lookup.
var upstairs = hvacOnTime.labelValues("upstairs");
var downstairs = hvacOnTime.labelValues("downstairs");
upstairs.inc(127.5);
downstairs.inc(3600.0);
// Pre-initialize zones so they appear in /metrics with value 0 on startup.
hvacOnTime.initLabelValues("basement");
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleCounter;
import io.opentelemetry.api.metrics.Meter;
public class OtelCounter {
// Preallocate attribute keys and, when values are static, entire Attributes objects.
private static final AttributeKey<String> ZONE = AttributeKey.stringKey("zone");
private static final Attributes UPSTAIRS = Attributes.of(ZONE, "upstairs");
private static final Attributes DOWNSTAIRS = Attributes.of(ZONE, "downstairs");
public static void counterUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// HVAC on-time is fractional — use ofDoubles() to get a DoubleCounter.
// No upfront label declaration: attributes are provided at record time.
DoubleCounter hvacOnTime =
meter
.counterBuilder("hvac.on")
.setDescription("Total time the HVAC system has been running")
.setUnit("s")
.ofDoubles()
.build();
hvacOnTime.add(127.5, UPSTAIRS);
hvacOnTime.add(3600.0, DOWNSTAIRS);
}
}
Key differences:
inc(value)→add(value). Unlike Prometheus, OpenTelemetry requires an explicit value — there is no bareinc()shorthand.- OpenTelemetry distinguishes
LongCounter(integers, the default) fromDoubleCounter(via.ofDoubles(), for fractional values). Prometheus uses a singleCountertype. - Preallocate
AttributeKeyinstances (always) andAttributesobjects (when values are static) to avoid per-call allocation on the hot path.
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
var hvacOnTime = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "hvac_on_seconds_total",
Help: "Total time the HVAC system has been running, in seconds",
}, []string{"zone"})
func prometheusCounterUsage(reg *prometheus.Registry) {
reg.MustRegister(hvacOnTime)
// Pre-bind to label value sets: subsequent calls avoid the series lookup.
upstairs := hvacOnTime.WithLabelValues("upstairs")
downstairs := hvacOnTime.WithLabelValues("downstairs")
upstairs.Add(127.5)
downstairs.Add(3600.0)
// Pre-initialize a series so it appears in /metrics with value 0.
hvacOnTime.WithLabelValues("basement")
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Preallocate attribute options when values are static to avoid per-call allocation.
var (
zoneUpstairsOpts = []metric.AddOption{metric.WithAttributes(attribute.String("zone", "upstairs"))}
zoneDownstairsOpts = []metric.AddOption{metric.WithAttributes(attribute.String("zone", "downstairs"))}
)
func otelCounterUsage(ctx context.Context, meter metric.Meter) {
// No upfront label declaration: attributes are provided at record time.
hvacOnTime, err := meter.Float64Counter("hvac.on",
metric.WithDescription("Total time the HVAC system has been running"),
metric.WithUnit("s"))
if err != nil {
panic(err)
}
hvacOnTime.Add(ctx, 127.5, zoneUpstairsOpts...)
hvacOnTime.Add(ctx, 3600.0, zoneDownstairsOpts...)
}
Key differences:
Add(value)→Add(ctx, value, metric.WithAttributes(...)). All instrument calls require acontext.Contextas the first argument.- In Go,
meter.Float64Counterandmeter.Int64Counterare separate methods. Prometheus uses a singleCountertype. - Instrument creation returns
(Instrument, error)and the error must be handled.
Callback (async) counter
Use a callback counter (an asynchronous counter in OpenTelemetry) when the total is maintained by an external source — such as a device or runtime — and you want to observe it at collection time rather than increment it yourself.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.CounterWithCallback;
public class PrometheusCounterCallback {
public static void counterCallbackUsage() {
// Each zone has its own smart energy meter tracking cumulative joule totals.
// Use a callback counter to report those values at scrape time without
// maintaining separate counters in application code.
CounterWithCallback.builder()
.name("energy_consumed_joules_total")
.help("Total energy consumed in joules")
.labelNames("zone")
.callback(
callback -> {
callback.call(SmartHomeDevices.totalEnergyJoules("upstairs"), "upstairs");
callback.call(SmartHomeDevices.totalEnergyJoules("downstairs"), "downstairs");
})
.register();
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.Meter;
public class OtelCounterCallback {
private static final AttributeKey<String> ZONE = AttributeKey.stringKey("zone");
private static final Attributes UPSTAIRS = Attributes.of(ZONE, "upstairs");
private static final Attributes DOWNSTAIRS = Attributes.of(ZONE, "downstairs");
public static void counterCallbackUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// Each zone has its own smart energy meter tracking cumulative joule totals.
// Use an asynchronous counter to report those values when a MetricReader
// collects metrics, without maintaining separate counters in application code.
meter
.counterBuilder("energy.consumed")
.setDescription("Total energy consumed")
.setUnit("J")
.ofDoubles()
.buildWithCallback(
measurement -> {
measurement.record(SmartHomeDevices.totalEnergyJoules("upstairs"), UPSTAIRS);
measurement.record(SmartHomeDevices.totalEnergyJoules("downstairs"), DOWNSTAIRS);
});
}
}
Key differences:
- OpenTelemetry distinguishes integer and floating-point counters;
.ofDoubles()selects the floating-point variant. PrometheusCounterWithCallbackalways uses floating-point values.
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
type energyCollector struct{ desc *prometheus.Desc }
func newEnergyCollector() *energyCollector {
return &energyCollector{desc: prometheus.NewDesc(
"energy_consumed_joules_total",
"Total energy consumed in joules",
[]string{"zone"}, nil,
)}
}
func (c *energyCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c *energyCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.CounterValue, totalEnergyJoules("upstairs"), "upstairs")
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.CounterValue, totalEnergyJoules("downstairs"), "downstairs")
}
func prometheusCounterCallbackUsage(reg *prometheus.Registry) {
// Each zone has its own smart energy meter tracking cumulative joule totals.
// Implement prometheus.Collector to report those values at scrape time.
reg.MustRegister(newEnergyCollector())
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
var (
zoneUpstairs = attribute.String("zone", "upstairs")
zoneDownstairs = attribute.String("zone", "downstairs")
)
func otelCounterCallbackUsage(meter metric.Meter) {
// Each zone has its own smart energy meter tracking cumulative joule totals.
// Use an observable counter to report those values when metrics are collected.
_, err := meter.Float64ObservableCounter("energy.consumed",
metric.WithDescription("Total energy consumed"),
metric.WithUnit("J"),
metric.WithFloat64Callback(func(_ context.Context, o metric.Float64Observer) error {
o.Observe(totalEnergyJoules("upstairs"), metric.WithAttributes(zoneUpstairs))
o.Observe(totalEnergyJoules("downstairs"), metric.WithAttributes(zoneDownstairs))
return nil
}))
if err != nil {
panic(err)
}
}
Key differences:
- The Prometheus example implements
prometheus.CollectorwithDescribeandCollectmethods to report labeled counter values. - OpenTelemetry distinguishes
Float64ObservableCounterfromInt64ObservableCounter.
Gauge
A gauge records an instantaneous value that can increase or decrease. Prometheus
uses a single Gauge type for all such values, but OpenTelemetry distinguishes
between additive and non-additive values when choosing the right
instrument:
- Non-additive values cannot be meaningfully summed across instances — for
example, temperature: adding readings from three room sensors doesn’t produce
a useful number. These map to OTel
GaugeandObservableGauge. - Additive values can be meaningfully summed across instances — for example,
connected device counts summed across service instances give a useful total.
These map to OTel
UpDownCounterandObservableUpDownCounter.
This distinction applies to all gauge patterns: abs, inc and dec, and callback variants. See the instrument selection guide for a fuller explanation.
Gauge — abs
Use this pattern for values recorded as an absolute value — such as a
configuration value or a device setpoint. Prometheus Gauge maps to the
OpenTelemetry Gauge instrument.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Gauge;
public class PrometheusGauge {
public static void gaugeUsage() {
Gauge thermostatSetpoint =
Gauge.builder()
.name("thermostat_setpoint_celsius")
.help("Target temperature set on the thermostat")
.labelNames("zone")
.register();
thermostatSetpoint.labelValues("upstairs").set(22.5);
thermostatSetpoint.labelValues("downstairs").set(20.0);
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleGauge;
import io.opentelemetry.api.metrics.Meter;
public class OtelGauge {
// Preallocate attribute keys and, when values are static, entire Attributes objects.
private static final AttributeKey<String> ZONE = AttributeKey.stringKey("zone");
private static final Attributes UPSTAIRS = Attributes.of(ZONE, "upstairs");
private static final Attributes DOWNSTAIRS = Attributes.of(ZONE, "downstairs");
public static void gaugeUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
DoubleGauge thermostatSetpoint =
meter
.gaugeBuilder("thermostat.setpoint")
.setDescription("Target temperature set on the thermostat")
.setUnit("Cel")
.build();
thermostatSetpoint.set(22.5, UPSTAIRS);
thermostatSetpoint.set(20.0, DOWNSTAIRS);
}
}
Key differences:
set(value)→set(value, attributes). The method name is the same.- OpenTelemetry distinguishes
LongGauge(integers, via.ofLongs()) fromDoubleGauge(the default). Prometheus uses a singleGaugetype. - Preallocate
AttributeKeyinstances (always) andAttributesobjects (when values are static) to avoid per-call allocation on the hot path.
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
var thermostatSetpoint = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "thermostat_setpoint_celsius",
Help: "Target temperature set on the thermostat",
}, []string{"zone"})
func prometheusGaugeUsage(reg *prometheus.Registry) {
reg.MustRegister(thermostatSetpoint)
thermostatSetpoint.WithLabelValues("upstairs").Set(22.5)
thermostatSetpoint.WithLabelValues("downstairs").Set(20.0)
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Preallocate attribute options when values are static to avoid per-call allocation.
var (
zoneUpstairsGaugeOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("zone", "upstairs"))}
zoneDownstairsGaugeOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("zone", "downstairs"))}
)
func otelGaugeUsage(ctx context.Context, meter metric.Meter) {
thermostatSetpoint, err := meter.Float64Gauge("thermostat.setpoint",
metric.WithDescription("Target temperature set on the thermostat"),
metric.WithUnit("Cel"))
if err != nil {
panic(err)
}
thermostatSetpoint.Record(ctx, 22.5, zoneUpstairsGaugeOpts...)
thermostatSetpoint.Record(ctx, 20.0, zoneDownstairsGaugeOpts...)
}
Key differences:
Set(value)→Record(ctx, value, metric.WithAttributes(...)).- In Go,
meter.Float64Gaugeandmeter.Int64Gaugeare separate methods. Prometheus uses a singleGaugetype.
Callback gauge — abs
Use a callback gauge (an asynchronous gauge in OpenTelemetry) when a non-additive value is maintained externally — such as a sensor reading — and you want to observe it at collection time rather than track it yourself.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.GaugeWithCallback;
public class PrometheusGaugeCallback {
public static void gaugeCallbackUsage() {
// Temperature sensors maintain their own readings in firmware.
// Use a callback gauge to report those values at scrape time without
// maintaining a separate gauge in application code.
GaugeWithCallback.builder()
.name("room_temperature_celsius")
.help("Current temperature in the room")
.labelNames("room")
.callback(
callback -> {
callback.call(SmartHomeDevices.livingRoomTemperatureCelsius(), "living_room");
callback.call(SmartHomeDevices.bedroomTemperatureCelsius(), "bedroom");
})
.register();
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.Meter;
public class OtelGaugeCallback {
private static final AttributeKey<String> ROOM = AttributeKey.stringKey("room");
private static final Attributes LIVING_ROOM = Attributes.of(ROOM, "living_room");
private static final Attributes BEDROOM = Attributes.of(ROOM, "bedroom");
public static void gaugeCallbackUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// Temperature sensors maintain their own readings in firmware.
// Use an asynchronous gauge to report those values when a MetricReader
// collects metrics, without maintaining separate gauges in application code.
meter
.gaugeBuilder("room.temperature")
.setDescription("Current temperature in the room")
.setUnit("Cel")
.buildWithCallback(
measurement -> {
measurement.record(SmartHomeDevices.livingRoomTemperatureCelsius(), LIVING_ROOM);
measurement.record(SmartHomeDevices.bedroomTemperatureCelsius(), BEDROOM);
});
}
}
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
type temperatureCollector struct{ desc *prometheus.Desc }
func newTemperatureCollector() *temperatureCollector {
return &temperatureCollector{desc: prometheus.NewDesc(
"room_temperature_celsius",
"Current temperature in the room",
[]string{"room"}, nil,
)}
}
func (c *temperatureCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c *temperatureCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, livingRoomTemperatureCelsius(), "living_room")
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, bedroomTemperatureCelsius(), "bedroom")
}
func prometheusGaugeCallbackUsage(reg *prometheus.Registry) {
// Temperature sensors maintain their own readings in firmware.
// Implement prometheus.Collector to report those values at scrape time.
reg.MustRegister(newTemperatureCollector())
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
var (
roomLivingRoom = attribute.String("room", "living_room")
roomBedroom = attribute.String("room", "bedroom")
)
func otelGaugeCallbackUsage(meter metric.Meter) {
// Temperature sensors maintain their own readings in firmware.
// Use an observable gauge to report those values when metrics are collected.
_, err := meter.Float64ObservableGauge("room.temperature",
metric.WithDescription("Current temperature in the room"),
metric.WithUnit("Cel"),
metric.WithFloat64Callback(func(_ context.Context, o metric.Float64Observer) error {
o.Observe(livingRoomTemperatureCelsius(), metric.WithAttributes(roomLivingRoom))
o.Observe(bedroomTemperatureCelsius(), metric.WithAttributes(roomBedroom))
return nil
}))
if err != nil {
panic(err)
}
}
Key differences:
- The Prometheus example implements
prometheus.CollectorwithDescribeandCollectmethods to report labeled gauge values.
Gauge — inc and dec
Prometheus Gauge supports incrementing and decrementing for values that change
gradually — such as the number of connected devices or active sessions.
OpenTelemetry Gauge records absolute values only; this pattern maps to the
OpenTelemetry UpDownCounter instrument.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Gauge;
public class PrometheusUpDownCounter {
public static void upDownCounterUsage() {
// Prometheus uses Gauge for values that can increase or decrease.
Gauge devicesConnected =
Gauge.builder()
.name("devices_connected")
.help("Number of smart home devices currently connected")
.labelNames("device_type")
.register();
// Increment when a device connects, decrement when it disconnects.
devicesConnected.labelValues("thermostat").inc();
devicesConnected.labelValues("thermostat").inc();
devicesConnected.labelValues("lock").inc();
devicesConnected.labelValues("lock").dec();
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.LongUpDownCounter;
import io.opentelemetry.api.metrics.Meter;
public class OtelUpDownCounter {
// Preallocate attribute keys and, when values are static, entire Attributes objects.
private static final AttributeKey<String> DEVICE_TYPE = AttributeKey.stringKey("device_type");
private static final Attributes THERMOSTAT = Attributes.of(DEVICE_TYPE, "thermostat");
private static final Attributes LOCK = Attributes.of(DEVICE_TYPE, "lock");
public static void upDownCounterUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
LongUpDownCounter devicesConnected =
meter
.upDownCounterBuilder("devices.connected")
.setDescription("Number of smart home devices currently connected")
.build();
// add() accepts positive and negative values.
devicesConnected.add(1, THERMOSTAT);
devicesConnected.add(1, THERMOSTAT);
devicesConnected.add(1, LOCK);
devicesConnected.add(-1, LOCK);
}
}
Key differences:
inc()/dec()→add(1)/add(-1).add()accepts both positive and negative values.- The Prometheus type is
Gauge; the OpenTelemetry type isLongUpDownCounter(orDoubleUpDownCountervia.ofDoubles()).
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
// Prometheus uses Gauge for values that can increase or decrease.
var devicesConnected = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "devices_connected",
Help: "Number of smart home devices currently connected",
}, []string{"device_type"})
func prometheusUpDownCounterUsage(reg *prometheus.Registry) {
reg.MustRegister(devicesConnected)
// Increment when a device connects, decrement when it disconnects.
devicesConnected.WithLabelValues("thermostat").Inc()
devicesConnected.WithLabelValues("thermostat").Inc()
devicesConnected.WithLabelValues("lock").Inc()
devicesConnected.WithLabelValues("lock").Dec()
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Preallocate attribute options when values are static to avoid per-call allocation.
var (
deviceThermostatAddOpts = []metric.AddOption{metric.WithAttributes(attribute.String("device_type", "thermostat"))}
deviceLockAddOpts = []metric.AddOption{metric.WithAttributes(attribute.String("device_type", "lock"))}
)
func otelUpDownCounterUsage(ctx context.Context, meter metric.Meter) {
devicesConnected, err := meter.Int64UpDownCounter("devices.connected",
metric.WithDescription("Number of smart home devices currently connected"))
if err != nil {
panic(err)
}
// Add() accepts positive and negative values.
devicesConnected.Add(ctx, 1, deviceThermostatAddOpts...)
devicesConnected.Add(ctx, 1, deviceThermostatAddOpts...)
devicesConnected.Add(ctx, 1, deviceLockAddOpts...)
devicesConnected.Add(ctx, -1, deviceLockAddOpts...)
}
Key differences:
Inc()/Dec()→Add(ctx, 1, ...)/Add(ctx, -1, ...).Add()accepts both positive and negative values.- The Prometheus type is
Gauge; the OpenTelemetry type isInt64UpDownCounter(orFloat64UpDownCounterviameter.Float64UpDownCounter).
Callback gauge — inc and dec
Use a callback gauge (an asynchronous up-down counter in OpenTelemetry) when an
additive count that would otherwise be tracked with inc()/dec() is
maintained externally — such as by a device manager or connection pool — and you
want to observe it at collection time.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.GaugeWithCallback;
public class PrometheusUpDownCounterCallback {
public static void upDownCounterCallbackUsage() {
// The device manager maintains the count of connected devices.
// Use a callback gauge to report that value at scrape time.
GaugeWithCallback.builder()
.name("devices_connected")
.help("Number of smart home devices currently connected")
.labelNames("device_type")
.callback(
callback -> {
callback.call(SmartHomeDevices.connectedDeviceCount("thermostat"), "thermostat");
callback.call(SmartHomeDevices.connectedDeviceCount("lock"), "lock");
})
.register();
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.Meter;
public class OtelUpDownCounterCallback {
private static final AttributeKey<String> DEVICE_TYPE = AttributeKey.stringKey("device_type");
private static final Attributes THERMOSTAT = Attributes.of(DEVICE_TYPE, "thermostat");
private static final Attributes LOCK = Attributes.of(DEVICE_TYPE, "lock");
public static void upDownCounterCallbackUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// The device manager maintains the count of connected devices.
// Use an asynchronous up-down counter to report that value when a MetricReader
// collects metrics.
meter
.upDownCounterBuilder("devices.connected")
.setDescription("Number of smart home devices currently connected")
.buildWithCallback(
measurement -> {
measurement.record(SmartHomeDevices.connectedDeviceCount("thermostat"), THERMOSTAT);
measurement.record(SmartHomeDevices.connectedDeviceCount("lock"), LOCK);
});
}
}
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
type deviceCountCollector struct{ desc *prometheus.Desc }
func newDeviceCountCollector() *deviceCountCollector {
return &deviceCountCollector{desc: prometheus.NewDesc(
"devices_connected",
"Number of smart home devices currently connected",
[]string{"device_type"}, nil,
)}
}
func (c *deviceCountCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c *deviceCountCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(connectedDeviceCount("thermostat")), "thermostat")
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(connectedDeviceCount("lock")), "lock")
}
func prometheusUpDownCounterCallbackUsage(reg *prometheus.Registry) {
// The device manager maintains the count of connected devices.
// Implement prometheus.Collector to report those values at scrape time.
reg.MustRegister(newDeviceCountCollector())
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
var (
deviceThermostat = attribute.String("device_type", "thermostat")
deviceLock = attribute.String("device_type", "lock")
)
func otelUpDownCounterCallbackUsage(meter metric.Meter) {
// The device manager maintains the count of connected devices.
// Use an observable up-down counter to report that value when metrics are collected.
_, err := meter.Int64ObservableUpDownCounter("devices.connected",
metric.WithDescription("Number of smart home devices currently connected"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(int64(connectedDeviceCount("thermostat")), metric.WithAttributes(deviceThermostat))
o.Observe(int64(connectedDeviceCount("lock")), metric.WithAttributes(deviceLock))
return nil
}))
if err != nil {
panic(err)
}
}
Key differences:
- The Prometheus example implements
prometheus.CollectorwithDescribeandCollectmethods to report labeled gauge values. Int64ObservableUpDownCounterusesmetric.WithInt64Callback.
Histogram
A histogram records the distribution of a set of measurements, tracking the count of observations, their sum, and the number that fall within configurable bucket boundaries.
Both Prometheus and OpenTelemetry support classic (explicit-bucket) histograms
and native (base2 exponential) histograms. Prometheus also has a Summary type,
which has no direct OTel equivalent — see Summary below.
Prometheus Histogram maps to the OpenTelemetry Histogram instrument.
Classic (explicit) histogram
Both systems support classic histograms, where fixed bucket boundaries partition observations into discrete ranges.
- Bucket configuration: Prometheus declares bucket boundaries on the
instrument itself at creation time. In OpenTelemetry, bucket boundaries are
set on the instrument as a hint that can be overridden or replaced by views
configured at the SDK level. This separation keeps instrumentation code
independent of collection configuration. If no boundaries are specified and no
view is configured, the SDK uses a default set designed for millisecond-scale
latency
(
[0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000]), which is likely wrong for second-scale measurements. Always provide boundaries or configure a view when migrating existing histograms.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Histogram;
public class PrometheusHistogram {
public static void histogramUsage() {
Histogram deviceCommandDuration =
Histogram.builder()
.name("device_command_duration_seconds")
.help("Time to receive acknowledgment from a smart home device")
.labelNames("device_type")
.classicUpperBounds(0.1, 0.25, 0.5, 1.0, 2.5, 5.0)
.register();
deviceCommandDuration.labelValues("thermostat").observe(0.35);
deviceCommandDuration.labelValues("lock").observe(0.85);
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.Meter;
import java.util.List;
public class OtelHistogram {
// Preallocate attribute keys and, when values are static, entire Attributes objects.
private static final AttributeKey<String> DEVICE_TYPE = AttributeKey.stringKey("device_type");
private static final Attributes THERMOSTAT = Attributes.of(DEVICE_TYPE, "thermostat");
private static final Attributes LOCK = Attributes.of(DEVICE_TYPE, "lock");
public static void histogramUsage(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// setExplicitBucketBoundariesAdvice() sets default boundaries as a hint to the SDK.
// Views configured at the SDK level take precedence over this advice.
DoubleHistogram deviceCommandDuration =
meter
.histogramBuilder("device.command.duration")
.setDescription("Time to receive acknowledgment from a smart home device")
.setUnit("s")
.setExplicitBucketBoundariesAdvice(List.of(0.1, 0.25, 0.5, 1.0, 2.5, 5.0))
.build();
deviceCommandDuration.record(0.35, THERMOSTAT);
deviceCommandDuration.record(0.85, LOCK);
}
}
Key differences:
observe(value)→record(value, attributes).- OpenTelemetry distinguishes
LongHistogram(integers, via.ofLongs()) fromDoubleHistogram(the default). Prometheus uses a singleHistogramtype. - Preallocate
AttributeKeyinstances (always) andAttributesobjects (when values are static) to avoid per-call allocation on the hot path. - SDK views can override the boundaries set by
setExplicitBucketBoundariesAdvice(), and can also configure other aspects of histogram collection such as attribute filtering, min/max recording, and instrument renaming.
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
var deviceCommandDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "device_command_duration_seconds",
Help: "Time to receive acknowledgment from a smart home device",
Buckets: []float64{0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
}, []string{"device_type"})
func prometheusHistogramUsage(reg *prometheus.Registry) {
reg.MustRegister(deviceCommandDuration)
deviceCommandDuration.WithLabelValues("thermostat").Observe(0.35)
deviceCommandDuration.WithLabelValues("lock").Observe(0.85)
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Preallocate attribute options when values are static to avoid per-call allocation.
var (
deviceThermostatOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("device_type", "thermostat"))}
deviceLockOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("device_type", "lock"))}
)
func otelHistogramUsage(ctx context.Context, meter metric.Meter) {
// WithExplicitBucketBoundaries sets default boundaries as a hint to the SDK.
// Views configured at the SDK level take precedence over this hint.
deviceCommandDuration, err := meter.Float64Histogram("device.command.duration",
metric.WithDescription("Time to receive acknowledgment from a smart home device"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.1, 0.25, 0.5, 1.0, 2.5, 5.0))
if err != nil {
panic(err)
}
deviceCommandDuration.Record(ctx, 0.35, deviceThermostatOpts...)
deviceCommandDuration.Record(ctx, 0.85, deviceLockOpts...)
}
Key differences:
Observe(value)→Record(ctx, value, metric.WithAttributes(...)).- In Go,
metric.WithExplicitBucketBoundaries(...)is variadic (not a slice). Prometheus uses aBucketsfield inHistogramOpts. - SDK views can override the boundaries set by
WithExplicitBucketBoundaries(), and can also configure other aspects of histogram collection such as attribute filtering, min/max recording, and instrument renaming.
Native (base2 exponential) histogram
Both systems support native (base2 exponential) histograms, which automatically adjust bucket boundaries to cover the observed range without requiring manual configuration.
- Format selection: Prometheus instruments can emit classic format only, native format only, or both simultaneously — enabling gradual migration without instrumentation changes. In OpenTelemetry, format selection is configured outside instrumentation code — on the exporter or via a view — so instrumentation code requires no changes either way.
- Instrumentation code: The OpenTelemetry instrumentation code is identical
for classic and native histograms. The same
record()calls produce either format depending on how the SDK is configured.
Prometheus
In Prometheus, the histogram format is controlled at instrument creation time.
The example below uses .nativeOnly() to restrict to native format; omitting it
would emit both classic and native formats simultaneously:
package otel;
import io.prometheus.metrics.core.metrics.Histogram;
public class PrometheusHistogramNative {
public static void nativeHistogramUsage() {
Histogram deviceCommandDuration =
Histogram.builder()
.name("device_command_duration_seconds")
.help("Time to receive acknowledgment from a smart home device")
.labelNames("device_type")
.nativeOnly()
.register();
deviceCommandDuration.labelValues("thermostat").observe(0.35);
deviceCommandDuration.labelValues("lock").observe(0.85);
}
}
Prometheus
In Prometheus, setting NativeHistogramBucketFactor enables native histograms
alongside the classic bucket configuration — both formats are reported
simultaneously:
package main
import "github.com/prometheus/client_golang/prometheus"
var nativeDeviceCommandDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "device_command_duration_seconds",
Help: "Time to receive acknowledgment from a smart home device",
NativeHistogramBucketFactor: 1.1,
}, []string{"device_type"})
func nativeHistogramUsage(reg *prometheus.Registry) {
reg.MustRegister(nativeDeviceCommandDuration)
nativeDeviceCommandDuration.WithLabelValues("thermostat").Observe(0.35)
nativeDeviceCommandDuration.WithLabelValues("lock").Observe(0.85)
}
Key differences:
NativeHistogramBucketFactormust be set to a value greater than 1.0 to enable native histograms in Go — it is not optional. Setting it to 0 (the zero value) disables native histograms entirely. The value controls the maximum ratio between consecutive bucket boundaries; smaller values give finer resolution at the cost of more buckets. To approximate the same bucket density as the commonly used value of1.1, setMaxScale: 3onAggregationBase2ExponentialHistogram.
In OpenTelemetry, the instrumentation code is identical to the classic histogram case. The base2 exponential format is configured separately, outside the instrumentation layer.
The preferred approach is to configure it on the metric exporter. This applies to all histograms exported through that exporter without touching instrumentation code:
package otel;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.sdk.metrics.Aggregation;
import io.opentelemetry.sdk.metrics.InstrumentType;
import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector;
public class OtelHistogramExponentialExporter {
static OtlpHttpMetricExporter createExporter() {
// Configure the exporter to use exponential histograms for all histogram instruments.
// This is the preferred approach — it applies globally without modifying instrumentation code.
return OtlpHttpMetricExporter.builder()
.setEndpoint("http://localhost:4318")
.setDefaultAggregationSelector(
DefaultAggregationSelector.getDefault()
.with(InstrumentType.HISTOGRAM, Aggregation.base2ExponentialBucketHistogram()))
.build();
}
}
package main
import (
"context"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
func createExponentialExporter(ctx context.Context) (*otlpmetrichttp.Exporter, error) {
// Configure the exporter to use exponential histograms for all histogram instruments.
// This is the preferred approach — it applies globally without modifying instrumentation code.
return otlpmetrichttp.New(ctx,
otlpmetrichttp.WithAggregationSelector(func(ik sdkmetric.InstrumentKind) sdkmetric.Aggregation {
if ik == sdkmetric.InstrumentKindHistogram {
return sdkmetric.AggregationBase2ExponentialHistogram{}
}
return sdkmetric.DefaultAggregationSelector(ik)
}),
)
}
For more granular control — for example, to use base2 exponential histograms for specific instruments while keeping explicit buckets for others — configure a view instead:
package otel;
import io.opentelemetry.sdk.metrics.Aggregation;
import io.opentelemetry.sdk.metrics.InstrumentSelector;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.View;
public class OtelHistogramExponentialView {
static SdkMeterProvider createMeterProvider() {
// Use a view for per-instrument control — select a specific instrument by name
// to use exponential histograms while keeping explicit buckets for others.
return SdkMeterProvider.builder()
.registerView(
InstrumentSelector.builder().setName("device.command.duration").build(),
View.builder().setAggregation(Aggregation.base2ExponentialBucketHistogram()).build())
.build();
}
}
func createExponentialView() sdkmetric.View {
// Use a view for per-instrument control — select a specific instrument by name
// to use exponential histograms while keeping explicit buckets for others.
return sdkmetric.NewView(
sdkmetric.Instrument{Name: "device.command.duration"},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationBase2ExponentialHistogram{}!},
)
}
Summary
Prometheus Summary computes quantiles client-side at scrape time and exposes
them as labeled time series (for example, {quantile="0.95"}). OpenTelemetry
has no direct equivalent.
For quantile estimation, a base2 exponential histogram is the recommended
replacement: it automatically adjusts bucket boundaries to cover the observed
range, and histogram_quantile() in PromQL can compute quantiles with bounded
errors at query time. Unlike Summary, the results can be aggregated across
instances. See
Native (base2 exponential) histogram.
If you only need count and sum — not quantiles — a histogram with no explicit bucket boundaries captures those statistics with minimal overhead. The examples below show this simpler approach.
Prometheus
package otel;
import io.prometheus.metrics.core.metrics.Summary;
public class PrometheusSummary {
public static void summaryUsage() {
Summary deviceCommandDuration =
Summary.builder()
.name("device_command_duration_seconds")
.help("Time to receive acknowledgment from a smart home device")
.labelNames("device_type")
.quantile(0.5, 0.05)
.quantile(0.95, 0.01)
.quantile(0.99, 0.001)
.register();
deviceCommandDuration.labelValues("thermostat").observe(0.35);
deviceCommandDuration.labelValues("lock").observe(0.85);
}
}
OpenTelemetry
package otel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.Meter;
import java.util.List;
public class OtelHistogramAsSummary {
private static final AttributeKey<String> DEVICE_TYPE = AttributeKey.stringKey("device_type");
private static final Attributes THERMOSTAT = Attributes.of(DEVICE_TYPE, "thermostat");
private static final Attributes LOCK = Attributes.of(DEVICE_TYPE, "lock");
public static void summaryReplacement(OpenTelemetry openTelemetry) {
Meter meter = openTelemetry.getMeter("smart.home");
// No explicit bucket boundaries: captures count and sum, a good stand-in for most
// Summary use cases. For quantile estimation, add boundaries that bracket your thresholds.
DoubleHistogram deviceCommandDuration =
meter
.histogramBuilder("device.command.duration")
.setDescription("Time to receive acknowledgment from a smart home device")
.setUnit("s")
.setExplicitBucketBoundariesAdvice(List.of())
.build();
deviceCommandDuration.record(0.35, THERMOSTAT);
deviceCommandDuration.record(0.85, LOCK);
}
}
Prometheus
package main
import "github.com/prometheus/client_golang/prometheus"
var summaryDeviceCommandDuration = prometheus.NewSummaryVec(prometheus.SummaryOpts{
Name: "device_command_duration_seconds",
Help: "Time to receive acknowledgment from a smart home device",
Objectives: map[float64]float64{0.5: 0.05, 0.95: 0.01, 0.99: 0.001},
}, []string{"device_type"})
func summaryUsage(reg *prometheus.Registry) {
reg.MustRegister(summaryDeviceCommandDuration)
summaryDeviceCommandDuration.WithLabelValues("thermostat").Observe(0.35)
summaryDeviceCommandDuration.WithLabelValues("lock").Observe(0.85)
}
OpenTelemetry
package main
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Preallocate attribute options when values are static to avoid per-call allocation.
var (
summaryThermostatOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("device_type", "thermostat"))}
summaryLockOpts = []metric.RecordOption{metric.WithAttributes(attribute.String("device_type", "lock"))}
)
func summaryReplacement(ctx context.Context, meter metric.Meter) {
// No explicit bucket boundaries: captures count and sum only.
// For quantile estimation, prefer a base2 exponential histogram instead.
deviceCommandDuration, err := meter.Float64Histogram("device.command.duration",
metric.WithDescription("Time to receive acknowledgment from a smart home device"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries()) // no boundaries
if err != nil {
panic(err)
}
deviceCommandDuration.Record(ctx, 0.35, summaryThermostatOpts...)
deviceCommandDuration.Record(ctx, 0.85, summaryLockOpts...)
}
Feedback
Was this page helpful?
Thank you. Your feedback is appreciated!
Please let us know how we can improve this page. Your feedback is appreciated!