echo-observability
echo-observability provides request correlation, request-scoped Zap loggers,
and structured Zap access logging middleware for
Labstack Echo v5.
The module path is github.com/janisto/echo-observability; the declared Go
package name is obs.
This is not official Echo middleware. It is a small, opinionated package for
services that want a consistent production logging contract on Echo v5.
When To Use It
Use this package when an Echo v5 service needs:
- Validated or generated request IDs with response propagation.
- Request-scoped
*zap.Logger values through obs.Logger(ctx).
- Strict W3C
traceparent parsing and trace-level log correlation.
- One structured access log after each Echo request.
- Low-cardinality
path_template values from Echo's c.Path().
- Echo v5 status resolution for committed responses and errors implementing
echo.HTTPStatusCoder.
- Generic, Google Cloud, AWS, and Azure JSON field presets.
- Panic access logging followed by re-panic for the application's recovery middleware.
- Router-wide request metadata for health checks, readiness probes, redirects,
static handlers, 404/405 handlers, and recovery middleware.
This package does not create traces, spans, metrics, Prometheus endpoints, or
OpenTelemetry exporters. It does not provide separate endpoint exporters.
Requirements
- Go 1.25 or newer; deploy with the latest available patch release.
- Echo v5 only.
- Zap.
The package is currently intended for a v0.x release line. Public API names
and log field names are maintained carefully, but this is not a v1
compatibility promise yet.
Install
go get github.com/janisto/echo-observability
Quick Start
package main
import (
"net/http"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
"github.com/janisto/echo-observability"
)
func main() {
logger, err := obs.NewLogger(obs.LoggerConfig{})
if err != nil {
panic(err)
}
e := echo.New()
e.Use(
obs.RequestContext(obs.RequestContextConfig{Logger: logger}),
obs.AccessLogger(obs.AccessLoggerConfig{Logger: logger}),
middleware.Recover(),
)
e.GET("/health", func(c *echo.Context) error {
obs.Logger(c.Request().Context()).Info("health checked")
return c.NoContent(http.StatusNoContent)
})
if err := e.Start(":8080"); err != nil {
logger.Error("server stopped", zap.Error(err))
}
}
Install RequestContext before AccessLogger. Put recovery middleware after
AccessLogger when panics must produce an access log before being recovered.
Echo applies Use middleware after routing, so c.Path() contains the matched
route template.
Middleware
RequestContext
RequestContext validates the incoming request ID, generates a safe ID when
needed, parses W3C trace context, installs metadata on
c.Request().Context(), and adds the request ID response header.
e.Use(obs.RequestContext(obs.RequestContextConfig{
Logger: logger,
Preset: obs.PresetGCP,
}))
Defaults:
| Setting |
Default |
| Request ID header |
X-Request-Id |
| Response header |
Request ID header |
| Trace header |
traceparent |
| Trace state header |
tracestate |
| Request ID format |
32 lowercase hexadecimal characters |
Incoming request IDs are at most 128 bytes and may contain ASCII letters,
digits, -, ., _, and ~. Customize generation or validation with
NewRequestID and ValidateRequestID. Set DisableResponseHeader when the
request ID must not be returned.
Access metadata anywhere a standard context.Context is available:
ctx := c.Request().Context()
requestID := obs.RequestID(ctx)
correlationID := obs.CorrelationID(ctx)
trace := obs.Trace(ctx)
logger := obs.Logger(ctx)
Logger always returns a non-nil logger. It returns a no-op logger outside an
installed request context.
HTTPRequestContext
For services with both Echo and non-Echo routes, install
HTTPRequestContext at the outer net/http boundary:
mux := http.NewServeMux()
mux.Handle("/", e)
mux.HandleFunc("GET /ready", readyHandler)
handler := obs.HTTPRequestContext(obs.HTTPRequestContextConfig{
Logger: logger,
Preset: obs.PresetDefault,
})(mux)
HTTPRequestContext installs request IDs, trace correlation metadata,
response request ID headers, and an optional request-scoped logger for every
HTTP request. Echo RequestContext reuses that metadata, so an inbound request
keeps one request ID across both layers.
It does not emit access logs or wrap http.ResponseWriter. Echo routes should
use AccessLogger; access logging for non-Echo routes remains
application-owned. The same header, generation, validation, response-header,
logger, and preset options are available in HTTPRequestContextConfig.
Handler Logging
Use obs.Logger(ctx) anywhere a standard request context is available:
func loadRepository(ctx context.Context, owner, repo string) error {
obs.Logger(ctx).Info("loading repository",
zap.String("owner", owner),
zap.String("repo", repo),
)
return nil
}
Configure RequestContextConfig.Logger for Echo-only services, or
HTTPRequestContextConfig.Logger at the router boundary for mixed services.
Background jobs, scripts, direct service calls, and tests using
context.Background() must use an explicit process logger; obs.Logger(ctx)
is intentionally a no-op outside installed request metadata.
Access Logger
AccessLogger installs request metadata by itself when RequestContext is
missing, but explicit installation of both middlewares is preferred. It emits:
method
path — escaped request path; never includes the query string
path_template — Echo route pattern such as /users/:id
operation_id — explicit Echo route name, when configured
status
duration_ms
remote_ip
user_agent
error — returned Echo error, when present
The request-scoped fields are request_id, correlation_id, and, for valid
W3C trace context, trace_id, parent_id, trace_flags, and trace_sampled.
Echo's ResolveResponseStatus determines the logged status. A committed
response wins over a later error; otherwise an Echo HTTPStatusCoder status
wins, and a plain error maps to 500. The original error is returned unchanged
for Echo's centralized HTTP error handler.
Application errors that a custom HTTPErrorHandler maps to a non-500 status
must implement echo.HTTPStatusCoder so the access log and eventual response
use the same status. AccessLogger intentionally does not invoke the global
error handler itself because that would commit the response inside the logging
middleware.
Use ExtraFields for application-owned access-log fields. Package-owned and
provider-owned field names are ignored to prevent duplicate JSON keys.
e.Use(obs.AccessLogger(obs.AccessLoggerConfig{
Logger: logger,
ExtraFields: func(c *echo.Context) []zap.Field {
return []zap.Field{zap.String("tenant_id", tenantID(c))}
},
}))
StatusLevel can override the default mapping: 5xx is error, 4xx is warn, and
all other statuses are info. Now exists for deterministic testing.
Named Routes
Echo assigns an internal default route name. operation_id is emitted only
for an explicitly named route:
_, err := e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/users/:id",
Name: "get-user",
Handler: getUser,
})
The raw request /users/123 logs path=/users/123 and
path_template=/users/:id. Group metrics or logs by path_template, not
path, to avoid high-cardinality dimensions.
Trace Correlation
W3C traceparent is the only trace input. A valid trace ID becomes
correlation_id; otherwise correlation_id falls back to request_id.
Multiple tracestate header fields are combined in wire order, and the
combined value is retained only when it is at most 512 bytes.
Provider-specific headers such as X-Cloud-Trace-Context,
X-Amzn-Trace-Id, and Azure's legacy Request-Id are intentionally not
parsed. The package correlates logs; it does not create spans or provider trace
segments.
Cloud Presets
Use the same preset for NewLogger, RequestContext, and AccessLogger.
logger, err := obs.NewLogger(obs.LoggerConfig{Preset: obs.PresetGCP})
e.Use(
obs.RequestContext(obs.RequestContextConfig{
Logger: logger, Preset: obs.PresetGCP,
}),
obs.AccessLogger(obs.AccessLoggerConfig{
Logger: logger, Preset: obs.PresetGCP,
}),
)
Google Cloud
The GCP preset emits severity instead of level, a structured
httpRequest object on access lines, logging.googleapis.com/trace, and
logging.googleapis.com/trace_sampled. The trace field contains the raw W3C
trace ID, which is Google Cloud's preferred format. It deliberately does not
emit logging.googleapis.com/spanId from the incoming parent ID.
AWS
The AWS preset keeps flat timestamp, level, and message fields. A valid
W3C trace also emits xray_trace_id in 1-8hex-24hex form. It does not create
X-Ray segments or treat the incoming parent ID as a current X-Ray span.
Azure
The Azure preset keeps flat JSON and maps a valid W3C trace to
operation_Id and operation_ParentId. It does not initialize Application
Insights or create dependency/request telemetry.
An incoming W3C parent ID is not emitted as a current span ID. A current span
ID can only come from real tracing instrumentation.
Field Contract
Every JSON line created by NewLogger uses:
timestamp: UTC RFC3339 with nanosecond precision.
level, or severity for GCP.
logger: present for named Zap loggers.
message.
Request-scoped lines add:
request_id.
correlation_id.
trace_id, parent_id, trace_flags, and trace_sampled only for a valid
W3C trace.
- Provider-specific trace fields selected by the configured preset.
Access lines add:
method.
path: escaped concrete URL path without the query string.
path_template: parameterized Echo route path when matched.
operation_id: explicitly configured Echo route name.
status.
duration_ms.
remote_ip: c.RealIP(), honoring the application's Echo IPExtractor.
user_agent when present.
error when Echo middleware or the handler returns an error.
httpRequest for the GCP preset only.
ExtraFields applies only to the access line. Reserved package and provider
keys are ignored so custom fields cannot produce duplicate or forged owned
values.
Request IDs
The default generator reads 128 bits from crypto/rand and encodes them as 32
lowercase hexadecimal characters. If entropy acquisition fails, or a custom
generator returns invalid data twice, a process-local atomic fallback is used.
The default validator accepts 1–128 ASCII characters from the unreserved URI
set: letters, digits, -, ., _, and ~. Invalid client input is replaced,
never copied to response headers or logs. Applications may supply both
NewRequestID and ValidateRequestID when they need a different contract.
Middleware Placement
Install request context and access logging at the outer observability boundary
so downstream middleware failures are correlated and logged:
e.Use(
obs.RequestContext(obs.RequestContextConfig{
Logger: logger,
Preset: obs.PresetGCP,
}),
obs.AccessLogger(obs.AccessLoggerConfig{
Logger: logger,
Preset: obs.PresetGCP,
}),
middleware.CORS(),
middleware.BodyLimit(1<<20),
middleware.Recover(),
)
Read request metadata with obs.RequestID(c.Request().Context()) and log with
obs.Logger(c.Request().Context()). Echo's own e.Logger remains separate
from application request logging.
Configure e.IPExtractor for the real deployment topology before serving
requests. AccessLogger uses Echo's resolved client IP and therefore inherits
its trust model for proxy headers.
Keep the observability pair outside middleware such as BodyLimit, CORS, and
authentication when their rejected requests must also receive request IDs and
access logs.
Logger Configuration
NewLogger writes JSON application logs to stdout and Zap internal errors to
stderr by default. LoggerConfig supports Level, Writer, ErrorWriter,
AddCaller, and Development. Add stable application fields to the returned
base logger before passing it to middleware:
logger = logger.With(
zap.String("service", "example-api"),
zap.String("environment", "production"),
zap.String("version", version),
)
Do not log authorization headers, cookies, tokens, request bodies, or other
secrets and personal data.
Panic Behavior
AccessLogger recovers a panic only long enough to emit an access log, then
re-panics with the original value. An uncommitted response is logged as 500. If
the response was already committed, its wire status is preserved in the log.
Install the application's recovery
middleware inside it—later in the e.Use list—when the application must turn
panics into HTTP responses. The package never swallows a panic or owns the
response format.
Optional Local Wrapper
Projects that prefer application-specific helpers can wrap the context API
without introducing another logging backend. A complete tested example is in
examples/local-wrapper/applog.
func Info(ctx context.Context, msg string, fields ...zap.Field) {
obs.Logger(ctx).Info(msg, fields...)
}
Keep the wrapper local to the application. This package intentionally exposes
Zap directly rather than defining a second logger interface.
Validation
The repository validates the Go 1.25 support line using its latest patched
toolchain with:
go test ./...
go test -race ./...
go vet ./...
golangci-lint run ./...
The suite covers the real Echo adapter path, standard net/http composition,
request ID and trace boundaries, returned and committed response errors,
panic rethrow, concurrent logging, cloud field contracts, reserved fields,
and request-context immutability. ParseTraceparent also has a fuzz target.
References
License
MIT. See LICENSE.