OpenTelemetry in Go — Traces, Metrics, Logs and the Collector
OpenTelemetry gives you traces, metrics, and logs from a single instrumentation library with a vendor-neutral export path. This post walks through adding OpenTelemetry to a Go HTTP server from scratch — setting up providers, auto-instrumenting HTTP, creating manual spans, recording custom metrics, emitting correlated logs, configuring the Collector, and running the full stack with Docker Compose. It finishes with a side-by-side comparison of the OTel metrics API against the Prometheus client library.
What OpenTelemetry Provides
OpenTelemetry (OTel) standardises three types of telemetry signals. Traces track a single request as it flows through your system — each operation within the trace is a span with a name, timing, attributes, and a parent span forming a tree. Metrics are numerical measurements collected over time — counters, histograms, gauges. Logs are timestamped structured records that carry trace context automatically, so each log record is tagged with the trace ID and span ID of the request that produced it.
The key benefit is correlation. Because all three signals share the same trace ID, you don’t need to manually generate and thread a correlation ID through every service and log call — OTel does it automatically. A log line, a metric exemplar, and a span from the same request all carry the same trace ID, so you can jump between them freely.
OTel is a vendor-neutral standard. You instrument once and send the data to whatever backend you want: Jaeger for traces, Prometheus for metrics, Datadog for everything.
| Component | What it does |
|---|---|
| API | Interfaces your code programs against (otel.Tracer(), otel.Meter()) |
| SDK | Implements the API — manages span lifecycle, metric aggregation, log batching |
| Exporters | Send data to backends (Jaeger, Prometheus, Datadog, etc.) |
| Collector | A standalone proxy that receives, processes, and exports telemetry |
| Instrumentation libraries | Auto-instrument common frameworks (HTTP, gRPC, databases) |
In this post, we will go through an example straightforward application: your application uses the OTel SDK to create traces, metrics, and logs. The SDK sends this data via OTLP (the OpenTelemetry Protocol) to a Collector, which routes it to the appropriate backends.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────┐ OTLP/HTTP ┌───────────────┐
│ Your App │ ──────────────────> │ OTel │
│ │ │ Collector │
│ OTel SDK │ │ │
│ + Exporters │ │ receivers │
└──────────────┘ │ processors │
│ exporters │
└───┬───────┬───┘
│ │
┌────▼───┐ ┌──▼────────┐
│Jaeger │ │Prometheus │
│(traces)│ │(metrics) │
└────────┘ └───────────┘
Full Source Code
https://gitlab.com/kimserey.lam/otel-learn
The Bookstore API
We start with a clean three-layer bookstore API with zero instrumentation. The code uses a simple architecture that makes the OTel additions easy to follow:
1
2
3
4
5
6
cmd/bookstore/main.go # Entry point
internal/
model/book.go # The Book data structure
store/books.go # In-memory data store
service/books.go # Business logic
handler/books.go # HTTP handlers
The Book model is minimal:
1
2
3
4
5
6
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
The store layer is an in-memory map[string]Book with a sync.RWMutex for thread safety, exposing List(ctx), Get(ctx, id), and Create(ctx, book). Every method takes a context.Context as its first argument — standard Go practice, but also critical for OTel since the context carries the active trace span.
The handler layer registers four routes using Go 1.22’s enhanced http.ServeMux pattern syntax:
| Route | Method | Description |
|---|---|---|
GET /books |
listBooks |
Returns all books as JSON |
GET /books/{id} |
getBook |
Returns one book, or 404 |
POST /books |
createBook |
Creates a book from JSON body |
GET /health |
health |
Health check |
The entry point wires everything together:
1
2
3
4
5
6
7
8
9
10
11
func main() {
bookStore := store.New()
bookService := service.New(bookStore)
bookHandler := handler.New(bookService)
mux := http.NewServeMux()
bookHandler.RegisterRoutes(mux)
srv := &http.Server{Addr: ":8080", Handler: mux}
srv.ListenAndServe()
}
The server works, but we have zero visibility into how long each request takes, which internal operations are slow, how many books are being created, or what happened when a request failed.
Adding Traces
Tracing lets you see the full journey of a request through your application. We add both auto-instrumentation (the HTTP layer instruments itself) and manual instrumentation (we create spans in our business logic).
TracerProvider Setup
Before you can create spans, you need a TracerProvider — the SDK component that manages span lifecycle and exports them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func setupTracerProvider(ctx context.Context, res *resource.Resource) (*sdktrace.TracerProvider, error) {
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(stripScheme(endpoint())),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
return tp, nil
}
The exporter sends spans over HTTP using OTLP. WithBatcher batches spans before exporting for efficiency. The resource identifies this service — service.name=bookstore, service.version=0.1.0. The provider is registered globally with otel.SetTracerProvider(tp).
Auto-Instrumentation with otelhttp
Wrap your http.Handler to get automatic HTTP tracing:
1
2
otelHandler := otelhttp.NewHandler(mux, "bookstore")
srv := &http.Server{Addr: ":8080", Handler: otelHandler}
This single line gives you a root span for every incoming HTTP request, automatic span attributes (http.method, http.route, http.status_code, url.path), 5xx responses marked as Error, and context propagation via the traceparent header so distributed traces work across services.
Manual Instrumentation
To see what happens inside your application, create manual spans. Get a tracer, then call Start:
1
2
3
4
5
6
7
8
9
10
var tracer = otel.Tracer("bookstore/store")
func (s *BookStore) Get(ctx context.Context, id string) (model.Book, error) {
ctx, span := tracer.Start(ctx, "BookStore.Get")
defer span.End()
span.SetAttributes(attribute.String("book.id", id))
// ... business logic ...
}
The returned ctx carries the new span. When you pass this ctx to a downstream function, that function’s span becomes a child — building the trace tree. Attributes are key-value pairs that add context to a span and show up in Jaeger, letting you search for traces by attribute value.
When an operation fails, record the error on the span:
1
2
3
4
5
6
if !ok {
err := fmt.Errorf("book not found: %s", id)
span.RecordError(err)
span.SetStatus(codes.Error, "not found")
return model.Book{}, err
}
RecordError adds an event with the error message and stack trace. SetStatus(codes.Error, ...) marks the span red in Jaeger so you can spot failures at a glance.
Trace Structure
After instrumentation, a GET /books/1 request produces this trace:
1
2
3
4
HTTP GET /books/1 [=====================] root span (otelhttp)
└── BookService.GetBook [=================] service span (manual)
└── BookStore.Get [============] store span (manual)
book.id = "1"
Each span has its own timing, attributes, and status. Spans don’t have to be nested — a parent span can have multiple children that run one after the other:
1
2
3
HTTP POST /books [=======================================]
└── BookStore.Save [============]
└── NotifyService.Send [============]
In Jaeger’s trace view, traces are displayed as an icicle chart: the root span sits at the top, child spans cascade downward, and time flows left to right.
Context Propagation
The context.Context is the thread that ties spans together. Every function in the chain must accept ctx context.Context as the first parameter and pass the ctx returned by tracer.Start() to downstream calls. If you break the context chain (e.g., using context.Background() instead of the request context), the child span becomes a separate trace.
For cross-service propagation, OTel uses the W3C traceparent HTTP header:
1
2
3
4
5
6
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ trace flags (01 = sampled)
│ │ └─ parent span ID (8 bytes)
│ └─ trace ID (16 bytes)
└─ version (always 00)
When Service A calls Service B, otelhttp injects the header with A’s current trace ID and span ID. Service B reads it, creates a new span linked as a child, and both services end up in the same trace. The propagator is set up with:
1
2
3
4
5
prop := propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
otel.SetTextMapPropagator(prop)
Adding Metrics
Traces show you individual requests. Metrics show you aggregate behaviour over time — request rates, error rates, latency percentiles. They’re lightweight, cheap to collect, and the foundation for dashboards and alerting.
MeterProvider Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func setupMeterProvider(ctx context.Context, res *resource.Resource) (*sdkmetric.MeterProvider, error) {
exporter, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpoint(stripScheme(endpoint())),
otlpmetrichttp.WithInsecure(),
)
if err != nil {
return nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(
exporter,
sdkmetric.WithInterval(10*time.Second),
)),
sdkmetric.WithResource(res),
)
return mp, nil
}
The PeriodicReader collects and exports metrics every 10 seconds. The OTLP HTTP exporter sends metrics to the Collector, same as traces.
Instrument Types
OTel defines several instrument types:
| Instrument | Use case | Example |
|---|---|---|
| Counter | Values that only increase | Total books created |
| UpDownCounter | Values that increase and decrease | Current books in store |
| Histogram | Distribution of values | Request duration |
| Gauge | Point-in-time snapshot of a value | CPU usage, queue depth |
Custom Metrics
In the service layer, we create three custom instruments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var meter = otel.Meter("bookstore/service")
booksCreated, _ := meter.Int64Counter("books.created",
metric.WithDescription("Total number of books created"),
metric.WithUnit("{book}"),
)
booksCount, _ := meter.Int64UpDownCounter("books.count",
metric.WithDescription("Current number of books in the store"),
metric.WithUnit("{book}"),
)
operationDuration, _ := meter.Float64Histogram("books.operation.duration",
metric.WithDescription("Duration of service-layer operations"),
metric.WithUnit("s"),
)
Recording is straightforward — increment the counter when a book is created:
1
2
3
4
5
6
func (svc *BookService) CreateBook(ctx context.Context, book model.Book) model.Book {
// ... create the book ...
svc.booksCreated.Add(ctx, 1)
svc.booksCount.Add(ctx, 1)
return created
}
Measure operation duration with the histogram:
1
2
3
4
5
6
7
8
9
func (svc *BookService) GetBook(ctx context.Context, id string) (model.Book, error) {
start := time.Now()
defer func() {
svc.operationDuration.Record(ctx, time.Since(start).Seconds(),
metric.WithAttributes(attribute.String("operation", "GetBook")),
)
}()
// ... business logic ...
}
The operation attribute becomes a label in Prometheus, letting you break down duration by operation type.
Auto-Instrumented HTTP Metrics
The otelhttp.NewHandler middleware also records HTTP metrics automatically:
| Metric | Type | Description |
|---|---|---|
http.server.request.duration |
Histogram | Request latency |
http.server.request.body.size |
Histogram | Request body size |
http.server.response.body.size |
Histogram | Response body size |
http.server.active_requests |
UpDownCounter | Currently processing |
These come for free — no code changes needed.
Push vs Pull
In our architecture, the app pushes to the Collector via OTLP, and then the Collector exposes a Prometheus-compatible scrape endpoint. Prometheus scrapes the Collector, not the app directly. This decouples the app from Prometheus.
OTel uses dots in metric names (e.g., books.created), but Prometheus uses underscores (e.g., bookstore_books_created_total). The Collector’s Prometheus exporter handles this conversion automatically.
Adding Logs
Logs are the oldest form of telemetry, but OTel gives them a superpower: trace correlation. Every log record is automatically tagged with the trace ID and span ID of the active request, letting you jump between logs and traces seamlessly.
LoggerProvider Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func setupLoggerProvider(ctx context.Context, res *resource.Resource) (*sdklog.LoggerProvider, error) {
exporter, err := otlploghttp.New(ctx,
otlploghttp.WithEndpoint(stripScheme(endpoint())),
otlploghttp.WithInsecure(),
)
if err != nil {
return nil, err
}
lp := sdklog.NewLoggerProvider(
sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)),
sdklog.WithResource(res),
)
return lp, nil
}
The slog Bridge
Go 1.21 introduced log/slog — a structured logging package in the standard library. Rather than using OTel’s log API directly, we use the otelslog bridge, which makes slog emit log records through the OTel pipeline:
1
2
logger = otelslog.NewLogger("bookstore")
slog.SetDefault(logger)
Now every slog.Info(), slog.Warn(), slog.Error() call sends log records to the OTel Collector via OTLP.
Writing Logs with Trace Correlation
The key is slog.InfoContext(ctx, ...) — the Context variant. The bridge extracts the active span from the context and attaches its trace ID and span ID to the log record:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (svc *BookService) CreateBook(ctx context.Context, book model.Book) model.Book {
slog.InfoContext(ctx, "creating book",
"book.title", book.Title,
"book.author", book.Author,
)
created := svc.store.Create(ctx, book)
slog.InfoContext(ctx, "book created",
"book.id", created.ID,
"book.title", created.Title,
)
return created
}
In the Collector’s debug output, each log record includes TraceID and SpanID, linking the log record to the exact trace and span that produced it. If you use slog.Info() without context, the log record won’t have trace/span IDs — always use the context variants (InfoContext, WarnContext, ErrorContext) when you’re inside a request handler.
The Three-Way Correlation
With all three signals in place, they’re connected:
- Trace -> Logs: Each log record carries the trace ID, so you can find all logs for a specific trace
- Logs -> Trace: From a log line, you can jump to the full trace to see the request flow
- Metrics + Traces: They share the same resource attributes (
service.name,service.version), linking them to the same service
The OTel Collector
The OTel Collector is a vendor-neutral proxy that sits between your application and your observability backends. It receives telemetry, processes it, and exports it to one or more destinations.
Why Use a Collector
You could export directly from your app to Jaeger and Prometheus. But the Collector gives you:
- Decoupling: Switch from Jaeger to Tempo? Change the Collector config, not your code.
- Processing: Batch data, drop noisy spans, add attributes, sample traces — all without touching your app.
- Fan-out: Send the same data to multiple backends.
- Buffering: The Collector can buffer data during backend outages.
The Pipeline Model
The Collector is built around pipelines. Each pipeline has three stages:
1
Receivers ──> Processors ──> Exporters
You define separate pipelines for each signal type (traces, metrics, logs).
Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
send_batch_size: 1024
timeout: 5s
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
namespace: bookstore
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger, debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug]
The otlp receiver accepts data over both gRPC (port 4317) and HTTP (port 4318). Our Go app sends data over HTTP to port 4318.
The batch processor groups data points together before sending them to exporters — it sends a batch when it reaches 1024 items or every 5 seconds, whichever comes first.
For exporters: modern Jaeger natively accepts OTLP on port 4317. The Prometheus exporter doesn’t push to Prometheus — it exposes a /metrics endpoint on port 8889 that Prometheus scrapes. The namespace: bookstore prefix is added to all metric names. The debug exporter prints telemetry to stdout.
Each signal gets its own pipeline. Notice that traces go to two exporters (otlp/jaeger and debug) — the Collector fans out automatically.
There are two Collector distributions: core (minimal) and contrib (includes community-contributed components). We use contrib because the Prometheus exporter is not in the core distribution.
Running Everything with Docker Compose
Docker Compose runs four services that form a complete observability stack.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
services:
bookstore:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "8080:8080"
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
depends_on:
- otel-collector
otel-collector:
image: otel/opentelemetry-collector-contrib:0.117.0
volumes:
- ./config/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "8889:8889" # Prometheus metrics endpoint
depends_on:
- jaeger
jaeger:
image: jaegertracing/all-in-one:latest
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686" # Jaeger UI
prometheus:
image: prom/prometheus:v3.1.0
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090" # Prometheus UI
depends_on:
- otel-collector
The bookstore app uses the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to reach the Collector via Docker network hostname otel-collector. The Collector uses the contrib image and mounts the config file. Jaeger’s all-in-one image runs the collector, query, and UI in a single container with native OTLP enabled. Prometheus scrapes the Collector’s Prometheus exporter every 15 seconds.
Network connectivity uses Docker Compose’s default network where services reach each other by name:
1
2
3
bookstore → otel-collector:4318 (OTLP HTTP)
otel-collector → jaeger:4317 (OTLP gRPC for traces)
prometheus → otel-collector:8889 (HTTP scrape for metrics)
The Dockerfile uses a multi-stage build — a Go 1.25 builder stage compiles a static binary with CGO_ENABLED=0, and a minimal Alpine runtime stage copies just the binary (~8MB):
1
2
3
4
5
6
7
8
9
10
11
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bookstore ./cmd/bookstore
FROM alpine:3.20
COPY --from=builder /bookstore /bookstore
EXPOSE 8080
CMD ["/bookstore"]
Build and start everything with docker compose up --build. Once all services are running, generate some traffic:
1
2
3
4
5
6
7
8
9
curl http://localhost:8080/health
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title": "Observability Engineering", "author": "Majors, Fong-Jones & Miranda", "year": 2022}'
curl http://localhost:8080/books
curl http://localhost:8080/books/1
curl http://localhost:8080/books/999 # 404 — generates an error span
Viewing Your Telemetry
Traces in Jaeger
Open http://localhost:16686. Select bookstore in the Service dropdown and click Find Traces. Click on a trace for GET /books/{id} to see the waterfall view:
1
2
3
bookstore: GET /books/{id} ████████████████████ 5.2ms
└── bookstore: BookService.GetBook ██████████████████ 4.8ms
└── bookstore: BookStore.Get █████████████████ 4.5ms
Each bar is a span. Click on a span to see its tags/attributes (book.id, http.method, http.route, http.status_code), logs/events (error events if the span recorded an error), process metadata (service.name=bookstore), and timing. Error traces have red indicators — the GET /books/999 request shows a span with status=Error.
In the search panel, add a tag filter like book.id = 1 to find all traces related to that book.
Metrics in Prometheus
Open http://localhost:9090. Type queries in the Expression box:
bookstore_books_created_total
bookstore_books_count
histogram_quantile(0.95, rate(bookstore_books_operation_duration_bucket[5m]))
Auto-instrumented HTTP metrics come for free:
rate(bookstore_http_server_request_duration_seconds_count[5m])
histogram_quantile(0.95, rate(bookstore_http_server_request_duration_seconds_bucket[5m]))
sum by (http_route, http_request_method) (
rate(bookstore_http_server_request_duration_seconds_count[5m])
)
Error rate (4xx + 5xx responses):
sum(rate(bookstore_http_server_request_duration_seconds_count{http_response_status_code=~"[45].."}[5m]))
/
sum(rate(bookstore_http_server_request_duration_seconds_count[5m]))
Logs in Collector Output
Since we export logs to the debug exporter, they appear in the Collector’s stdout with docker compose logs -f otel-collector:
1
2
3
4
5
6
7
8
9
LogRecord #0
ObservedTimestamp: 2024-01-15 10:30:42.123456789 +0000 UTC
SeverityText: INFO
Body: Str(book created)
Attributes:
-> book.id: Str(1)
-> book.title: Str(The Go Programming Language)
TraceID: abc123def456...
SpanID: 789xyz...
The TraceID and SpanID link the log record to the exact trace and span that produced it. In production with Grafana and Loki, you could click a log line and jump directly to its trace in Tempo or Jaeger.
OpenTelemetry vs Prometheus Client
You might wonder why we used OpenTelemetry for metrics instead of the Prometheus client library directly. After all, our metrics end up in Prometheus anyway.
Feature Comparison
| Prometheus client | OpenTelemetry | |
|---|---|---|
| Scope | Metrics only | Metrics + traces + logs |
| Export model | Pull — exposes /metrics endpoint |
Push — sends OTLP to a Collector |
| Backend lock-in | Tied to Prometheus | Vendor-neutral |
| Trace correlation | None | Built-in |
Defining a Counter
With the Prometheus client:
1
2
3
4
5
6
7
8
9
var booksCreated = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "bookstore",
Name: "books_created_total",
Help: "Total number of books created",
})
func init() {
prometheus.MustRegister(booksCreated)
}
With OpenTelemetry:
1
2
3
4
5
6
var meter = otel.Meter("bookstore/service")
booksCreated, _ := meter.Int64Counter("books.created",
metric.WithDescription("Total number of books created"),
metric.WithUnit("{book}"),
)
Similar amount of code. The Prometheus version requires a global init() to register the metric. OTel metrics are registered implicitly when you create them from a Meter.
Recording a Histogram
With the Prometheus client:
1
opDuration.WithLabelValues("GetBook").Observe(time.Since(start).Seconds())
With OpenTelemetry:
1
2
3
svc.operationDuration.Record(ctx, time.Since(start).Seconds(),
metric.WithAttributes(attribute.String("operation", "GetBook")),
)
Prometheus uses WithLabelValues() with label names declared upfront. OTel uses attribute.String() at record time — more flexible, but you need to be careful about cardinality. OTel always takes a ctx parameter, which is how metrics get correlated with traces.
Exposing Metrics
This is where the architectures diverge most. The Prometheus client exposes a /metrics endpoint that Prometheus scrapes directly:
1
mux.Handle("/metrics", promhttp.Handler())
OTel pushes metrics to the Collector, and the Collector exposes the Prometheus endpoint:
1
2
3
┌─────────────┐ OTLP push ┌────────────────┐ scrape ┌────────────┐
│ Your App │ ──────────> │ OTel Collector │ <─────── │ Prometheus │
└─────────────┘ └────────────────┘ └────────────┘
The extra Collector hop adds a component to run, but decouples your app from the backend entirely. If you later switch from Prometheus to Grafana Cloud or Datadog, you change the Collector config — zero code changes.
When to Use Which
Use the Prometheus client when you only need metrics, you’re running a simple service with the fewest dependencies, and your infrastructure is 100% Prometheus. Use OpenTelemetry when you want metrics and traces, vendor flexibility, trace correlation in a multi-service architecture, or your organisation is standardising on OTel.
You can also use OTel with Prometheus’s pull model directly — the OTel Prometheus exporter serves a /metrics endpoint from the OTel SDK without needing a Collector for metrics:
1
2
promExporter, _ := prometheus.New()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(promExporter))
Naming Translation
When OTel metrics land in Prometheus, names are converted automatically:
| OTel metric name | Prometheus name |
|---|---|
books.created (Counter) |
bookstore_books_created_total |
books.count (UpDownCounter) |
bookstore_books_count |
books.operation.duration (Histogram) |
bookstore_books_operation_duration_seconds |
Dots become underscores. Counters get _total. Duration histograms get _seconds. The bookstore prefix comes from the namespace in the Collector config.