Files
coder/coderd/tracing/httpmw_test.go
T
Andrew Aquino 91e28e2983 feat: handle client_session_id in agent middleware (#28039)
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._
2026-08-27 13:12:41 -07:00

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)
}
}
})
}