An application you cannot observe is an application you cannot operate.
Iris ships the observability essentials in-tree: a structured access log,
request correlation IDs, an application logger, a live process dashboard,
the Go profiling endpoints, per-request cost measurement, Server-Timing
responses and a health endpoint that reports build information. This
chapter wires each of them the way you would in production, then closes
with an honest look at where third-party tools (Prometheus,
OpenTelemetry) take over.
Background
The day you deploy, your relationship with your own code changes. On your machine, when something misbehaves, you attach a debugger, set a breakpoint and inspect variables at leisure. In production none of that exists: the process runs on a remote machine, serves real users and cannot be paused. By the time someone reports a problem, the moment that caused it is minutes or days gone. The only way to understand it is through what the application recorded while it happened. Observability is the discipline of emitting that evidence deliberately (before you need it) instead of hoping to reconstruct events from memory.
The evidence comes in three shapes, and the industry uses three words for them:
- Logs are timestamped records of individual events: “this request returned 500 at 03:12:07”, “user 42 signed up”. They answer what exactly happened. A structured log records each entry as key-value data (in practice, one JSON object per line) instead of free-form prose, so tools can filter and index it rather than merely display it.
- Metrics are numbers tracked over time: requests per second, memory in use, the error rate. A metric forgets individual events and keeps only the aggregate, which makes it tiny to store and ideal for graphs and alerts. Metrics answer how the system is trending.
- Traces follow one request across every service it touches, with timing for each step: this instance, then the database, then a downstream API. Traces answer where the time went when a single operation spans several processes.
A rough rule: metrics tell you something is wrong, logs tell you what, traces tell you where.
Two more terms carry this chapter. A health check is a cheap endpoint whose only job is to be polled by machines. The poller is typically an orchestrator, supervising software such as a load balancer or Kubernetes that starts, stops and routes traffic to your instances. A failing liveness check tells the orchestrator “this process is dead, restart it”; a failing readiness check tells it “this instance cannot serve right now, send traffic elsewhere”, a distinction the health section returns to.
Finally, request IDs. One production request leaves fragments everywhere: a line in the access log, several in the application log, entries on the database and on downstream services, all interleaved with the fragments of thousands of neighboring requests. When a user reports one failure, finding its fragments by timestamp is archaeology. A request ID (or correlation ID) is a unique value stamped onto every record a request produces, across every system it touches. Debugging at 3am then starts with one search: filter all logs by the ID from the user’s error message, and the complete story of that single request reads out in order. Little in this chapter is glamorous, but this one habit routinely turns hours of guessing into minutes of reading.
Structured Access Logging
Chapter 12 introduced middleware/accesslog
as a one-liner. In production the access log is the backbone of your
observability: one record per request (latency, status, sizes, custom
fields) written as JSON lines that any log shipper can tail, parse and
index. The configuration below is tuned for exactly that:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/accesslog"
"github.com/kataras/iris/v14/middleware/requestid"
)
func newAccessLog() *accesslog.AccessLog {
ac := accesslog.File("access_log.json")
// Fire the log line in a goroutine on a copy of the request
// context, so the response never waits on log I/O.
ac.Async = true
// Keep payloads out of production logs: privacy and volume.
ac.RequestBody = false
ac.ResponseBody = false
// Body sizes are cheap to record. The full wire sizes
// (BytesReceived/BytesSent) dump the whole request and response
// to count headers too — expensive, leave them off.
ac.BytesReceivedBody = true
ac.BytesSentBody = true
// One line per record, even when a panic is logged.
ac.KeepMultiLineError = false
ac.PanicLog = accesslog.LogHandler
// Compact JSON: one object per line.
ac.SetFormatter(&accesslog.JSON{})
// Attach the correlation ID to every record.
ac.AddFields(func(ctx iris.Context, f *accesslog.Fields) {
f.Set("request_id", requestid.Get(ctx))
})
return ac
}
func main() {
app := iris.New()
ac := newAccessLog()
defer ac.Close()
app.UseRouter(ac.Handler)
app.Use(requestid.New(requestid.ServerOnlyGenerator))
app.Get("/", func(ctx iris.Context) {
ctx.JSON(iris.Map{"hello": "world"})
})
app.Listen(":8080")
}Register the handler with UseRouter so the log also captures requests
that never match a route: 404s, CORS preflights, rejected requests.
accesslog.File(path) opens a buffered file (flushed on Close),
accesslog.FileUnbuffered(path) trades throughput for immediate writes,
and accesslog.New(w io.Writer) accepts any writer, the hook for log
rotation below.
The Record
With the JSON formatter each line carries these keys (empty ones are
omitted): timestamp (unix milliseconds), latency, code, method,
path, ip, query, params, fields, request, response,
bytes_received, bytes_sent. The query, params and fields keys
hold arrays of {"key": ..., "value": ...} entries, the same shape in
compact and indented output. Without a formatter the default text
format is:
Time|Latency|Code|Method|Path|IP|Path Params Query Fields|Bytes Received|Bytes Sent|Request|Response|Three formatters ship with the package, all set through SetFormatter:
// JSON lines. A non-empty Indent pretty-prints instead;
// HumanTime replaces the unix timestamp with TimeFormat text.
ac.SetFormatter(&accesslog.JSON{})
// CSV, optionally with a header row on a fresh file.
ac.SetFormatter(&accesslog.CSV{Header: true})
// Full control via text/template.
ac.SetFormatter(&accesslog.Template{
Text: "{{.Now.Format .TimeFormat}} {{.Code}} {{.Method}} " +
"{{.Path}} {{.Latency}}\n",
})A custom formatter is any type implementing accesslog.Formatter
(SetOutput(io.Writer) plus Format(*accesslog.Log) (bool, error)).
Custom Fields
Instance-level fields are computed for every request through AddFields,
as in the request_id example above. Handlers add per-request fields
through accesslog.GetFields:
func chargeHandler(ctx iris.Context) {
fields := accesslog.GetFields(ctx)
fields.Set("tenant", ctx.Params().GetString("tenant"))
fields.Set("items_charged", 3)
ctx.JSON(iris.Map{"ok": true})
}Fields land in the record’s fields array as {"key": ..., "value": ...}
entries, so your log pipeline can index them like any other attribute.
Skipping Noise and Logging Only Failures
Health checks and static assets can drown a log. Skip them per route
with accesslog.SkipHandler as a middleware, or conditionally with
accesslog.Skip(ctx):
app.Get("/ready", accesslog.SkipHandler, readyHandler)
app.Get("/assets/{file:path}", func(ctx iris.Context) {
accesslog.Skip(ctx)
// serve the asset...
})Setting ac.OnlyFailures = true logs only requests that end with a
status code of 400 or above, a useful mode for a second, dedicated
error log alongside your metrics.
Shutdown Semantics
Close flushes buffers, waits up to ten seconds for pending
asynchronous lines (CloseContext accepts a custom deadline) and closes
the underlying file. In manual apps, defer ac.Close(); the middleware
also registers its own Close on the interrupt signal. With the
builder, LogRequestsWith registers the closer for you:
ac := newAccessLog()
app := iris.NewBuilder().
Prefix("/api").
AllowOrigin("none").
Compression(true).
LogRequestsWith(ac). // your tuned instance, closed on shutdown
Health(true, "production", "ops@example.com").
Errors(errors.NewOptions()).
Deferrables().
Services( /* your services */ ).
RouterMiddlewares(requestid.New(requestid.ServerOnlyGenerator)).
Middlewares().
API("/orders", NewOrdersAPI).
Build()LogRequests(true) installs the framework’s tuned default instead:
convenient in development, but it logs request and response bodies,
which is rarely what production wants. Prefer LogRequestsWith with a
configuration like the one above.
Log Rotation
The access log writes to whatever io.Writer you give it, so rotation
plugs in at the writer level. With the third-party
gopkg.in/natefinch/lumberjack.v2 package (clearly a third-party
choice, any rotating writer works):
import "gopkg.in/natefinch/lumberjack.v2"
ac := accesslog.New(&lumberjack.Logger{
Filename: "/var/log/myapp/access_log.json",
MaxSize: 100, // megabytes per file
MaxBackups: 7,
MaxAge: 28, // days
Compress: true,
})If an external rotator (logrotate, a sidecar) truncates the file
instead, the middleware exposes ac.TruncateFile(0) to clear the
current file in place without reopening it.
Live Tail with the Broker
The broker broadcasts every accesslog.Log to registered listeners,
the building block for a live log view over SSE or WebSocket, or for an
in-process alerting hook. Create it before the server starts:
broker := ac.Broker()
go func() {
logs := broker.NewListener() // read-only channel of accesslog.Log
for log := range logs {
if log.Code >= 500 {
// push to an alert channel, a websocket, a counter...
}
}
}()Call broker.CloseListener(logs) when a consumer goes away; Close on
the access log terminates all listeners.
The bare go func() above is the shortest way to show the channel. In a
builder application, wrap the loop in an iris.Worker instead (the
CPUAlerter further down this chapter is the shape) and pass it to
Services: the listener then starts after the initializers, stops on the
shutdown context, and Shutdown waits for it, none of which a loose
goroutine gives you.
Request Correlation
One request produces many log lines: the access log record, application
log entries, database logs, entries on downstream services. A
correlation ID ties them together. The requestid middleware assigns
one per request, stores it on the context and reflects it in the
X-Request-Id response header:
import "github.com/kataras/iris/v14/middleware/requestid"
app.Use(requestid.New())
app.Get("/", func(ctx iris.Context) {
id := requestid.Get(ctx) // "" if the middleware did not run
ctx.JSON(iris.Map{"request_id": id})
})The default generator honors a client-supplied X-Request-Id header and
generates a UUIDv4 only when it is missing. Behind a trusted proxy that
assigns IDs, that is exactly right. Facing untrusted clients it is a log
injection vector: anyone can choose the value that lands in your logs.
When IDs must be unforgeable, generate them server-side only:
app.Use(requestid.New(requestid.ServerOnlyGenerator))Because the ID travels in the response header, a client (or your own
outgoing HTTP calls, by setting the same header on downstream requests)
can quote it in bug reports, and support can grep every system for it.
The access log picks it up through the AddFields setter shown earlier;
your application logger should include it too, which is where the next
section comes in.
That setter is worth a second look, because at first glance it should not
work. The access log is registered with UseRouter, before
requestid.New, so when its handler runs there is no ID yet. The reason it
sees one anyway is that field setters do not run on the way in. They run in
the access log’s second pass, after the chain has returned:
Application Logging
The access log records what happened to requests; the application logger
records what your code decided along the way. Every Iris application
carries a golog logger, reachable as
app.Logger() at startup and ctx.Application().Logger() inside
handlers:
package main
import (
"os"
"github.com/kataras/golog"
"github.com/kataras/iris/v14"
)
func main() {
app := iris.New()
logger := app.Logger()
logger.SetLevel("info") // "fatal","error","warn","info","debug"
logger.SetTimeFormat("2006-01-02 15:04:05")
// Keep stdout and add a file output.
f, err := os.OpenFile("app.log",
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
logger.Fatalf("open log file: %v", err)
}
defer f.Close()
logger.AddOutput(f)
app.Get("/signup", func(ctx iris.Context) {
ctx.Application().Logger().Infof("user signed up",
golog.Fields{
"plan": "free",
"source": "landing-page",
})
ctx.JSON(iris.Map{"ok": true})
})
app.Listen(":8080")
}The leveled methods come in pairs (Debug/Debugf, Info/Infof,
Warn/Warnf, Error/Errorf, Fatal/Fatalf), and any
golog.Fields value passed among the arguments becomes structured
fields on the entry rather than format arguments. To emit the
application log itself as JSON (so it flows through the same pipeline as
the access log), set the formatter:
app.Logger().SetFormat("json")SetOutput(w) replaces all outputs with one writer, AddOutput(w...)
appends. At SetLevel("debug") Iris also prints route registrations and
other framework internals: useful in development, noisy in production;
"info" is the production default worth keeping.
The Monitor Dashboard
The middleware/monitor package samples process and operating system
statistics (CPU, RAM, TCP connections, load average) on a fixed
interval and serves them two ways: as JSON and as a self-refreshing HTML
dashboard. The dashboard page fetches its numbers with a POST request
to its own URL, so the two handlers mount on the same path:
package main
import (
"time"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/basicauth"
"github.com/kataras/iris/v14/middleware/monitor"
)
func main() {
app := iris.New()
m := monitor.New(monitor.Options{
RefreshInterval: 2 * time.Second, // stats collection
ViewRefreshInterval: 2 * time.Second, // dashboard refresh
ViewTitle: "My App",
})
iris.RegisterOnInterrupt(m.Stop)
admin := app.Router("/admin", basicauth.Default(map[string]string{
"admin": "change-me",
}))
admin.Get("/monitor", m.View) // HTML dashboard
admin.Post("/monitor", m.Stats) // JSON stats (used by the view)
app.Listen(":8080")
}Three practical notes. First, RefreshInterval left unset defaults
to monitor.DefaultRefreshInterval (2 seconds), so a zero-value
monitor.Options{} is safe; set it yourself when the collection
cadence matters. Second, the dashboard exposes live host
metrics:
mount it behind authentication, as above, never publicly. Third, stop
the collector on shutdown: iris.RegisterOnInterrupt(m.Stop) in manual
apps, Deferrables(m.Stop) in builder apps.
Options also accepts PID (defaults to the current process) and
ViewAnimationInterval for the chart animation.
Reading the Stats Programmatically
The same numbers the dashboard renders are available in code, which
turns the monitor into a simple in-process alerting source. A sampling
loop is long-running work, so it belongs in an iris.Worker rather than
a goroutine started next to the server: the builder starts Run after
the initializers, cancels its context at shutdown, and waits for it to
return (Chapter 3). A bare go func() gets none
of that, and a time.Tick inside one leaks its ticker for the life of
the process.
// CPUAlerter watches the numbers the dashboard renders and warns when
// the host is hot.
type CPUAlerter struct {
Monitor *monitor.Monitor
Logger *golog.Logger
Interval time.Duration
}
func (a *CPUAlerter) Run(ctx context.Context) error {
ticker := time.NewTicker(a.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil // shutdown: stop sampling.
case <-ticker.C:
if stats := a.Monitor.Holder.GetStats(); stats.OSCPU > 90 {
a.Logger.Warnf("high CPU: %.1f%%", stats.OSCPU)
}
}
}
}
// Stop releases what Run acquired. The ticker is Run's own, so there is
// nothing left to release here.
func (a *CPUAlerter) Stop(context.Context) error { return nil }Register it with the monitor it reads:
Deferrables(m.Stop).
Services(&CPUAlerter{
Monitor: m,
Logger: golog.Default,
Interval: 30 * time.Second,
}).monitor.Stats carries PIDCPU, PIDRAM, PIDConns for the process
and OSCPU, OSRAM, OSTotalRAM, OSLoadAvg, OSConns for the host.
Each value is also published through the standard expvar package under
pid_cpu, pid_ram, pid_conns, os_cpu, os_ram, os_total_ram,
os_load_avg and os_conns; see the
third-party section for exposing
them over HTTP.
Profiling with pprof
A profiler watches a running program and reports where its resources actually go, attributed to functions: which code burns the CPU, which allocates the memory, where goroutines sit blocked. Go’s profiler, pprof, is built into the runtime and works by sampling (briefly recording call stacks many times per second), so it is safe to leave available in production and enable on demand.
When the monitor tells you that CPU or memory is high, the Go
profiler tells you why. The middleware/pprof package mounts the
standard net/http/pprof endpoints as a routing controller. Profiles
reveal source file names and execution details, so guard them exactly
like the monitor:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/basicauth"
"github.com/kataras/iris/v14/middleware/pprof"
)
func main() {
app := iris.New()
auth := basicauth.Default(map[string]string{"ops": "change-me"})
app.Router("/debug/pprof", auth, pprof.New())
app.Listen(":8080")
}GET /debug/pprof renders an index of the available profiles (heap,
goroutine, allocs, block, mutex, threadcreate, cmdline, profile, trace).
The interesting ones connect straight to the Go toolchain:
# 30-second CPU profile, interactive analysis
go tool pprof http://ops:change-me@localhost:8080/debug/pprof/profile
# heap snapshot
go tool pprof http://ops:change-me@localhost:8080/debug/pprof/heap
# 5-second execution trace
curl -u ops:change-me -o trace.out \
"http://localhost:8080/debug/pprof/trace?seconds=5"
go tool trace trace.outAn alternative to permanent credentials is registering the routes only when a debug flag or environment variable is set, so production builds do not carry the endpoints at all. Chapter 20 walks through interpreting the profiles.
Per-Request Cost Measurement
The middleware/httpcost package answers a question most stacks cannot:
what does one request cost? It measures elapsed time, CPU time, memory
allocation delta and bytes written, then folds them into a single number
using per-unit prices you define, the basis for usage-based billing,
tenant showback or simply spotting expensive endpoints:
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/httpcost"
)
func main() {
app := iris.New()
app.Use(httpcost.New(httpcost.Options{
CostPerSecond: 0.01, // per wall-clock second
CostPerMB: 0.001, // per MB of RAM and network
CostPerCPU: 0.02, // per CPU-second
}))
app.Get("/reports", func(ctx iris.Context) {
ctx.JSON(iris.Map{"report": "..."})
})
app.Listen(":8080")
}httpcost.DefaultOptions carries those same values; setting a price to
zero disables that dimension, and at least one must remain enabled. The
middleware stores an *httpcost.Metric on the context after the
handler chain returns, so read it from something that wraps the chain;
the access log’s field setters are the natural place:
ac.AddFields(func(ctx iris.Context, f *accesslog.Fields) {
if metric, ok := httpcost.Get(ctx); ok {
f.Set("cost", metric.Cost)
f.Set("cpu_time", metric.CpuTime) // seconds
f.Set("memory_used", metric.MemoryUsed) // bytes
}
})The metric also carries StartTime, Duration and NetworkUsed (the
bytes written to the response). Be honest about precision: CPU and
memory are sampled process- and system-wide, so under concurrency each
request is attributed a share of everything that ran at the same time.
Treat the numbers as consistent approximations, not an invoice-grade
meter. Outside HTTP, the same measurement runs over any function:
cost := httpcost.Compute(func() {
// an import job, a report build...
})Server-Timing
The middleware/servertiming package emits the standard Server-Timing
response header, which browser devtools render as a waterfall of your
server-side phases, free instrumentation for anyone with the network
tab open:
import "github.com/kataras/iris/v14/middleware/servertiming"
app.Use(servertiming.Handler)
app.Get("/dashboard", func(ctx iris.Context) {
servertiming.Start(ctx, "db", "Load records")
// ... query the database ...
servertiming.End(ctx, "db")
servertiming.Start(ctx, "render", "Build payload")
// ... assemble the response ...
servertiming.End(ctx, "render")
ctx.JSON(iris.Map{"data": "..."})
})servertiming.Handler records the total as an app metric on its own.
Named metrics are optional refinement: every Start must be matched by
an End with the same name, and the header is written once all started
metrics have ended, before any body reaches the client. Timings are
visible to whoever receives the response, so keep metric names and
descriptions free of internal detail on public endpoints.
Health Checks and Build Information
Load balancers, container orchestrators and uptime monitors all need a cheap endpoint that answers “is this process alive, and which build is it?”. With the builder, one step provides it:
Health(true, "production", "ops@example.com")This registers GET /health at the root (deliberately outside the
Prefix), handled by the middleware/modrevision package. The response
is plain text: the server name and environment, the git revision and
build time when available, cloud container details when detected, and a
final OK. The revision and time come from core/pkg/buildinfo, which
reads the vcs.revision and vcs.time stamps Go embeds when you
go build inside a version-controlled checkout; go run or
-buildvcs=false builds simply omit them.
Outside the builder, mount the same handler yourself with full control over its options:
import "github.com/kataras/iris/v14/middleware/modrevision"
app.Get("/health", modrevision.New(modrevision.Options{
ServerName: "Orders API",
Env: "production",
Developer: "ops@example.com",
UnixTime: false,
CloudDetection: true,
}))The endpoint reveals environment and contact information by design. If
that is more exposure than your security policy allows, use NoHealth()
in the builder chain and register a terser endpoint of your own.
Liveness Versus Readiness
/health is a liveness check: it answers as long as the process serves
HTTP, dependencies aside, exactly what a restart-deciding orchestrator
should watch. A readiness check asks a different question (“should this
instance receive traffic right now?”) and must consult dependencies.
That is plain handler code:
import (
"database/sql"
"github.com/kataras/iris/v14/rest/errors"
)
func readyHandler(db *sql.DB) iris.Handler {
return func(ctx iris.Context) {
if err := db.Ping(); err != nil {
// 503 UNAVAILABLE, with the ping error logged and not sent.
errors.Unavailable.LogErr(ctx, err)
return
}
ctx.OK(iris.Map{"status": "ok"}, nil)
}
}
app.Get("/ready", accesslog.SkipHandler, readyHandler(db))Keep readiness checks fast and side-effect free (they run every few seconds) and skip them in the access log as shown, or they become the loudest client you have.
Third-Party Metrics and Tracing
Everything above is in-tree. Two observability pillars deliberately are
not: time-series metrics (Prometheus and friends) and distributed
tracing (OpenTelemetry). Iris ships no exporter or integration layer for
them, and this section shows no invented one. What Iris does provide is
iris.FromStd, which mounts any standard net/http handler. That is
all the glue these ecosystems need.
Prometheus is the de facto standard metrics system: a separate server
that stores time series (numbers with timestamps) and evaluates alert
rules over them. It works by pull: on a fixed interval it fetches
(“scrapes”) a plain-text endpoint on your application, conventionally
/metrics, where your process publishes its current counters and
gauges. Your application never sends anything; it only answers when
scraped.
Exposing a Prometheus scrape endpoint with the official client library
(github.com/prometheus/client_golang):
import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/kataras/iris/v14"
)
app.Any("/metrics", iris.FromStd(promhttp.Handler()))Instrumentation itself is ordinary middleware: register counters and
histograms with the Prometheus client and observe them in an app.Use
handler around ctx.Next(), using ctx.GetStatusCode(), ctx.Path()
and your own timer. No Iris-specific API is involved, so the client
library’s documentation applies as-is.
Go’s standard library has a minimal cousin of this idea: expvar lets
any package publish named variables, and its handler serves them all as
one JSON document over HTTP. The endpoint mounts the same way, and it is
worth mounting when the monitor middleware runs, because the monitor
publishes its gauges through expvar:
import "expvar"
app.Any("/debug/vars", iris.FromStd(expvar.Handler()))For distributed tracing, the OpenTelemetry Go SDK instruments at the
net/http layer; anything it produces as an http.Handler mounts
through iris.FromStd the same way, and its context propagation flows
through the standard request context that Iris hands to your code.
Protect /metrics and /debug/vars like the monitor and profiler:
behind authentication or reachable only from your internal network.
Summary
accesslogis the backbone: JSON lines to a file,Asyncon, bodies off, custom fields for the request ID and anything else your pipeline indexes. Register withUseRouter, close on shutdown, plug a rotating writer intoaccesslog.Newfor rotation.LogRequestsWith(ac)is the builder slot for a tuned instance.requestidcorrelates every log line of a request;ServerOnlyGeneratorkeeps the IDs unforgeable.app.Logger()is a leveled golog logger;golog.Fieldsadds structure andSetFormat("json")matches the access log’s pipeline.monitorserves live process/OS stats as JSON and as a dashboard:GETandPOSTon the same path, behind admin authentication, with a positiveRefreshIntervalandStopon shutdown.pprofmounts the Go profiler at/debug/pprof;httpcostprices each request;servertimingsurfaces server phases in browser devtools.Health(true, env, dev)registersGET /healthat the root viamodrevision, reporting the git revision and build time embedded bygo build; readiness checks that ping dependencies are plain handlers.- Metrics and tracing belong to Prometheus and OpenTelemetry;
iris.FromStd(promhttp.Handler())is the only glue required.
Further Reading
- OWASP Logging Cheat Sheet: what to log, what never to log, and how to keep attacker-controlled input from corrupting your logs.
- The Go net/http/pprof package:
the standard profiling endpoints that
middleware/pprofmounts. - The Go expvar package: the standard
mechanism behind
/debug/varsand the monitor’s published gauges. - MDN: Server-Timing: the header format and how browser devtools display it.
- Prometheus Go client:
counters, gauges and histograms for the
/metricsendpoint shown in this chapter. - OpenTelemetry Go SDK:
the tracing SDK whose
net/httpinstrumentation mounts throughiris.FromStd.