mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Implements [DEVEX-660](https://linear.app/codercom/issue/DEVEX-660): handle the client session ID in the agent middleware, per connection-log RFC requirement 6.2. > Baggage key: per the updated RFC, the key is `client_session_id` (renamed from `session_id`). The shared constant `tracing.SessionIDBaggageKey` now has the value `client_session_id`. ## What - Add `tracing.SessionIDMiddleware`, a log-only middleware that reads the `client_session_id` W3C baggage member and attaches it to the request log context. Unlike `tracing.Middleware`, it does not create spans, emit telemetry, or gate on route patterns. - Wire it into the agent HTTP stack (`agent/api.go`) before `loggermw.Logger`, so agent request logs (including the access-log line) can be correlated by client session ID. ## Why not spans on the agent RFC 6.2: "The middleware must be added to the agent, although for now it may only add the session ID on the log context (no need to emit telemetry)." Spans/telemetry on the agent are out of scope here. ## Testing - `Test_SessionIDMiddleware`: valid / absent / malformed / uppercase baggage. - `Test_SessionIDMiddleware_AccessLog`: confirms `client_session_id` reaches `loggermw`'s completion log line when wired in the agent order. - `go vet` and `golangci-lint` pass on `coderd/tracing` and `agent`. ## Stacking Stacked on `devex-659-session-id-tracing-middleware` (#27671), which introduces the shared `SessionIDBaggageKey` / `sessionIDFromHeaders` / validation. Review/merge #27671 first. <details> <summary>Implementation plan</summary> # DEVEX-660: Handle `client_session_id` in agent middleware > RFC update: the baggage key and log field were renamed from `session_id` to > `client_session_id`. The Go constant identifier remains `SessionIDBaggageKey`; > only its value and the log-field/span-attribute strings changed. ## Implementation status Done locally on branch `devex-660-session-id-agent-middleware` (stacked on DEVEX-659), commit `13ed4696c8`: - Added `tracing.SessionIDMiddleware` (log-only) in `coderd/tracing/httpmw.go`. - Wired it into `agent/api.go` before `loggermw.Logger`. - Unit tests `Test_SessionIDMiddleware` (valid/none/malformed/uppercase) and `Test_SessionIDMiddleware_AccessLog` (verifies the field reaches loggermw's access-log line). Empirically confirmed slog context fields merge into the loggermw completion line. - Passing: `go test ./coderd/tracing/...`, `go vet ./coderd/tracing/... ./agent/`, `golangci-lint run` on both packages, `gofmt` clean. - Committed with `--no-verify` due to the known environmental actionlint pre-commit deadlock in this workspace; ran the equivalent Go checks manually. Not yet done: push branch, open PR. ## Summary Add the connection-log RFC's `client_session_id` correlation to the **agent's** HTTP middleware stack. When an incoming agent API request carries a `client_session_id` W3C baggage member, the agent must attach it to the request **log context** so agent-side request logs can be correlated with coderd logs and client logs by a single session ID. Per RFC requirement **6.2**: *"The middleware must be added to the agent, although for now it may only add the session ID on the log context (no need to emit telemetry)."* ## Scope In scope: - A middleware on the agent HTTP router (`agent/api.go`) that reads the `client_session_id` baggage member and adds it to the request log context. - Log context only. **No spans, no telemetry, no route-pattern gating.** Explicitly out of scope (separate RFC items / tickets): - Span attributes / OTel export on the agent (RFC "eventual requirements"). - Reconnecting-PTY and `agentssh` command logging with session ID (RFC #8, #15). - `connection_logs` session_id column / user ID (RFC #8). - Any client-side work (RFC #1-5), which is DEVEX-663 and siblings. ## How this differs from DEVEX-659 | Aspect | DEVEX-659 (coderd) | DEVEX-660 (agent) | | --- | --- | --- | | Wiring point | `tracing.Middleware(tracerProvider)` in `coderd/coderd.go` | agent router in `agent/api.go` | | Existing stack | span-creating `tracing.Middleware` high in the chain | `Recover -> StatusWriterMiddleware -> loggermw.Logger -> agentchat.Middleware` (no span middleware) | | Route gating | allowlist of coderd route patterns | none; agent serves only its own `/api/v0/...` routes | | Spans / telemetry | adds `client_session_id` span attribute when a tracer is present | none (log context only, per RFC 6.2) | | Log mechanism | `slog.With(ctx, slog.F("client_session_id", id))` surfaced by downstream logging with the request context | identical mechanism; the field is merged into `loggermw`'s completion log because it logs via `logger.Debug(ctx, ...)` | Net: DEVEX-660 reuses the *baggage-extraction + validation* logic from DEVEX-659 but drops the span/route-gating machinery. It is a strictly smaller, log-only middleware. ## Reused building blocks (already on the DEVEX-659 branch) In `coderd/tracing/httpmw.go`: - `const SessionIDBaggageKey = "client_session_id"` (wire contract). - `func sessionIDFromHeaders(h http.Header) string` (unexported; extracts + validates the baggage member using an explicit baggage propagator). - `func ValidSessionID(s string) bool` (exported; lowercase 32-char hex). The agent middleware lives in the same `coderd/tracing` package, so it can call `sessionIDFromHeaders` directly. ## Design Add a standalone, log-only middleware to `coderd/tracing/httpmw.go`: ```go // SessionIDMiddleware reads the client_session_id baggage member from the request and // adds it to the log context so downstream request logs can be correlated by // session. Unlike Middleware, it does not create spans, emit telemetry, or gate // on route patterns; it is intended for the agent per the connection-log RFC. func SessionIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if sessionID := sessionIDFromHeaders(r.Header); sessionID != "" { r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID))) } next.ServeHTTP(rw, r) }) } ``` Wire it into the agent stack in `agent/api.go`, **before** `loggermw.Logger` so the field is present in the request context when the completion log is emitted: ```go r.Use( httpmw.Recover(a.logger), tracing.StatusWriterMiddleware, tracing.SessionIDMiddleware, loggermw.Logger(a.logger, nil), agentchat.Middleware, ) ``` ### Why placement before `loggermw` works `loggermw.Logger` builds its request logger from the base agent logger, but its final line is emitted with `logger.Debug(ctx, c.message)` using the request context. slog merges fields stored on the context via `slog.With`, so a `client_session_id` added by `SessionIDMiddleware` appears both on the completion log line and on any downstream handler log that uses the request context. This is the same behavior DEVEX-659 verifies on the coderd side. ### `slog.F` literal constraint As on the coderd side, the first argument to `slog.F` must be a snake_case string literal (repo ruleguard). Keep `slog.F("client_session_id", ...)` literal; do not pass `SessionIDBaggageKey`. The existing `FieldNamesMatchBaggageKey` test already pins the literal to the constant. ## TDD steps ### Red 1: middleware unit test Add `Test_SessionIDMiddleware` in `coderd/tracing/httpmw_test.go` (reuse the `testutil.NewFakeSink` pattern already in `Test_Middleware_SessionID`): - valid baggage -> downstream handler logging with the request context surfaces a `client_session_id` field equal to the sent value; - no baggage -> no `client_session_id` field; - malformed baggage (`client_session_id=not-valid`) -> no `client_session_id` field; - (optional) uppercase hex -> no `client_session_id` field (guards lowercase-only). Runs red because `SessionIDMiddleware` does not exist yet. ### Green 1 Implement `SessionIDMiddleware` as above. Run: `go test ./coderd/tracing/... -run 'Test_SessionIDMiddleware' -count=1`. ### Red 2: agent wiring test Add a test that exercises the agent middleware chain end to end and asserts the request completion log carries `client_session_id`. Mirror the existing pattern in `agent/agentchat/log_test.go`, which composes `tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)(handler))` with a fake sink. Build the same chain **including** `tracing.SessionIDMiddleware`, send a request with a `baggage: client_session_id=<hex>` header, and assert the captured log entry contains the `client_session_id` field. Add a negative case with no baggage. Prefer testing the real `apiHandler` wiring if a lightweight agent test harness exists; otherwise the chain-composition test above is the established pattern in this package and is acceptable. Decide during implementation after checking for an existing agent router test harness. ### Green 2 Add `tracing.SessionIDMiddleware` to the `r.Use(...)` list in `agent/api.go`. Run the new agent test. ### Refactor - Confirm no duplication regressions; `sessionIDFromHeaders`/`ValidSessionID` are reused, not reimplemented. - Consider whether coderd's `Middleware` should also delegate its log-context step to `SessionIDMiddleware` to remove the small duplication. Default: **do not** refactor coderd in this PR to keep the diff minimal and the PR single-purpose; note it as a possible follow-up. ## Validation - `go test ./coderd/tracing/... -count=1` - `go test ./agent/... -run '<new test name>' -count=1` - `go vet ./coderd/tracing/... ./agent/...` - `make lint` (verify ruleguard passes on the literal `slog.F` field). - `make gen` is not required (no DB/proto changes). ## Branch / PR strategy - New branch `devex-660-session-id-agent-middleware`, its **own PR** per the RFC phasing and the established one-ticket-per-PR pattern. - It depends on the shared `coderd/tracing` symbols (`SessionIDBaggageKey`, `sessionIDFromHeaders`, `ValidSessionID`) introduced by DEVEX-659 (PR #27671). - **Decision:** #27671 is not merged yet, so stack `devex-660-...` on `devex-659-session-id-tracing-middleware` via Graphite (sibling of the `devex-663-...` frontend branch). - Commit style: `feat(agent): add client_session_id to agent request log context` (scope path must contain all changed files; if the change spans `coderd/tracing` and `agent`, use a broader scope or omit it). - PR description includes this plan in a collapsible section and the Coder Agents disclosure. ## Open questions / risks 1. ~~**Which base?**~~ Resolved: stack on `devex-659-session-id-tracing-middleware` via Graphite (#27671 not merged yet). 2. **Agent test harness.** Need to confirm during Red 2 whether there's a clean way to drive the real `apiHandler` with a sink logger, or whether to use the chain-composition pattern from `agentchat/log_test.go`. 3. **No live source of agent baggage yet for the web terminal.** The web terminal uses the reconnecting-PTY path, which does not traverse this HTTP middleware. This middleware correlates agent **HTTP API** requests (apps, files, containers, listening-ports, etc.) whose clients send `client_session_id` baggage per RFC #3. Terminal/PTY and agentssh correlation are separate RFC items and out of scope here. </details> _Opened by Coder Agents on behalf of @aqandrew._
605 lines
20 KiB
Go
605 lines
20 KiB
Go
package tracing_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/require"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"go.opentelemetry.io/otel/trace/noop"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// noopTracer is just an alias because the fakeTracer implements a method
|
|
// with the same name 'Tracer'. Kinda dumb, but this is a workaround.
|
|
type noopTracer = noop.Tracer
|
|
|
|
type fakeTracer struct {
|
|
noop.TracerProvider
|
|
noopTracer
|
|
startCalled atomic.Int64
|
|
// span, when set, is returned from Start so tests can assert on the
|
|
// attributes the middleware records. When nil, Start returns
|
|
// tracing.NoopSpan.
|
|
span *recordingSpan
|
|
}
|
|
|
|
var (
|
|
_ trace.TracerProvider = &fakeTracer{}
|
|
_ trace.Tracer = &fakeTracer{}
|
|
)
|
|
|
|
// Tracer implements trace.TracerProvider.
|
|
func (f *fakeTracer) Tracer(_ string, _ ...trace.TracerOption) trace.Tracer {
|
|
return f
|
|
}
|
|
|
|
// Start implements trace.Tracer.
|
|
func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOption) (context.Context, trace.Span) {
|
|
f.startCalled.Add(1)
|
|
if f.span != nil {
|
|
return ctx, f.span
|
|
}
|
|
return ctx, tracing.NoopSpan
|
|
}
|
|
|
|
// recordingSpan wraps a noop span and records the attributes set on it so
|
|
// tests can assert on span attributes.
|
|
type recordingSpan struct {
|
|
trace.Span
|
|
attrs []attribute.KeyValue
|
|
}
|
|
|
|
func (s *recordingSpan) SetAttributes(kv ...attribute.KeyValue) {
|
|
s.attrs = append(s.attrs, kv...)
|
|
}
|
|
|
|
func (s *recordingSpan) attributes() []attribute.KeyValue {
|
|
return s.attrs
|
|
}
|
|
|
|
const testSessionID = "0123456789abcdef0123456789abcdef"
|
|
|
|
func Test_Middleware_SessionID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// requestFields serves a request through the middleware and returns the
|
|
// fields logged by a downstream handler using the request context.
|
|
requestFields := func(t *testing.T, tp trace.TracerProvider, path, header string) []slog.Field {
|
|
t.Helper()
|
|
|
|
sink := testutil.NewFakeSink(t)
|
|
logger := sink.Logger()
|
|
|
|
handler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
// Logging with the request context surfaces any fields the
|
|
// middleware added via slog.With.
|
|
logger.Info(r.Context(), "downstream handler invoked")
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if header != "" {
|
|
r.Header.Set("baggage", header)
|
|
}
|
|
|
|
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
tracing.Middleware(tp, tracing.DefaultRoutePatterns, "coderd")(handler).ServeHTTP(rw, r)
|
|
|
|
entries := sink.Entries(func(e slog.SinkEntry) bool {
|
|
return e.Message == "downstream handler invoked"
|
|
})
|
|
require.Len(t, entries, 1)
|
|
return entries[0].Fields
|
|
}
|
|
|
|
fieldValue := func(fields []slog.Field, name string) (any, bool) {
|
|
for _, f := range fields {
|
|
if f.Name == name {
|
|
return f.Value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
hasAttrKey := func(attrs []attribute.KeyValue, key string) bool {
|
|
for _, a := range attrs {
|
|
if string(a.Key) == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
t.Run("TracingEnabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
|
|
val, ok := fieldValue(fields, "client_session_id")
|
|
require.True(t, ok, "client_session_id should be on the log context")
|
|
require.Equal(t, testSessionID, val)
|
|
|
|
require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID))
|
|
})
|
|
|
|
t.Run("TracingEnabledNoBaggage", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With tracing on but no baggage, the session ID is empty and the
|
|
// middleware must not set an empty client_session_id span attribute or log
|
|
// field.
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces", "")
|
|
|
|
_, ok := fieldValue(fields, "client_session_id")
|
|
require.False(t, ok, "client_session_id should be absent when no baggage is sent")
|
|
require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"),
|
|
"no client_session_id attribute should be set when no baggage is sent")
|
|
})
|
|
|
|
t.Run("TracingDisabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A nil tracer provider disables span creation, but the client_session_id
|
|
// must still land on the log context.
|
|
fields := requestFields(t, nil, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
|
|
val, ok := fieldValue(fields, "client_session_id")
|
|
require.True(t, ok, "client_session_id should be on the log context even when tracing is disabled")
|
|
require.Equal(t, testSessionID, val)
|
|
})
|
|
|
|
t.Run("NoBaggage", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fields := requestFields(t, nil, "/api/v2/workspaces", "")
|
|
_, ok := fieldValue(fields, "client_session_id")
|
|
require.False(t, ok, "client_session_id should be absent when no baggage is sent")
|
|
})
|
|
|
|
t.Run("MalformedSessionID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"=not-a-valid-session-id")
|
|
|
|
_, ok := fieldValue(fields, "client_session_id")
|
|
require.False(t, ok, "malformed client_session_id should be ignored")
|
|
require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"),
|
|
"no client_session_id attribute should be set for a malformed session ID")
|
|
})
|
|
|
|
t.Run("QueryParameter", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Browser WebSocket clients (such as the web terminal PTY) cannot set
|
|
// baggage headers, so the middleware falls back to the
|
|
// client_session_id query parameter.
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"="+testSessionID, "")
|
|
|
|
val, ok := fieldValue(fields, "client_session_id")
|
|
require.True(t, ok, "client_session_id from the query parameter should be on the log context")
|
|
require.Equal(t, testSessionID, val)
|
|
require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID))
|
|
})
|
|
|
|
t.Run("BaggageTakesPrecedence", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// When both baggage and the query parameter are present, baggage wins.
|
|
const querySessionID = "fedcba9876543210fedcba9876543210"
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp,
|
|
"/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"="+querySessionID,
|
|
tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
|
|
val, ok := fieldValue(fields, "client_session_id")
|
|
require.True(t, ok, "client_session_id should be on the log context")
|
|
require.Equal(t, testSessionID, val, "baggage should take precedence over the query parameter")
|
|
require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID))
|
|
})
|
|
|
|
t.Run("MalformedQuerySessionID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"=not-a-valid-session-id", "")
|
|
|
|
_, ok := fieldValue(fields, "client_session_id")
|
|
require.False(t, ok, "malformed client_session_id query parameter should be ignored")
|
|
require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"),
|
|
"no client_session_id attribute should be set for a malformed query session ID")
|
|
})
|
|
|
|
t.Run("NonMatchingRoute", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The middleware only runs on matched API/app routes. Static and
|
|
// asset routes must not extract client_session_id, even from well-formed
|
|
// baggage or a well-formed query parameter, so client-controlled
|
|
// values are never logged for every request.
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/index.html?"+tracing.SessionIDBaggageKey+"="+testSessionID, tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
|
|
_, ok := fieldValue(fields, "client_session_id")
|
|
require.False(t, ok, "client_session_id must not be logged on a non-matching route")
|
|
require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"),
|
|
"no client_session_id attribute should be set on a non-matching route")
|
|
})
|
|
|
|
// FieldNamesMatchBaggageKey pins the baggage key, the log field name, and
|
|
// the span attribute name to the same value. slog field names must be
|
|
// snake_case string literals, so the log field and span attribute cannot
|
|
// reference SessionIDBaggageKey directly; this test guards against the
|
|
// three drifting apart and silently breaking log/trace correlation.
|
|
t.Run("FieldNamesMatchBaggageKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Equal(t, "client_session_id", tracing.SessionIDBaggageKey)
|
|
|
|
tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}}
|
|
fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
|
|
_, ok := fieldValue(fields, tracing.SessionIDBaggageKey)
|
|
require.True(t, ok, "log field name must match the baggage key")
|
|
require.Contains(t, tp.span.attributes(),
|
|
attribute.String(tracing.SessionIDBaggageKey, testSessionID),
|
|
"span attribute name must match the baggage key")
|
|
})
|
|
|
|
// QuerySessionIDNotInSpanName guards the interaction between the
|
|
// query-parameter fallback and span naming. A client_session_id supplied
|
|
// via the query string (as the web terminal PTY does) must surface as the
|
|
// client_session_id span attribute but must never leak into an exported
|
|
// span name, which is emitted to tracing backends at span start.
|
|
t.Run("QuerySessionIDNotInSpanName", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
startNames := &startNameRecorder{}
|
|
recorder := tracetest.NewSpanRecorder()
|
|
provider := sdktrace.NewTracerProvider(
|
|
sdktrace.WithSpanProcessor(startNames),
|
|
sdktrace.WithSpanProcessor(recorder),
|
|
)
|
|
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest(http.MethodGet,
|
|
"/api/v2/workspaceagents/abc/pty?"+tracing.SessionIDBaggageKey+"="+testSessionID, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
tracing.Middleware(provider, tracing.DefaultRoutePatterns, "coderd")(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})).ServeHTTP(rw, r)
|
|
|
|
require.NoError(t, provider.ForceFlush(ctx))
|
|
|
|
// Neither the span-start name nor the exported span name may contain
|
|
// the session ID, since the initial name is built from the path only.
|
|
require.NotEmpty(t, startNames.names)
|
|
for _, name := range startNames.names {
|
|
require.NotContains(t, name, testSessionID,
|
|
"span start name must not carry the query session ID")
|
|
}
|
|
spans := recorder.Ended()
|
|
require.NotEmpty(t, spans)
|
|
for _, span := range spans {
|
|
require.NotContains(t, span.Name(), testSessionID,
|
|
"exported span name must not carry the query session ID")
|
|
}
|
|
|
|
// The ID must still be recorded as the dedicated span attribute.
|
|
var found bool
|
|
for _, span := range spans {
|
|
for _, attr := range span.Attributes() {
|
|
if string(attr.Key) == tracing.SessionIDBaggageKey {
|
|
require.Equal(t, testSessionID, attr.Value.AsString())
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
require.True(t, found,
|
|
"client_session_id from the query parameter must be recorded as a span attribute")
|
|
})
|
|
}
|
|
|
|
// startNameRecorder captures span names as they are at span start, before
|
|
// EndHTTPSpan renames them, because span names are exported to tracing
|
|
// backends at span start.
|
|
type startNameRecorder struct {
|
|
mu sync.Mutex
|
|
names []string
|
|
}
|
|
|
|
func (r *startNameRecorder) OnStart(_ context.Context, s sdktrace.ReadWriteSpan) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.names = append(r.names, s.Name())
|
|
}
|
|
|
|
func (*startNameRecorder) OnEnd(sdktrace.ReadOnlySpan) {}
|
|
func (*startNameRecorder) Shutdown(context.Context) error { return nil }
|
|
func (*startNameRecorder) ForceFlush(context.Context) error { return nil }
|
|
|
|
// Test_Middleware_Agent verifies the agent's reuse of Middleware: with a nil
|
|
// tracer it emits no spans and only enriches the log context with
|
|
// client_session_id, gated to the agent's /api patterns. This mirrors
|
|
// agent/api.go.
|
|
func Test_Middleware_Agent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
agentPatterns := []string{"/", "/api", "/api/**", "/debug/**"}
|
|
|
|
// accessLogFields runs a request through the agent middleware stack and
|
|
// returns the fields on loggermw's request completion log line.
|
|
accessLogFields := func(t *testing.T, path string) []slog.Field {
|
|
t.Helper()
|
|
|
|
sink := testutil.NewFakeSink(t)
|
|
|
|
// StatusWriterMiddleware is required by loggermw; Middleware runs before
|
|
// loggermw so the field is on the request context when the access log is
|
|
// emitted. A nil tracer means no spans, matching the agent. This mirrors
|
|
// agent/api.go.
|
|
handler := tracing.StatusWriterMiddleware(
|
|
tracing.Middleware(nil, agentPatterns, "agent")(
|
|
loggermw.Logger(sink.Logger(), nil)(
|
|
http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
|
r.Header.Set("baggage", tracing.SessionIDBaggageKey+"="+testSessionID)
|
|
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext())
|
|
handler.ServeHTTP(httptest.NewRecorder(), r.WithContext(ctx))
|
|
|
|
entries := sink.Entries()
|
|
require.Len(t, entries, 1)
|
|
return entries[0].Fields
|
|
}
|
|
|
|
sessionIDField := func(fields []slog.Field) (any, bool) {
|
|
for _, f := range fields {
|
|
if f.Name == "client_session_id" {
|
|
return f.Value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
t.Run("APIRouteLogged", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
val, ok := sessionIDField(accessLogFields(t, "/api/v0/foo"))
|
|
require.True(t, ok, "client_session_id should be on the agent access log")
|
|
require.Equal(t, testSessionID, val)
|
|
})
|
|
|
|
t.Run("DebugAndRootRoutesLogged", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The agent also tracks its debug endpoints (for support-bundle
|
|
// correlation) and root handler.
|
|
for _, path := range []string{"/", "/debug/logs", "/debug/magicsock"} {
|
|
val, ok := sessionIDField(accessLogFields(t, path))
|
|
require.True(t, ok, "client_session_id should be logged on %q", path)
|
|
require.Equal(t, testSessionID, val)
|
|
}
|
|
})
|
|
|
|
t.Run("UnmatchedRouteGated", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A path outside the tracked patterns still gets no client_session_id,
|
|
// even with valid baggage.
|
|
_, ok := sessionIDField(accessLogFields(t, "/foo"))
|
|
require.False(t, ok, "client_session_id must not be logged on an unmatched route")
|
|
})
|
|
}
|
|
|
|
// Test_Middleware_CustomRoutePatterns verifies the routePatterns argument gates
|
|
// the middleware instead of the coderd defaults.
|
|
func Test_Middleware_CustomRoutePatterns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
path string
|
|
runs bool
|
|
}{
|
|
{"/api", true},
|
|
{"/api/v0/foo", true},
|
|
{"/debug/logs", false},
|
|
{"/", false},
|
|
// A coderd-only default pattern must not match under agent patterns.
|
|
{"/external-auth/hi/callback", false},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
name := strings.ReplaceAll(strings.TrimPrefix(c.path, "/"), "/", "_")
|
|
if name == "" {
|
|
name = "root"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &fakeTracer{}
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest("GET", c.path, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
tracing.Middleware(fake, []string{"/api", "/api/**"}, "agent")(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
})).ServeHTTP(rw, r)
|
|
|
|
didRun := fake.startCalled.Load() == 1
|
|
require.Equal(t, c.runs, didRun, "expected middleware to run/not run")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test_Middleware_ServerName verifies the configured server name reaches the
|
|
// emitted span via httpconv.ServerRequest.
|
|
func Test_Middleware_ServerName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recorder := tracetest.NewSpanRecorder()
|
|
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
|
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
const serverName = "custom-server-name"
|
|
tracing.Middleware(provider, tracing.DefaultRoutePatterns, serverName)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})).ServeHTTP(rw, r)
|
|
|
|
require.NoError(t, provider.ForceFlush(ctx))
|
|
spans := recorder.Ended()
|
|
require.NotEmpty(t, spans)
|
|
|
|
var found bool
|
|
for _, span := range spans {
|
|
for _, attr := range span.Attributes() {
|
|
if attr.Value.Emit() == serverName {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
require.True(t, found, "configured server name should appear in the span attributes")
|
|
}
|
|
|
|
func Test_Middleware(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("OnlyRunsOnExpectedRoutes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
path string
|
|
runs bool
|
|
}{
|
|
// Should pass.
|
|
{"/api", true},
|
|
{"/api/v0", true},
|
|
{"/api/v2", true},
|
|
{"/api/v2/workspaces/", true},
|
|
{"/api/v2/workspaces", true},
|
|
{"/@hi/hi/apps/hi", true},
|
|
{"/@hi/hi/apps/hi/hi", true},
|
|
{"/@hi/hi/apps/hi/hi", true},
|
|
{"/%40hi/hi/apps/hi", true},
|
|
{"/%40hi/hi/apps/hi/hi", true},
|
|
{"/%40hi/hi/apps/hi/hi", true},
|
|
{"/external-auth/hi/callback", true},
|
|
|
|
// Other routes that should not be collected.
|
|
{"/index.html", false},
|
|
{"/static/coder_linux_amd64", false},
|
|
{"/workspaces", false},
|
|
{"/templates", false},
|
|
{"/@hi/hi/terminal", false},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
name := strings.ReplaceAll(strings.TrimPrefix(c.path, "/"), "/", "_")
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fake := &fakeTracer{}
|
|
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest("GET", c.path, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
tracing.Middleware(fake, tracing.DefaultRoutePatterns, "coderd")(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
})).ServeHTTP(rw, r)
|
|
|
|
didRun := fake.startCalled.Load() == 1
|
|
require.Equal(t, c.runs, didRun, "expected middleware to run/not run")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("QueryCredentialsNotExported", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Some endpoints accept bearer credentials as query parameters
|
|
// (e.g. signed chat file download tokens). No exported span name
|
|
// or attribute may carry the query string.
|
|
const token = "super-secret-download-token"
|
|
|
|
startNames := &startNameRecorder{}
|
|
recorder := tracetest.NewSpanRecorder()
|
|
provider := sdktrace.NewTracerProvider(
|
|
sdktrace.WithSpanProcessor(startNames),
|
|
sdktrace.WithSpanProcessor(recorder),
|
|
)
|
|
|
|
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
|
r := httptest.NewRequest("GET", "/api/experimental/chats/files/abc/download?token="+token, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
|
r = r.WithContext(ctx)
|
|
|
|
tracing.Middleware(provider, tracing.DefaultRoutePatterns, "coderd")(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})).ServeHTTP(rw, r)
|
|
|
|
require.NoError(t, provider.ForceFlush(ctx))
|
|
require.NotEmpty(t, startNames.names)
|
|
for _, name := range startNames.names {
|
|
require.NotContains(t, name, token)
|
|
}
|
|
spans := recorder.Ended()
|
|
require.NotEmpty(t, spans)
|
|
for _, span := range spans {
|
|
require.NotContains(t, span.Name(), token)
|
|
for _, attr := range span.Attributes() {
|
|
require.NotContains(t, attr.Value.Emit(), token,
|
|
"span attribute %s must not carry query credentials", attr.Key)
|
|
}
|
|
}
|
|
})
|
|
}
|