250 lines
7.0 KiB
Go
250 lines
7.0 KiB
Go
package otelchi
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/felixge/httpsnoop"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/riandyrn/otelchi/version"
|
|
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/propagation"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
|
"go.opentelemetry.io/otel/semconv/v1.20.0/httpconv"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
const (
|
|
tracerName = "github.com/riandyrn/otelchi"
|
|
)
|
|
|
|
func newTracer(tp trace.TracerProvider) trace.Tracer {
|
|
return tp.Tracer(tracerName, trace.WithInstrumentationVersion(version.Version()))
|
|
}
|
|
|
|
// Middleware sets up a handler to start tracing the incoming
|
|
// requests. The serverName parameter should describe the name of the
|
|
// (virtual) server handling the request.
|
|
func Middleware(serverName string, opts ...Option) func(next http.Handler) http.Handler {
|
|
cfg := config{}
|
|
for _, opt := range opts {
|
|
opt.apply(&cfg)
|
|
}
|
|
|
|
var tracer oteltrace.Tracer
|
|
if cfg.tracerProvider != nil {
|
|
tracer = newTracer(cfg.tracerProvider)
|
|
}
|
|
|
|
if cfg.propagators == nil {
|
|
cfg.propagators = otel.GetTextMapPropagator()
|
|
}
|
|
|
|
return func(handler http.Handler) http.Handler {
|
|
return traceware{
|
|
config: cfg,
|
|
serverName: serverName,
|
|
tracer: tracer,
|
|
handler: handler,
|
|
}
|
|
}
|
|
}
|
|
|
|
type traceware struct {
|
|
config
|
|
serverName string
|
|
tracer oteltrace.Tracer
|
|
handler http.Handler
|
|
}
|
|
|
|
type recordingResponseWriter struct {
|
|
writer http.ResponseWriter
|
|
written bool
|
|
status int
|
|
}
|
|
|
|
var rrwPool = &sync.Pool{
|
|
New: func() interface{} {
|
|
return &recordingResponseWriter{}
|
|
},
|
|
}
|
|
|
|
func getRRW(writer http.ResponseWriter) *recordingResponseWriter {
|
|
rrw := rrwPool.Get().(*recordingResponseWriter)
|
|
rrw.written = false
|
|
rrw.status = http.StatusOK
|
|
rrw.writer = httpsnoop.Wrap(writer, httpsnoop.Hooks{
|
|
Write: func(next httpsnoop.WriteFunc) httpsnoop.WriteFunc {
|
|
return func(b []byte) (int, error) {
|
|
if !rrw.written {
|
|
rrw.written = true
|
|
}
|
|
return next(b)
|
|
}
|
|
},
|
|
WriteHeader: func(next httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc {
|
|
return func(statusCode int) {
|
|
if !rrw.written {
|
|
rrw.written = true
|
|
rrw.status = statusCode
|
|
// only call next WriteHeader when header is not written yet
|
|
// this is to prevent superfluous WriteHeader call
|
|
next(statusCode)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
return rrw
|
|
}
|
|
|
|
func putRRW(rrw *recordingResponseWriter) {
|
|
rrw.writer = nil
|
|
rrwPool.Put(rrw)
|
|
}
|
|
|
|
// ServeHTTP implements the http.Handler interface. It does the actual
|
|
// tracing of the request.
|
|
func (tw traceware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// go through all filters if any
|
|
for _, filter := range tw.filters {
|
|
// if there is a filter that returns false, we skip tracing
|
|
// and execute next handler
|
|
if !filter(r) {
|
|
tw.handler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// extract tracing header using propagator
|
|
ctx := tw.propagators.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
|
|
// create span, based on specification, we need to set already known attributes
|
|
// when creating the span, the only thing missing here is HTTP route pattern since
|
|
// in go-chi/chi route pattern could only be extracted once the request is executed
|
|
// check here for details:
|
|
//
|
|
// https://github.com/go-chi/chi/issues/150#issuecomment-278850733
|
|
//
|
|
// if we have access to chi routes, we could extract the route pattern beforehand.
|
|
spanName := ""
|
|
routePattern := ""
|
|
spanAttributes := httpconv.ServerRequest(tw.serverName, r)
|
|
|
|
if tw.chiRoutes != nil {
|
|
rctx := chi.NewRouteContext()
|
|
if tw.chiRoutes.Match(rctx, r.Method, r.URL.Path) {
|
|
routePattern = rctx.RoutePattern()
|
|
spanName = addPrefixToSpanName(tw.requestMethodInSpanName, r.Method, routePattern)
|
|
spanAttributes = append(spanAttributes, semconv.HTTPRoute(routePattern))
|
|
}
|
|
}
|
|
|
|
// define span start options
|
|
spanOpts := []oteltrace.SpanStartOption{
|
|
oteltrace.WithAttributes(spanAttributes...),
|
|
oteltrace.WithSpanKind(oteltrace.SpanKindServer),
|
|
}
|
|
|
|
if tw.publicEndpointFn != nil && tw.publicEndpointFn(r) {
|
|
// mark span as the root span
|
|
spanOpts = append(spanOpts, oteltrace.WithNewRoot())
|
|
|
|
// linking incoming span context to the root span, we need to
|
|
// ensure if the incoming span context is valid (because it is
|
|
// possible for us to receive invalid span context due to various
|
|
// reason such as bug or context propagation error) and it is
|
|
// coming from another service (remote) before linking it to the
|
|
// root span
|
|
spanCtx := oteltrace.SpanContextFromContext(ctx)
|
|
if spanCtx.IsValid() && spanCtx.IsRemote() {
|
|
spanOpts = append(
|
|
spanOpts,
|
|
oteltrace.WithLinks(oteltrace.Link{
|
|
SpanContext: spanCtx,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
tracer := tw.tracer
|
|
if tracer == nil {
|
|
if span := trace.SpanFromContext(r.Context()); span.SpanContext().IsValid() {
|
|
tracer = newTracer(span.TracerProvider())
|
|
} else {
|
|
tracer = newTracer(otel.GetTracerProvider())
|
|
}
|
|
}
|
|
|
|
// start span
|
|
ctx, span := tracer.Start(ctx, spanName, spanOpts...)
|
|
defer span.End()
|
|
|
|
// put trace_id to response header only when `WithTraceIDResponseHeader` is used
|
|
if len(tw.traceIDResponseHeaderKey) > 0 && span.SpanContext().HasTraceID() {
|
|
w.Header().Add(tw.traceIDResponseHeaderKey, span.SpanContext().TraceID().String())
|
|
w.Header().Add(tw.traceSampledResponseHeaderKey, strconv.FormatBool(span.SpanContext().IsSampled()))
|
|
}
|
|
|
|
// get recording response writer
|
|
rrw := getRRW(w)
|
|
defer putRRW(rrw)
|
|
|
|
// execute next http handler
|
|
r = r.WithContext(ctx)
|
|
tw.handler.ServeHTTP(rrw.writer, r)
|
|
|
|
// set span name & http route attribute if route pattern cannot be determined
|
|
// during span creation
|
|
if len(routePattern) == 0 {
|
|
routePattern = chi.RouteContext(r.Context()).RoutePattern()
|
|
span.SetAttributes(semconv.HTTPRoute(routePattern))
|
|
|
|
spanName = addPrefixToSpanName(tw.requestMethodInSpanName, r.Method, routePattern)
|
|
span.SetName(spanName)
|
|
}
|
|
|
|
// check if the request is a WebSocket upgrade request
|
|
if isWebSocketRequest(r) {
|
|
span.SetStatus(codes.Unset, "WebSocket upgrade request")
|
|
return
|
|
}
|
|
|
|
// set status code attribute
|
|
span.SetAttributes(semconv.HTTPStatusCode(rrw.status))
|
|
|
|
// set span status
|
|
span.SetStatus(httpconv.ServerStatus(rrw.status))
|
|
}
|
|
|
|
func addPrefixToSpanName(shouldAdd bool, prefix, spanName string) string {
|
|
// in chi v5.0.8, the root route will be returned has an empty string
|
|
// (see https://github.com/go-chi/chi/blob/v5.0.8/context.go#L126)
|
|
if spanName == "" {
|
|
spanName = "/"
|
|
}
|
|
|
|
if shouldAdd && len(spanName) > 0 {
|
|
spanName = prefix + " " + spanName
|
|
}
|
|
return spanName
|
|
}
|
|
|
|
// isWebSocketRequest checks if an HTTP request is a WebSocket upgrade request
|
|
// Fix: https://github.com/riandyrn/otelchi/issues/66
|
|
func isWebSocketRequest(r *http.Request) bool {
|
|
// Check if the Connection header contains "Upgrade"
|
|
connectionHeader := r.Header.Get("Connection")
|
|
if !strings.Contains(strings.ToLower(connectionHeader), "upgrade") {
|
|
return false
|
|
}
|
|
|
|
// Check if the Upgrade header is "websocket"
|
|
upgradeHeader := r.Header.Get("Upgrade")
|
|
return strings.ToLower(upgradeHeader) == "websocket"
|
|
}
|