-
Notifications
You must be signed in to change notification settings - Fork 4.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
x-pack/filebeat/input/httpjson: add transaction tracer #32412
Changes from all commits
b292640
5c91223
77bae95
6728231
4b72b04
6cc0263
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
// or more contributor license agreements. Licensed under the Elastic License; | ||
// you may not use this file except in compliance with the Elastic License. | ||
|
||
// Package httplog provides http request and response transaction logging. | ||
package httplog | ||
|
||
import ( | ||
"bytes" | ||
"encoding/base32" | ||
"encoding/binary" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httputil" | ||
"strconv" | ||
"time" | ||
|
||
"go.uber.org/atomic" | ||
"go.uber.org/zap" | ||
"go.uber.org/zap/zapcore" | ||
) | ||
|
||
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 the provided logger. | ||
func NewLoggingRoundTripper(next http.RoundTripper, logger *zap.Logger) *LoggingRoundTripper { | ||
return &LoggingRoundTripper{ | ||
transport: next, | ||
logger: logger, | ||
txBaseID: newID(), | ||
txIDCounter: atomic.NewUint64(0), | ||
} | ||
} | ||
|
||
// LoggingRoundTripper is an http.RoundTripper that logs requests and responses. | ||
type LoggingRoundTripper struct { | ||
transport http.RoundTripper | ||
logger *zap.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.bytes | ||
// http.request.mime_type | ||
// event.original (the full request and body from httputil.DumpRequestOut) | ||
// | ||
// Fields logged in responses: | ||
// http.response.status_code | ||
// http.response.body.content | ||
// http.response.body.bytes | ||
// http.response.mime_type | ||
// event.original (the full response and body from httputil.DumpResponse) | ||
// | ||
func (rt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | ||
// Create a child logger for this request. | ||
log := rt.logger.With( | ||
zap.String("transaction.id", rt.nextTxID()), | ||
) | ||
|
||
if v := req.Context().Value(TraceIDKey); v != nil { | ||
if traceID, ok := v.(string); ok { | ||
log = log.With(zap.String("trace.id", traceID)) | ||
} | ||
} | ||
|
||
reqParts := []zapcore.Field{ | ||
zap.String("url.original", req.URL.String()), | ||
zap.String("url.scheme", req.URL.Scheme), | ||
zap.String("url.path", req.URL.Path), | ||
zap.String("url.domain", req.URL.Hostname()), | ||
zap.String("url.port", req.URL.Port()), | ||
zap.String("url.query", req.URL.RawQuery), | ||
zap.String("http.request.method", req.Method), | ||
zap.String("user_agent.original", req.Header.Get("User-Agent")), | ||
} | ||
var ( | ||
body []byte | ||
err error | ||
errorsMessages []string | ||
) | ||
req.Body, body, err = copyBody(req.Body) | ||
if err != nil { | ||
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to read request body: %s", err)) | ||
} else { | ||
reqParts = append(reqParts, | ||
zap.ByteString("http.request.body.content", body), | ||
zap.Int("http.request.body.bytes", len(body)), | ||
zap.String("http.request.mime_type", req.Header.Get("Content-Type")), | ||
) | ||
} | ||
message, err := httputil.DumpRequestOut(req, true) | ||
if err != nil { | ||
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to dump request: %s", err)) | ||
} else { | ||
reqParts = append(reqParts, zap.ByteString("event.original", message)) | ||
} | ||
switch len(errorsMessages) { | ||
case 0: | ||
case 1: | ||
reqParts = append(reqParts, zap.String("error.message", errorsMessages[0])) | ||
default: | ||
reqParts = append(reqParts, zap.Strings("error.message", errorsMessages)) | ||
} | ||
log.Debug("HTTP request", reqParts...) | ||
|
||
resp, err := rt.transport.RoundTrip(req) | ||
if err != nil { | ||
log.Debug("HTTP response error", zap.NamedError("error.message", err)) | ||
return resp, err | ||
} | ||
if resp == nil { | ||
log.Debug("HTTP response error", noResponse) | ||
return resp, err | ||
} | ||
respParts := append(reqParts[:0], | ||
zap.Int("http.response.status_code", resp.StatusCode), | ||
) | ||
errorsMessages = errorsMessages[:0] | ||
resp.Body, body, err = copyBody(resp.Body) | ||
if err != nil { | ||
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to read response body: %s", err)) | ||
} else { | ||
respParts = append(respParts, | ||
zap.ByteString("http.response.body.content", body), | ||
zap.Int("http.response.body.bytes", len(body)), | ||
zap.String("http.response.mime_type", resp.Header.Get("Content-Type")), | ||
) | ||
} | ||
message, err = httputil.DumpResponse(resp, true) | ||
if err != nil { | ||
errorsMessages = append(errorsMessages, fmt.Sprintf("failed to dump response: %s", err)) | ||
} else { | ||
respParts = append(respParts, zap.ByteString("event.original", message)) | ||
} | ||
switch len(errorsMessages) { | ||
case 0: | ||
case 1: | ||
respParts = append(reqParts, zap.String("error.message", errorsMessages[0])) | ||
default: | ||
respParts = append(reqParts, zap.Strings("error.message", errorsMessages)) | ||
} | ||
log.Debug("HTTP response", respParts...) | ||
|
||
return resp, err | ||
} | ||
|
||
// nextTxID returns the next transaction.id value. It increments the internal | ||
// request counter. | ||
func (rt *LoggingRoundTripper) nextTxID() string { | ||
count := rt.txIDCounter.Inc() | ||
return rt.txBaseID + "-" + strconv.FormatUint(count, 10) | ||
} | ||
|
||
var noResponse = zap.NamedError("error.message", errors.New("unexpected nil response")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you checked the output of this? I wanted to make sure that it behaves as expected because I recall that ecszap automatically takes regular There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will check. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The two types of error messages:
and
So the New version:
Double
Round trip
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "New version" looks good. |
||
|
||
// 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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have a logging library in Beats: https://github.com/elastic/elastic-agent-libs/tree/main/logp that uses go.uber.org/zap under the hood.
If possible I'd prefer to stick with those ones (preferably
elastic-agent-libs/logp
) to avoid introducing a new dependency and "logging" library.What are the advantages of using lumberjack over the ones we already have in Beats?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another alternative is https://pkg.go.dev/github.com/elastic/elastic-agent-libs/file#Rotator. It gives you an
io.WriteCloser
interface and manages rolling the underlying files.But it looks like the library got polluted since I last looked with a hardcoded file extension 😢 . https://github.com/elastic/elastic-agent-libs/blob/a09d65fd12dec3efe53c44f05562431f96b1028b/file/rotator.go#L416 IMO is should have used
filepath.Ext
to get the extension from the configuredfilename
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Simplicity; this does exactly what is needed and is transparently simple.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@andrewkroh's suggestion looks great and simple. The file extension is a pitty, but we can easily fix that as well. I glanced over the code and it seems to be possible to make the extension configurable without changing the public API.