mirror of
https://github.com/coder/coder.git
synced 2026-10-31 11:02:50 +08:00
## What Adds `client_session_id` correlation to coderd's HTTP request handling, per the [Connection log collection and correlation RFC](https://www.notion.so/coderhq/Connection-log-collection-and-correlation-36ed579be5928025a56cd11fe58661fb). Clients attach a per-session correlation ID to every API request via W3C baggage using the `client_session_id` key. This change makes coderd's tracing middleware read that baggage member and: - add `client_session_id` to the **per-request log context** so all logs for a request (and the handlers it calls) can be correlated by a single ID, and - set `client_session_id` as a **span attribute** when tracing is enabled. Per RFC requirement 6.1, the value is added to the log context **even when tracing is disabled** (the middleware previously returned early when no tracer provider was configured, so baggage was never read). The `client_session_id` is validated as a 32-character hexadecimal string (a 16-byte value, per RFC requirement 1) to guard against logging arbitrary client-controlled baggage values. ## Scope This is `DEVEX-659` and is intentionally limited to the coderd tracing middleware. It is the first piece of a stack: the web terminal client change (`DEVEX-663`) that generates and sends the `client_session_id` will be stacked on top of this PR. No client currently sends `client_session_id` baggage, so this change is a no-op until the client work lands. ## Testing - `coderd/tracing`: new unit tests cover `validSessionID`, baggage extraction (`sessionIDFromHeaders`), and the middleware end to end, asserting `client_session_id` lands on the log context with tracing enabled **and** disabled, is exposed as a span attribute when tracing is enabled, and that absent/malformed baggage is ignored. - Existing `Test_Middleware` route-matching behavior is unchanged. <details> <summary>Design notes / decision log</summary> - **Where the value is read:** the existing `tracing.Middleware` runs high in the coderd middleware stack (`coderd/coderd.go`), before request-id and request-logger middleware, and already matches the `/api`, `/api/**`, app proxy, and external-auth routes. Reading baggage here means the `client_session_id` is on the context before the request logger and handlers run, so it flows into all downstream `slog` calls that use the request context. This mirrors the existing `request_id` pattern in `httpmw.AttachRequestID` (`slog.With(ctx, ...)` + span attribute). - **Works when tracing is off:** the middleware now gates only on the route matcher, extracts baggage and adds `client_session_id` to the log context for all matched routes, and only then branches on whether a tracer is configured. When a tracer is present, `client_session_id` is additionally set as a span attribute. - **Explicit baggage propagator:** extraction uses `propagation.Baggage{}` directly rather than the global text map propagator, so it does not depend on the global propagator being configured (also makes it deterministic in tests). - **Validation:** only a 32-char hex string is accepted (lower or upper case). Malformed values are dropped rather than logged, preventing log/attribute pollution from arbitrary client-supplied baggage. - **Out of scope for this PR (tracked elsewhere):** client generation/sending of `client_session_id` (`DEVEX-663`, web terminal), the equivalent agent-side middleware (RFC 6.2), `connection_logs.client_session_id` (RFC 12), and additional connection state-change logging (RFC 7-13). </details> --- _Opened by Coder Agents on behalf of @aqandrew._
216 lines
7.7 KiB
Go
216 lines
7.7 KiB
Go
package tracing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/baggage"
|
|
"go.opentelemetry.io/otel/propagation"
|
|
semconv "go.opentelemetry.io/otel/semconv/v1.14.0"
|
|
"go.opentelemetry.io/otel/semconv/v1.14.0/httpconv"
|
|
"go.opentelemetry.io/otel/semconv/v1.14.0/netconv"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"go.opentelemetry.io/otel/trace/noop"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/httpmw/patternmatcher"
|
|
)
|
|
|
|
// SessionIDBaggageKey is the W3C baggage key clients use to propagate the
|
|
// per-session correlation ID described in the connection-log RFC. The value is
|
|
// a 16-byte identifier encoded as a 32-character hexadecimal string.
|
|
const SessionIDBaggageKey = "client_session_id"
|
|
|
|
// Middleware adds tracing to http routes.
|
|
func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Handler {
|
|
// We only want to create spans on the following route patterns, however
|
|
// we want the middleware to be very high in the middleware stack so it can
|
|
// capture the entire request.
|
|
re := patternmatcher.RoutePatterns{
|
|
"/api",
|
|
"/api/**",
|
|
"/@*/*/apps/**",
|
|
"/%40*/*/apps/**",
|
|
"/external-auth/*/callback",
|
|
}.MustCompile()
|
|
|
|
if tracerProvider == nil {
|
|
tracerProvider = noop.NewTracerProvider()
|
|
}
|
|
tracer := tracerProvider.Tracer(TracerName)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
if !re.MatchString(r.URL.Path) {
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
|
|
// Read the client_session_id from the request and add it to the log
|
|
// context. This is done even when tracing is disabled so that logs can
|
|
// always be correlated by client_session_id.
|
|
sessionID := sessionIDFromRequest(r)
|
|
if sessionID != "" {
|
|
r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID)))
|
|
}
|
|
|
|
// Start span with default span name. Span name will be updated to
|
|
// "method route" format once request finishes. The initial name
|
|
// excludes the query string because span names are exported to
|
|
// tracing backends at span start and some endpoints accept
|
|
// bearer credentials as query parameters.
|
|
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
|
|
defer span.End()
|
|
|
|
if sessionID != "" {
|
|
span.SetAttributes(attribute.String("client_session_id", sessionID))
|
|
}
|
|
|
|
sw, ok := rw.(*StatusWriter)
|
|
if !ok {
|
|
panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw))
|
|
}
|
|
|
|
// pass the span through the request context and serve the request to the next middleware
|
|
next.ServeHTTP(sw, r)
|
|
// capture response data
|
|
EndHTTPSpan(r, sw.Status, span)
|
|
})
|
|
}
|
|
}
|
|
|
|
// sessionIDFromRequest extracts and validates the client_session_id from the
|
|
// request. It prefers the W3C baggage member and falls back to the
|
|
// client_session_id query parameter. The query parameter fallback exists
|
|
// because browser WebSocket clients (such as the web terminal PTY) cannot set
|
|
// arbitrary baggage headers. It returns an empty string when neither source
|
|
// provides a valid value.
|
|
func sessionIDFromRequest(r *http.Request) string {
|
|
if id := sessionIDFromHeaders(r.Header); id != "" {
|
|
return id
|
|
}
|
|
return sessionIDFromQueryString(r.URL.Query())
|
|
}
|
|
|
|
// sessionIDFromHeaders extracts and validates the client_session_id baggage member
|
|
// from the request headers. It returns an empty string when the member is
|
|
// absent or malformed. Extraction uses an explicit baggage propagator so it
|
|
// does not depend on the globally configured text map propagator.
|
|
func sessionIDFromHeaders(h http.Header) string {
|
|
ctx := propagation.Baggage{}.Extract(context.Background(), propagation.HeaderCarrier(h))
|
|
id := baggage.FromContext(ctx).Member(SessionIDBaggageKey).Value()
|
|
if !validSessionID(id) {
|
|
return ""
|
|
}
|
|
return id
|
|
}
|
|
|
|
// sessionIDFromQueryString extracts and validates the client_session_id query
|
|
// parameter. It returns an empty string when the parameter is absent or
|
|
// malformed. It mirrors sessionIDFromHeaders for the query-parameter fallback
|
|
// used by browser WebSocket clients (such as the web terminal PTY) that cannot
|
|
// set arbitrary baggage headers.
|
|
func sessionIDFromQueryString(q url.Values) string {
|
|
id := q.Get(SessionIDBaggageKey)
|
|
if !validSessionID(id) {
|
|
return ""
|
|
}
|
|
return id
|
|
}
|
|
|
|
// validSessionID reports whether s is a 32-character lowercase hexadecimal
|
|
// string (a 16-byte value), the encoding the RFC mandates for the session ID.
|
|
// Only lowercase is accepted so that case-sensitive searches correlate
|
|
// reliably. Validating also guards against logging arbitrary client-controlled
|
|
// baggage values.
|
|
func validSessionID(s string) bool {
|
|
b, err := hex.DecodeString(s)
|
|
if err != nil || len(b) != 16 {
|
|
return false
|
|
}
|
|
// hex.DecodeString also accepts upper-case, so require the canonical
|
|
// lowercase encoding.
|
|
return hex.EncodeToString(b) == s
|
|
}
|
|
|
|
// StartHTTPSpan starts a span, propagating inbound trace context and writing
|
|
// X-Trace-ID/X-Span-ID response headers. The caller must end the span.
|
|
func StartHTTPSpan(tracer trace.Tracer, rw http.ResponseWriter, r *http.Request, name string) (*http.Request, trace.Span) {
|
|
propagator := otel.GetTextMapPropagator()
|
|
ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
|
|
|
|
ctx, span := tracer.Start(ctx, name)
|
|
r = r.WithContext(ctx)
|
|
|
|
if span.SpanContext().HasTraceID() && span.SpanContext().HasSpanID() {
|
|
// Technically these values are included in the Traceparent header, but
|
|
// they are easier to read for humans this way.
|
|
rw.Header().Set("X-Trace-ID", span.SpanContext().TraceID().String())
|
|
rw.Header().Set("X-Span-ID", span.SpanContext().SpanID().String())
|
|
propagator.Inject(ctx, propagation.HeaderCarrier(rw.Header()))
|
|
}
|
|
|
|
return r, span
|
|
}
|
|
|
|
// EndHTTPSpan captures request and response data after the handler is done.
|
|
func EndHTTPSpan(r *http.Request, status int, span trace.Span) {
|
|
// set the resource name as we get it only once the handler is executed
|
|
route := chi.RouteContext(r.Context()).RoutePattern()
|
|
span.SetName(fmt.Sprintf("%s %s", r.Method, route))
|
|
span.SetAttributes(netconv.Transport("tcp"))
|
|
span.SetAttributes(httpconv.ServerRequest("coderd", r)...)
|
|
span.SetAttributes(semconv.HTTPRouteKey.String(route))
|
|
|
|
// 0 status means one has not yet been sent in which case net/http library will write StatusOK
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
span.SetAttributes(semconv.HTTPStatusCodeKey.Int(status))
|
|
span.SetStatus(httpconv.ServerStatus(status))
|
|
|
|
// finally end span
|
|
span.End()
|
|
}
|
|
|
|
type tracerNameKey struct{}
|
|
|
|
// SetTracerName sets the tracer name that will be used by all spans created
|
|
// from the context.
|
|
func SetTracerName(ctx context.Context, tracerName string) context.Context {
|
|
return context.WithValue(ctx, tracerNameKey{}, tracerName)
|
|
}
|
|
|
|
// GetTracerName returns the tracer name from the context, or TracerName if none
|
|
// is set.
|
|
func GetTracerName(ctx context.Context) string {
|
|
if tracerName, ok := ctx.Value(tracerNameKey{}).(string); ok {
|
|
return tracerName
|
|
}
|
|
|
|
return TracerName
|
|
}
|
|
|
|
// StartSpan calls StartSpanWithName with the name set to the caller's function
|
|
// name.
|
|
func StartSpan(ctx context.Context, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
|
return StartSpanWithName(ctx, FuncNameSkip(1), opts...)
|
|
}
|
|
|
|
// StartSpanWithName starts a new span with the given name from the context. If
|
|
// a tracer name was set on the context (or one of its parents), it will be used
|
|
// as the tracer name instead of the default TracerName.
|
|
func StartSpanWithName(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
|
tracerName := GetTracerName(ctx)
|
|
return trace.SpanFromContext(ctx).
|
|
TracerProvider().
|
|
Tracer(tracerName).
|
|
Start(ctx, name, opts...)
|
|
}
|