Logging and observability
This page explains where Wicket v0.1.4's logs come from, what the host can and cannot control, and how the host can record the outcomes of protocol requests itself in the absence of a built-in event system.
The short version:
| What you want to do | What actually happens in v0.1.4 |
|---|---|
Set the logger with server.WithLogger(l) | Works, but only for a few middleware layers and the startup checks |
Set the logger with cfg.SetLogger(l) | Has no effect: the call doesn't error, but the output doesn't change |
| Change the log level or format of protocol endpoints, validators, or storage adapters | Not possible: they always use server.DefaultLogger(), i.e. JSON, written to stdout, Debug level, with source locations |
| Subscribe to events such as "token issued" or "login failed" | No built-in event system; the host has to record these in middleware itself (see below) |
Two loggers, each with its own territory
All of Wicket's logs go through the standard library's *slog.Logger, but the process actually contains two unrelated loggers.
The container's logger: server.DefaultLogger()
When di.NewRoot() builds the container, it immediately creates a singleton *slog.Logger, and every component the container creates uses it: the endpoint router, the protocol endpoint handlers, request validators, token and discovery document generators, storage adapters, and warnings such as "store not injected, falling back to in-memory implementation".
The singleton is chosen like this: if a logger has already been set in ServerConfig, use it; otherwise use server.DefaultLogger(). The problem is that this choice happens inside di.NewRoot(), and you can only call cfg.SetLogger after you have root, by which time the singleton has long been built with the default and is never read again. So in v0.1.4:
cfg.SetLogger(...)has no effect on components inside the container;- Components inside the container always log through
server.DefaultLogger():
// The actual definition of server.DefaultLogger (v0.1.4)
slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}))In other words: the format is JSON, the destination is the process's stdout, the level is Debug (every request produces several Debug lines), and every line has a source field (function name, source file path, and line number).
The host can't override this logger
v0.1.4 has no public entry point to replace this logger, nor to adjust its level or format. Calling slog.SetDefault before or after di.NewRoot() doesn't affect it either, because it doesn't read the global default logger. In production, handle it on the log collection side; see "Production recommendations" at the end of this page.
The middleware logger: server.WithLogger
The logger passed through server.DefaultServer(root, server.WithLogger(l)) is given only to the layers that srv.UseWicket() assembles itself. The ones that actually log are:
- One Info line at startup (
Starting wicket authorization server ...), and errors when the startup check finds a required service missing; - The default authentication scheme middleware: Warn when resolving an authentication handler fails, when authentication fails, or when a request handler errors, with fields
schemeanderror; - The dynamic authentication scheme middleware (only when dynamic schemes are enabled): activation notices and degradation warnings.
Without WithLogger, this path also falls back to server.DefaultLogger().
Recommended: pass the same logger in both places. WithLogger works today; SetLogger has no effect in v0.1.4 but does no harm, and keeping it makes the intent of the wiring code clear.
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
root, err := di.NewRoot()
// ...
cfg := root.ServerConfig()
cfg.SetLogger(logger) // v0.1.4: doesn't change the output of components inside the container
// ... the rest of the wiring, cfg.Freeze()
srv, err := server.DefaultServer(root, server.WithLogger(logger)) // works: middleware and startup checksTested (with the quick start example): with a TextHandler writing to stderr passed to both SetLogger and WithLogger, only the startup line from the WithLogger path appears on stderr; all logs from endpoint processing are still written to stdout as JSON. That is why "I passed a TextHandler but the output is still JSON".
What the logs look like
Typical lines from components inside the container (source omitted):
{"time":"...","level":"DEBUG","msg":"Request path matched to endpoint type","path":"/connect/token","endpoint":"Token"}
{"time":"...","level":"ERROR","msg":"No validator is registered for the grant type","grantType":"urn:example:grant-type:sms"}
{"time":"...","level":"INFO","msg":"User authentication failed","error":"invalid_grant","errorDescription":"invalid_username_or_password"}Common fields:
| Field | Meaning |
|---|---|
time, level, msg, source | Standard slog fields; source comes from AddSource: true |
path, endpoint | The request path and the name of the matched endpoint (Token, Discovery, Authorize, etc.) |
clientId | The client identifier; appears in client validation, token requests, introspection, and so on |
subjectId, sessionId | The user identifier and session identifier |
error, err, errorDescription | A protocol error code or Go error text |
scopes, grantType, type, validatorType | The requested scopes, the grant type, and the type of the component handling the request |
A few things to know:
- Some
msgvalues contain unreplaced placeholders, for exampleclient configuration validation for client {clientId} succeeded.; the real value is in the field of the same name. When searching, match on fields rather than on the fullmsgtext. - The log levels lean toward "development debugging": caller errors such as a missing
client_idor a request body that isn't a form are also logged asERROR. Don't use the count of WicketERRORlines directly as a metric for service-failure alerts.
Credentials and sensitive values
Wicket itself has one explicit logging constraint, guarded by end-to-end tests: when storage adapters process data that carries credentials, they log only identifiers and error text. Specifically:
- When client configuration validation fails, the log contains
clientIdbut never the client secret; - The payload of grant records (persisted grants) never enters the log;
- The "store not injected, falling back to in-memory implementation" warning is written to the logger Wicket uses and names the store, rather than being emitted by a component through a logger of its own.
In addition, the configuration types of phoneverify and wechatverify implement slog.LogValuer, so even if the host hands the whole configuration object to a logger, secret fields such as AccessKeySecret are masked.
However, these constraints don't cover every log point. Some v0.1.4 logs (at both ERROR and DEBUG level) may contain sensitive request parameters and client configuration. Because the host currently can't adjust the level of the container's internal logger (see above), "raise the level above Warn" isn't possible in v0.1.4, and wouldn't be enough to solve the problem anyway. Filter on the log collection side instead; see "Production recommendations" at the end of this page. This page will be updated once a fixed version is released.
Verified never to appear in the logs: the plaintext client_secret a client submits in a token request, the user password in a password grant, and issued access tokens.
Principles for the host's own logging
Log only identifiers (client_id, sub, endpoint path, status code). Don't log: access tokens, refresh tokens, ID tokens, authorization codes, code_verifier, device codes and user codes, CIBA auth_req_id, client secrets, signing private keys, or raw request bodies. When you need to correlate the same token, log its jti or a hash, not the token itself.
Recording protocol outcomes without a built-in event system
Wicket v0.1.4 has no event or audit event service: the event configuration placeholders in the options are commented out in the code, and there is no event interface to subscribe to. If you need an audit trail of "who got what result from which endpoint and when", a workable approach is to wrap the outermost layer of the host middleware chain and record the method, path, status code, and duration of each protocol request.
The following middleware has been compiled and run on top of the quick start example:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// accessLog records the outcome of requests to wicket protocol endpoints (doesn't read the request body, logs no parameter values).
func accessLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/connect/") && !strings.HasPrefix(r.URL.Path, "/.well-known/") {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
attrs := []any{
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
}
// Take only client_id from the Basic auth header; never log the password part
if id, _, ok := r.BasicAuth(); ok {
attrs = append(attrs, slog.String("client_id", id))
}
level := slog.LevelInfo
if rec.status >= 400 {
level = slog.LevelWarn
}
logger.Log(r.Context(), level, "wicket protocol request", attrs...)
})
}
// Wiring: place it outside the wicket middleware
handler := accessLog(logger, hosting.ScopeMiddleware(root)(srv.UseWicket()(app)))Actual output (TextHandler):
level=INFO msg="wicket protocol request" method=POST path=/connect/token status=200 duration=1.57ms client_id=billing-worker
level=INFO msg="wicket protocol request" method=POST path=/connect/introspect status=200 duration=253µs client_id=orders
level=INFO msg="wicket protocol request" method=GET path=/connect/authorize status=302 duration=542µsLimits of this approach:
- What you get is the HTTP-level outcome. Token endpoint failures are all 4xx, and the specific OAuth error code (
invalid_grant,invalid_client, etc.) is in the response body. To tell them apart, you'd need to additionally buffer the token endpoint's response body in the wrappingResponseWriterand parse only theerrorfield. Don't write the whole response body to the log (a successful response contains the tokens). - If a client puts
client_idin the form instead of the Basic auth header, the middleware can't see it unless you parse the form yourself — which consumes the request body and requires restoring it, so it isn't recommended in a logging middleware. - User-level auditing (who logged in, and by which method) is better recorded in the host's own login page logic, which is where the host knows the user's identity.
Production recommendations
- Filter stdout by level on the collection side. Components inside the container always write JSON to stdout at Debug level, and every request produces several lines. Have the log collector drop
DEBUG(by thelevelfield) and keep onlyINFOand above; also note thatERRORincludes caller errors, so design alert thresholds separately. - Reserve stdout for Wicket. Write the host's own logs to stderr or a separate destination, so the collection side can route them separately and apply different retention policies.
- Treat Wicket's logs as sensitive data. Because some logs may contain sensitive request parameters, restrict access, shorten retention, and avoid sending raw logs to untrusted systems; also remove the
request,client,code, andcodeVerifierfields in the collection pipeline. - The
sourcefield exposes module paths on the build machine. If logs may flow somewhere untrusted, strip it on the collection side. - Always pass
server.WithLoggerfor the host middleware. Without it, this path also writes to stdout at Debug level. - Audit requirements are implemented by the host. Use the access log middleware above, plus user-level records in the host's login logic.