internal/httplog/roundtripper.go (169 lines of code) (raw):
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Package httplog provides http request and response transaction logging.
package httplog
import (
"bytes"
"context"
"encoding/base32"
"encoding/binary"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"os"
"strconv"
"sync/atomic"
"time"
)
var _ http.RoundTripper = (*LoggingRoundTripper)(nil)
// TraceIDKey is key used to add a trace.id value to the context of HTTP
// requests. The value will be logged by LoggingRoundTripper.
const TraceIDKey = contextKey("trace.id")
type contextKey string
// NewLoggingRoundTripper returns a LoggingRoundTripper that logs requests and
// responses to stderr. Transaction creation is logged to log.
func NewLoggingRoundTripper(next http.RoundTripper, maxBodyLen int) *LoggingRoundTripper {
return &LoggingRoundTripper{
transport: next,
maxBodyLen: maxBodyLen,
txLog: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})),
txBaseID: newID(),
}
}
// LoggingRoundTripper is an http.RoundTripper that logs requests and responses.
type LoggingRoundTripper struct {
transport http.RoundTripper
maxBodyLen int // The maximum length of a body. Longer bodies will be truncated.
txLog *slog.Logger // Destination logger.
txBaseID string // Random value to make transaction IDs unique.
txIDCounter atomic.Uint64 // Transaction ID counter that is incremented for each request.
}
// RoundTrip implements the http.RoundTripper interface, logging
// the request and response to the underlying logger.
//
// Fields logged in requests:
//
// url.original
// url.scheme
// url.path
// url.domain
// url.port
// url.query
// http.request
// user_agent.original
// http.request.body.content
// http.request.body.truncated
// http.request.body.bytes
// http.request.mime_type
// event.original (the request without body from httputil.DumpRequestOut)
//
// Fields logged in responses:
//
// http.response.status_code
// http.response.body.content
// http.response.body.truncated
// http.response.body.bytes
// http.response.mime_type
// event.original (the response without body from httputil.DumpResponse)
func (rt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Create a child logger for this request.
txID := rt.nextTxID()
log := rt.txLog.With(
slog.String("transaction.id", txID),
)
if v := req.Context().Value(TraceIDKey); v != nil {
if traceID, ok := v.(string); ok {
log = log.With(slog.String("trace.id", traceID))
}
}
req, respParts, errorsMessages := logRequest(log, req, rt.maxBodyLen)
resp, err := rt.transport.RoundTrip(req)
if err != nil {
return resp, err
}
if resp == nil {
return resp, err
}
respParts = append(respParts,
slog.Int("http.response.status_code", resp.StatusCode),
)
errorsMessages = errorsMessages[:0]
var body []byte
resp.Body, body, err = copyBody(resp.Body)
if err != nil {
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to read response body: %s", err))
}
respParts = append(respParts,
slog.Any("http.response.body.content", byteString(body[:min(len(body), rt.maxBodyLen)])),
slog.Bool("http.response.body.truncated", rt.maxBodyLen < len(body)),
slog.Int("http.response.body.bytes", len(body)),
slog.String("http.response.mime_type", resp.Header.Get("Content-Type")),
)
message, err := httputil.DumpResponse(resp, false)
if err != nil {
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to dump response: %s", err))
} else {
respParts = append(respParts, slog.Any("event.original", byteString(message)))
}
switch len(errorsMessages) {
case 0:
case 1:
respParts = append(respParts, slog.String("error.message", errorsMessages[0]))
default:
respParts = append(respParts, slog.Any("error.message", errorsMessages))
}
log.LogAttrs(context.Background(), slog.LevelInfo, "HTTP response", respParts...)
return resp, err
}
func logRequest(log *slog.Logger, req *http.Request, maxBodyLen int, extra ...slog.Attr) (_ *http.Request, parts []slog.Attr, errorsMessages []string) {
reqParts := append([]slog.Attr{
slog.String("url.original", req.URL.String()),
slog.String("url.scheme", req.URL.Scheme),
slog.String("url.path", req.URL.Path),
slog.String("url.domain", req.URL.Hostname()),
slog.String("url.port", req.URL.Port()),
slog.String("url.query", req.URL.RawQuery),
slog.String("http.request.method", req.Method),
slog.String("user_agent.original", req.Header.Get("User-Agent")),
}, extra...)
var (
body []byte
err error
)
req.Body, body, err = copyBody(req.Body)
if err != nil {
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to read request body: %s", err))
}
reqParts = append(reqParts,
slog.Any("http.request.body.content", byteString(body[:min(len(body), maxBodyLen)])),
slog.Bool("http.request.body.truncated", maxBodyLen < len(body)),
slog.Int("http.request.body.bytes", len(body)),
slog.String("http.request.mime_type", req.Header.Get("Content-Type")),
)
message, err := httputil.DumpRequestOut(req, false)
if err != nil {
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to dump request: %s", err))
} else {
reqParts = append(reqParts, slog.Any("event.original", byteString(message)))
}
switch len(errorsMessages) {
case 0:
case 1:
reqParts = append(reqParts, slog.String("error.message", errorsMessages[0]))
default:
reqParts = append(reqParts, slog.Any("error.message", errorsMessages))
}
log.LogAttrs(context.Background(), slog.LevelInfo, "HTTP request", reqParts...)
return req, reqParts[:0], errorsMessages
}
type byteString []byte
func (b byteString) LogValue() slog.Value {
if b == nil {
return slog.StringValue("<nil>")
}
return slog.StringValue(string(b))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// TxID returns the current transaction.id value. If rt is nil, the empty string is returned.
func (rt *LoggingRoundTripper) TxID() string {
if rt == nil {
return ""
}
count := rt.txIDCounter.Load()
return rt.formatTxID(count)
}
// nextTxID returns the next transaction.id value. It increments the internal
// request counter.
func (rt *LoggingRoundTripper) nextTxID() string {
count := rt.txIDCounter.Add(1)
return rt.formatTxID(count)
}
func (rt *LoggingRoundTripper) formatTxID(count uint64) string {
return rt.txBaseID + "-" + strconv.FormatUint(count, 10)
}
// newID returns an ID derived from the current time.
func newID() string {
var data [8]byte
binary.LittleEndian.PutUint64(data[:], uint64(time.Now().UnixNano()))
return base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(data[:])
}
// copyBody is derived from drainBody in net/http/httputil/dump.go
//
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//
// copyBody reads all of b to memory and then returns a
// ReadCloser yielding the same bytes, and the bytes themselves.
//
// It returns an error if the initial slurp of all bytes fails.
func copyBody(b io.ReadCloser) (r io.ReadCloser, body []byte, err error) {
if b == nil || b == http.NoBody {
// No copying needed. Preserve the magic sentinel meaning of NoBody.
return http.NoBody, nil, nil
}
var buf bytes.Buffer
if _, err = buf.ReadFrom(b); err != nil {
return nil, buf.Bytes(), err
}
if err = b.Close(); err != nil {
return nil, buf.Bytes(), err
}
return io.NopCloser(&buf), buf.Bytes(), nil
}