echo-observability provides request correlation, request-scoped Zap loggers,
and structured Zap access logging middleware for
Labstack Echo v5.
Managed platforms such as Cloud Run already collect container output.
Applications should only need to write structured JSON to standard output
(stdout); the platform can handle ingestion and delivery.
Compared with sending logs through an in-process cloud logging client, this reduces container CPU, memory, and network use by removing logging API calls, authentication, buffering, batching, and retry work from the application. Under sustained logging load, that reduction can provide a noticeable performance improvement. It also avoids the dependency and maintenance cost of a cloud logging SDK, including its configuration, credentials, and upgrades.
This package turns that simple pipeline into useful production observability. It provides validated request IDs, strict W3C trace correlation, request-scoped fields, and one structured terminal access record. Application and access logs share the same correlation metadata, making all records from a request easier to find, filter, and understand.
Cloud presets map the same logging contract to provider-oriented fields without coupling application code to a cloud logging SDK. The package focuses on structured logging and request correlation: it does not create spans, configure OpenTelemetry, or ship logs to a backend.
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.
Use this package when an Echo v5 service needs:
- Validated or generated request IDs with response propagation.
- Request-scoped
*zap.Loggervalues throughobs.Logger(ctx). - Strict W3C
traceparentparsing and trace-level log correlation. - One structured access log after each Echo request.
- Low-cardinality
path_templatevalues from Echo'sc.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 also does not create metrics, Prometheus endpoints, or separate endpoint exporters.
- Go 1.25 or newer; deploy with the latest available patch release.
- Echo v5.2.0 or newer within the Echo v5 line.
- Zap.
Starting with v1.0.0, exported APIs and documented structured log fields are compatibility contracts. Breaking changes are reserved for a future major version.
go get github.com/janisto/echo-observabilityWhen this documentation shows one configuration, it uses GCP. Complete
runnable GCP, provider-neutral, AWS, and Azure applications are available in
examples.
package main
import (
"net/http"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/janisto/echo-observability"
)
func main() {
logger, err := obs.NewLogger(obs.LoggerConfig{
Preset: obs.PresetGCP,
Level: zapcore.DebugLevel,
})
if err != nil {
panic(err)
}
e := echo.New()
e.Use(
obs.RequestContext(obs.RequestContextConfig{Logger: logger, Preset: obs.PresetGCP}),
obs.AccessLogger(obs.AccessLoggerConfig{Logger: logger, Preset: obs.PresetGCP}),
middleware.Recover(),
)
e.GET("/health", func(c *echo.Context) error {
logger := obs.Logger(c.Request().Context())
logger.Info("health check",
zap.String("service_name", "example-service"),
zap.String("health_status", "ok"),
)
logger.Debug("dependency check",
zap.String("dependency", "database"),
zap.String("dependency_status", "ok"),
zap.Int64("check_duration_ms", 3),
)
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
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. NewLogger defaults to info level; this example enables debug
to show that both application levels retain the same request correlation fields
as the terminal access record.
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.
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.PresetGCP,
})(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.
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.
AccessLogger installs request metadata by itself when RequestContext is
missing, but explicit installation of both middlewares is preferred. It emits:
methodpath— escaped request path; never includes the query stringpath_template— Echo route pattern such as/users/:idoperation_id— explicit Echo route name, when configuredstatusduration_msremote_ipuser_agenterror— 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.
ExtraFields is evaluated only when the selected access-log level is enabled,
so suppressed logs do not run application enrichment callbacks.
e.Use(obs.AccessLogger(obs.AccessLoggerConfig{
Logger: logger,
Preset: obs.PresetGCP,
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.
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.
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.
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,
}),
)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.
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.
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.
Every JSON line created by NewLogger uses:
timestamp: UTC RFC3339 with nanosecond precision.level, orseverityfor GCP.logger: present for named Zap loggers.message.
Request-scoped lines add:
request_id.correlation_id.trace_id,parent_id,trace_flags, andtrace_sampledonly 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 EchoIPExtractor.user_agentwhen present.errorwhen Echo middleware or the handler returns an error.httpRequestfor 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.
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.
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.
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.
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.
If access-log enrichment or writing also panics while the handler panic is
unwinding, the original handler panic remains the value propagated downstream.
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.
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.
Development uses just. On macOS, install the workflow linters:
brew install actionlint zizmorThen run the repository gates:
just install
just qa
just vulnjust qa validates the Go 1.25 support line with formatting, lint, build,
tests, race tests, actionlint, and
zizmor. just vuln runs the Go vulnerability scanner
separately.
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.
- Echo v5
- Echo middleware
- Echo error handling
- Go 1.25 release notes
- W3C Trace Context
- Zap
- Google Cloud structured logging
- Google Cloud trace and log integration
- Google Cloud Trace release notes
- AWS X-Ray trace header
- Azure Application Insights data model
MIT. See LICENSE.
Install Gremlins with Homebrew on macOS:
brew tap go-gremlins/tap
brew install gremlinsThen run its mutation campaign against covered production code with:
just mutationGremlins changes expressions and conditions, then checks whether the existing
tests detect each behavioral change. Review LIVED mutants as possible test
gaps; equivalent transformations do not need artificial assertions. Mutation
testing intentionally runs outside just qa and may take several minutes. The
configured per-mutant safety timeout does not limit the total campaign time.
This repository uses Go's native fuzzing engine for FuzzParseTraceparent.
Run the default ten-second session with:
just fuzzPass the target and duration explicitly for a longer run:
just fuzz FuzzParseTraceparent 1mThe equivalent native Go command is:
go test -fuzz=FuzzParseTraceparent -fuzztime=10s .Go first replays the seed corpus and then generates new inputs. When fuzzing
finds a failure, it minimizes the input and writes it under
testdata/fuzz/FuzzParseTraceparent; normal go test ./... runs saved corpus
inputs as regression tests. Review and commit a failing input together with the
fix when it represents behavior the parser must preserve.
See the Go fuzzing documentation for the engine's workflow and additional flags.