Chapter 17

Monitoring & Observability

Structured access logs, request ID correlation, the process dashboard, pprof, per-request cost measurement, Server-Timing and health checks.

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:

Go
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:

Go
// 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:

Go
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):

Go
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:

Go
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):

Go
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:

Go
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:

Go
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:

Go
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:

How a request ID reaches the access-log record The access log starts its timer before the chain and writes its record after it, and because its field setters run in that second pass they can read a request ID that a later middleware generated. accesslog.Handler (UseRouter) startTime = time.Now(). Wraps the writer in a response recorder when bodies or byte counts are enabled, then calls ctx.Next(). ctx.Next() requestid.New(requestid.ServerOnlyGenerator) (Use) generates a UUIDv4, ignores any inbound X-Request-Id, sets the X-Request-Id response header, and calls ctx.SetID(id). ctx.Next() your handlers accesslog.GetFields(ctx).Set("tenant", ...) and the application logger, both able to read requestid.Get(ctx) THE CHAIN RETURNS: EVERYTHING BELOW RUNS ON THE WAY OUT accesslog after() latency = time.Since(startTime) the AddFields setters run now f.Set("request_id", requestid.Get(ctx)) sees the ID even though the access log was registered first. httpcost stores its Metric here too. one record timestamp, latency, code, method, path, ip, query, params, bytes, and fields{request_id, tenant, cost} access_log.json buffered os.Stdout the same lines, unbuffered Broker listeners live tail and alerting Async = true runs after() on a goroutine over ctx.Clone(), so the response never waits on log I/O; Close flushes with a ten-second deadline. accesslog.New registers its own Close on the interrupt list. Give the application logger the same request_id field and one search returns the whole request.
The access log starts its timer before the chain and writes its record after it, and because its field setters run in that second pass they can read a request ID that a later middleware generated.

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:

Go
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:

Go
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:

Go
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.

Go
// 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:

Go
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:

Go
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:

Shell
# 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.out

An 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:

Go
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:

Go
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:

Go
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:

Go
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:

Go
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:

Go
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:

Go
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):

Go
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:

Go
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

  • accesslog is the backbone: JSON lines to a file, Async on, bodies off, custom fields for the request ID and anything else your pipeline indexes. Register with UseRouter, close on shutdown, plug a rotating writer into accesslog.New for rotation. LogRequestsWith(ac) is the builder slot for a tuned instance.
  • requestid correlates every log line of a request; ServerOnlyGenerator keeps the IDs unforgeable.
  • app.Logger() is a leveled golog logger; golog.Fields adds structure and SetFormat("json") matches the access log’s pipeline.
  • monitor serves live process/OS stats as JSON and as a dashboard: GET and POST on the same path, behind admin authentication, with a positive RefreshInterval and Stop on shutdown.
  • pprof mounts the Go profiler at /debug/pprof; httpcost prices each request; servertiming surfaces server phases in browser devtools.
  • Health(true, env, dev) registers GET /health at the root via modrevision, reporting the git revision and build time embedded by go 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

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.