mirror of
https://github.com/hanzoai/krakend-otel.git
synced 2026-11-02 14:23:07 +00:00
268 lines
12 KiB
Go
268 lines
12 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/metric"
|
|
"go.opentelemetry.io/otel/metric/noop"
|
|
"go.opentelemetry.io/otel/semconv/v1.21.0"
|
|
v127 "go.opentelemetry.io/otel/semconv/v1.27.0"
|
|
|
|
kotelconfig "github.com/krakend/krakend-otel/config"
|
|
)
|
|
|
|
// TransportMetricsOptions contains the options to enable / disable
|
|
// for reporting metrics, and a set of fixed attributes to add
|
|
// to all metrics.
|
|
type TransportMetricsOptions struct {
|
|
RoundTrip bool // provide the round trip metrics
|
|
ReadPayload bool // provide metrics for the reading the full body
|
|
DetailedConnection bool // provide detailed metrics about the connection: dns lookup, tls ...
|
|
FixedAttributes []attribute.KeyValue // "static" attributes set at config time.
|
|
SemConv string // to use the latest metric names conventions
|
|
}
|
|
|
|
// Enabled tells if metrics should be reported for the transport.
|
|
func (o *TransportMetricsOptions) Enabled() bool {
|
|
return o.RoundTrip || o.ReadPayload
|
|
}
|
|
|
|
// transportMetrics holds the metric instruments for the round trip
|
|
type transportMetrics struct {
|
|
// total of initiated requests (sucessful, failed and cancelled)
|
|
requestsStarted metric.Int64Counter
|
|
requestsFailed metric.Int64Counter
|
|
requestsCanceled metric.Int64Counter
|
|
requestsTimedOut metric.Int64Counter
|
|
|
|
// the value of the Content-Length header for the request (not the
|
|
// actual written bytes of the request, that might be cancelled
|
|
// when it is already on flight.
|
|
requestContentLength metric.Int64Counter
|
|
requestContentLengthHist metric.Int64Histogram
|
|
|
|
responseLatency metric.Float64Histogram
|
|
|
|
// the response content lenght comes from the server provided header
|
|
// and might differ from the actual number of bytes read from the body
|
|
responseContentLength metric.Int64Histogram
|
|
responseNoContentLength metric.Int64Counter
|
|
|
|
// from the httptrace details
|
|
detailsEnabled bool
|
|
getConnLatency metric.Float64Histogram
|
|
dnsLatency metric.Float64Histogram
|
|
tlsLatency metric.Float64Histogram
|
|
|
|
// to identify the source of the request (in KrakenD the front facing endpoint)
|
|
clientName string
|
|
}
|
|
|
|
type metricFillerFn func(*TransportMetricsOptions, metric.Meter, *transportMetrics)
|
|
|
|
func noSemConvMetricsFiller(metricsOpts *TransportMetricsOptions, meter metric.Meter, tm *transportMetrics) {
|
|
nopMeter := noop.Meter{}
|
|
|
|
tm.requestsStarted, _ = meter.Int64Counter("http.client.request.started.count") // number of reqs started
|
|
tm.requestsFailed, _ = meter.Int64Counter("http.client.request.failed.count") // number of reqs failed
|
|
tm.requestsCanceled, _ = meter.Int64Counter("http.client.request.canceled.count") // number of canceled requests
|
|
tm.requestsTimedOut, _ = meter.Int64Counter("http.client.request.timedout.count") // numer of timedout request (inclued in failed)
|
|
|
|
tm.requestContentLength, _ = meter.Int64Counter("http.client.request.size") // the value of the Content-Length header for the request
|
|
// For non implemented metrics that can be used with other config, we use a noop meter:
|
|
tm.requestContentLengthHist, _ = nopMeter.Int64Histogram(v127.HTTPClientRequestBodySizeName)
|
|
|
|
tm.responseLatency, _ = meter.Float64Histogram("http.client.duration", kotelconfig.TimeBucketsOpt)
|
|
tm.responseContentLength, _ = meter.Int64Histogram("http.client.response.size", kotelconfig.SizeBucketsOpt)
|
|
tm.responseNoContentLength, _ = meter.Int64Counter("http.client.response.no-content-length")
|
|
|
|
tm.detailsEnabled = metricsOpts.DetailedConnection
|
|
tm.getConnLatency, _ = meter.Float64Histogram("http.client.request.get-conn.duration", kotelconfig.TimeBucketsOpt)
|
|
tm.dnsLatency, _ = meter.Float64Histogram("http.client.request.dns.duration", kotelconfig.TimeBucketsOpt)
|
|
tm.tlsLatency, _ = meter.Float64Histogram("http.client.request.tls.duration", kotelconfig.TimeBucketsOpt)
|
|
}
|
|
|
|
func semConv1_27MetricsFiller(metricsOpts *TransportMetricsOptions, meter metric.Meter, tm *transportMetrics) {
|
|
nopMeter := noop.Meter{}
|
|
|
|
tm.detailsEnabled = metricsOpts.DetailedConnection
|
|
|
|
// For non implemented metrics that can be used with other config, we use a noop meter:
|
|
tm.requestContentLength, _ = nopMeter.Int64Counter("http.client.request.size")
|
|
|
|
// WARNING: Stability => Experimental (subject to change in the future)
|
|
tm.requestContentLengthHist, _ = meter.Int64Histogram(v127.HTTPClientRequestBodySizeName,
|
|
metric.WithUnit(v127.HTTPClientRequestBodySizeUnit),
|
|
metric.WithDescription(v127.HTTPClientRequestBodySizeDescription),
|
|
kotelconfig.SizeBucketsOpt) // the value of the Content-Length header for the request
|
|
|
|
// this is `http.client.request.duration`, and is a required metric:
|
|
// https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration
|
|
tm.responseLatency, _ = meter.Float64Histogram(v127.HTTPClientRequestDurationName,
|
|
metric.WithUnit(v127.HTTPClientRequestDurationUnit),
|
|
metric.WithDescription(v127.HTTPClientRequestDurationDescription),
|
|
kotelconfig.TimeBucketsOpt)
|
|
|
|
// WARNING: Stability => Experimental (subject to change in the future)
|
|
tm.responseContentLength, _ = meter.Int64Histogram(v127.HTTPClientResponseBodySizeName,
|
|
metric.WithUnit(v127.HTTPClientResponseBodySizeUnit),
|
|
metric.WithDescription(v127.HTTPClientResponseBodySizeDescription),
|
|
kotelconfig.SizeBucketsOpt) // the value of the Content-Length header for the response
|
|
|
|
// TODO: if we want the exact semantic convention, what should we do with our own extra data, that
|
|
// is not standarized by OTEL ? for now we only set it if the `metricsOpts.DetailedConnection` is
|
|
// set too:
|
|
if metricsOpts.DetailedConnection {
|
|
tm.requestsStarted, _ = meter.Int64Counter("http.client.request.started.count") // number of reqs started
|
|
tm.requestsFailed, _ = meter.Int64Counter("http.client.request.failed.count") // number of reqs failed
|
|
tm.requestsCanceled, _ = meter.Int64Counter("http.client.request.canceled.count") // number of canceled requests
|
|
tm.requestsTimedOut, _ = meter.Int64Counter("http.client.request.timedout.count") // numer of timedout request (inclued in failed)
|
|
|
|
tm.responseNoContentLength, _ = meter.Int64Counter("http.client.response.no-content-length",
|
|
metric.WithUnit(v127.HTTPClientResponseBodySizeUnit),
|
|
metric.WithDescription("Client received responses that do not have 'Content-Length' value set"))
|
|
tm.getConnLatency, _ = meter.Float64Histogram("http.client.request.get-conn.duration",
|
|
metric.WithUnit(v127.HTTPClientRequestDurationUnit),
|
|
metric.WithDescription("Time spent acquiring a client connection"),
|
|
kotelconfig.TimeBucketsOpt)
|
|
tm.dnsLatency, _ = meter.Float64Histogram("http.client.request.dns.duration",
|
|
metric.WithUnit(v127.HTTPClientRequestDurationUnit),
|
|
metric.WithDescription("Time spent resolving the DNS name"),
|
|
kotelconfig.TimeBucketsOpt)
|
|
tm.tlsLatency, _ = meter.Float64Histogram("http.client.request.tls.duration",
|
|
metric.WithUnit(v127.HTTPClientRequestDurationUnit),
|
|
metric.WithDescription("Time spent on TLS negotiation and connection"),
|
|
kotelconfig.TimeBucketsOpt)
|
|
return
|
|
}
|
|
tm.requestsStarted, _ = nopMeter.Int64Counter("http.client.request.started.count") // number of reqs started
|
|
tm.requestsFailed, _ = nopMeter.Int64Counter("http.client.request.failed.count") // number of reqs failed
|
|
tm.requestsCanceled, _ = nopMeter.Int64Counter("http.client.request.canceled.count") // number of canceled requests
|
|
tm.requestsTimedOut, _ = nopMeter.Int64Counter("http.client.request.timedout.count") // numer of timedout request (inclued in failed)
|
|
|
|
tm.responseNoContentLength, _ = nopMeter.Int64Counter("http.client.response.no-content-length")
|
|
tm.getConnLatency, _ = nopMeter.Float64Histogram("http.client.request.get-conn.duration")
|
|
tm.dnsLatency, _ = nopMeter.Float64Histogram("http.client.request.dns.duration")
|
|
tm.tlsLatency, _ = nopMeter.Float64Histogram("http.client.request.tls.duration")
|
|
}
|
|
|
|
func newTransportMetrics(metricsOpts *TransportMetricsOptions, meter metric.Meter, clientName string) *transportMetrics {
|
|
if meter == nil {
|
|
return nil
|
|
}
|
|
|
|
supportedSemConv := map[string]metricFillerFn{
|
|
"": noSemConvMetricsFiller,
|
|
"1.27": semConv1_27MetricsFiller,
|
|
}
|
|
tm := transportMetrics{
|
|
clientName: clientName,
|
|
}
|
|
filler := noSemConvMetricsFiller
|
|
if versionFiller, ok := supportedSemConv[metricsOpts.SemConv]; ok {
|
|
filler = versionFiller
|
|
}
|
|
filler(metricsOpts, meter, &tm)
|
|
return &tm
|
|
}
|
|
|
|
func (m *transportMetrics) report(rtt *roundTripTracking, attrs []attribute.KeyValue) {
|
|
if m == nil || m.requestsStarted == nil {
|
|
return
|
|
}
|
|
attrOpt := m.attributesOption(rtt, attrs)
|
|
ctx := rtt.req.Context()
|
|
|
|
m.requestsStarted.Add(ctx, 1, attrOpt)
|
|
if rtt.req.ContentLength >= 0 {
|
|
m.requestContentLength.Add(ctx, rtt.req.ContentLength, attrOpt)
|
|
// the content-length is optional and experimental for 1.27 and also 1.29 sem conv:
|
|
// https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestbodysize
|
|
m.requestContentLengthHist.Record(ctx, rtt.req.ContentLength, attrOpt)
|
|
}
|
|
|
|
if rtt.err != nil {
|
|
reqCtx := rtt.req.Context()
|
|
var ctxErr error
|
|
if reqCtx != nil {
|
|
ctxErr = rtt.req.Context().Err()
|
|
}
|
|
if errors.Is(ctxErr, context.Canceled) {
|
|
// ATTENTION: a Cancelled requests is not considered failed
|
|
m.requestsCanceled.Add(ctx, 1, attrOpt)
|
|
} else if errors.Is(ctxErr, context.DeadlineExceeded) {
|
|
m.requestsTimedOut.Add(ctx, 1, attrOpt)
|
|
m.requestsFailed.Add(ctx, 1, attrOpt)
|
|
} else {
|
|
m.requestsFailed.Add(ctx, 1, attrOpt)
|
|
}
|
|
}
|
|
|
|
// the `http.client.request.duration` is required for semconv 1.27 and 1.29
|
|
m.responseLatency.Record(ctx, rtt.latencyInSecs, attrOpt)
|
|
|
|
if rtt.req.Method != "HEAD" && rtt.resp != nil {
|
|
if rtt.resp.ContentLength >= 0 {
|
|
// it might be the case were we receive a chunked response, and then
|
|
// we will not record a metric for it.
|
|
// the `http.client.response.body.size` is optional and experimental
|
|
// for semconv 1.27 and 1.29
|
|
m.responseContentLength.Record(ctx, rtt.resp.ContentLength, attrOpt)
|
|
} else {
|
|
m.responseNoContentLength.Add(ctx, 1, attrOpt)
|
|
}
|
|
}
|
|
|
|
if m.detailsEnabled {
|
|
m.getConnLatency.Record(ctx, rtt.getConnLatency, attrOpt)
|
|
m.dnsLatency.Record(ctx, rtt.dnsLatency, attrOpt)
|
|
m.tlsLatency.Record(ctx, rtt.tlsLatency, attrOpt)
|
|
}
|
|
}
|
|
|
|
func (m *transportMetrics) attributesOption(rtt *roundTripTracking, attrs []attribute.KeyValue) metric.MeasurementOption {
|
|
numAttrs := len(attrs) + 4 + 1 // static attrs + required attributes + clientname
|
|
attrM := make([]attribute.KeyValue, len(attrs), numAttrs)
|
|
copy(attrM, attrs)
|
|
if len(m.clientName) > 0 {
|
|
attrM = append(attrM, attribute.Key("clientname").String(m.clientName))
|
|
}
|
|
|
|
serverAddress, serverPort := requestServerAndPort(rtt.req)
|
|
|
|
statusCode := 0
|
|
if rtt.err == nil {
|
|
// if we fail on the client side, we do not have a status code, but we
|
|
// want it set to 0 to be displayed on the dashboard
|
|
statusCode = int(rtt.resp.StatusCode)
|
|
}
|
|
|
|
attrM = append(attrM,
|
|
semconv.HTTPRequestMethodKey.String(rtt.req.Method), // required
|
|
// this is wrong, as the RemoteAddr is ignored when the request is used
|
|
// for a client (is filled by the server side):
|
|
// semconv.ServerAddress(rtt.req.RemoteAddr),
|
|
semconv.ServerAddress(serverAddress), // required by sem conv 1.29
|
|
semconv.ServerPort(serverPort), // required by sem conv 1.29
|
|
semconv.HTTPResponseStatusCode(statusCode), // required if received
|
|
)
|
|
return metric.WithAttributeSet(attribute.NewSet(attrM...))
|
|
}
|
|
|
|
func requestServerAndPort(r *http.Request) (string, int) {
|
|
serverAddress := r.Host
|
|
serverPort := int(80)
|
|
if r.URL != nil {
|
|
serverAddress = r.URL.Hostname()
|
|
strPort := r.URL.Port()
|
|
if p, err := strconv.ParseInt(strPort, 10, 32); err != nil {
|
|
serverPort = int(p)
|
|
}
|
|
}
|
|
return serverAddress, serverPort
|
|
}
|