Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/design/daemon-legacy-session-workspace-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Legacy Session Workspace Telemetry

## Context

The daemon telemetry middleware classifies HTTP requests before Express route
handlers run. Legacy singular session routes can resolve to any registered
workspace, but the middleware cannot know the selected runtime from the URL
alone. Resolving the live owner in both middleware and the handler duplicates
work and can disagree if the registry changes between the two lookups.

This design gives every explicit legacy `/session`, `/sessions`, and
`/permission` route a stable request span while attributing dynamic routes to
the runtime selected by the handler.

## Route inventory

The route catalog contains all 48 explicit legacy routes. Each entry declares
its HTTP method, Express path template, canonical route label, and one of two
attribution modes:

- `handler_resolved` (41 routes): `POST /session`, load/resume, the legacy
transcript route, and every singular session route that resolves a live
owner. The handler publishes the selected runtime workspace to telemetry.
- `pre_resolved` (7 routes): legacy export, A2UI action, legacy organization,
the three global batch mutations, and the global permission vote. These
routes remain bound to the primary workspace.

The catalog matcher follows the relevant Express 5 defaults: static segments
are case-insensitive, one trailing slash is accepted, and parameter segments
are decoded only after their raw path boundary has been captured. A malformed
session id is retained as its raw value. Permission request ids are decoded
before their existing length and character-set validation. The emitted
`http.route` always uses the canonical catalog template.

## Deferred attribution

Handler-resolved requests start without `qwen-code.workspace.hash`. The
middleware stores a private context on the Express response. Route code calls
`setDaemonTelemetryWorkspace(res, runtime.workspaceCwd)` after a unique runtime
has been selected. The setter is best-effort and first-selection-wins: a
repeated identical value is idempotent and a later different value is ignored.

The four publication seams are:

1. `requireSessionRuntime`, shared by live-owner routes.
2. Session creation after workspace selection.
3. Session load/resume after target runtime selection.
4. Legacy transcript resolution after a unique live or persisted owner is
found.

Publication precedes later trust, unsupported-secondary, conflict, and request
validation checks. Consequently those failures retain the uniquely selected
runtime. Requests that fail before unique selection, including not-found,
ambiguous, and workspace-mismatch cases, omit the workspace hash. Attribution
uses `runtime.workspaceCwd`, not a session's requested or temporary cwd.

On response `finish` or `close`, the middleware hashes the published workspace,
sets the span attribute, records the response, and ends the span. Resolution,
hashing, and span updates are best-effort and cannot affect request handling or
metrics settlement. The context is cleared after one settlement.

Pre-resolved requests continue to hash the middleware-selected workspace when
the span starts. Removing the middleware's live-owner callback ensures a live
owner is resolved no more than once per request.

## Streaming and metrics

All 48 catalog routes create request spans. A successful
`GET /session/:id/events` response ends its span when the SSE connection closes,
but is excluded from the ordinary HTTP request count/duration and the Web Shell
status metrics ring because its duration is the connection lifetime. SSE
handshake failures are recorded as ordinary short HTTP requests.

`POST /session/:id/generate` is a bounded request-scoped SSE operation. Its
connection ends when generation completes, so its duration remains meaningful
request latency and continues to enter ordinary HTTP metrics.

Heartbeat requests remain in OpenTelemetry HTTP metrics but stay excluded from
the status metrics ring. `GET /daemon/status` also remains excluded only from
that ring. A shared settlement guard prevents duplicate recording when both
`finish` and `close` fire.

HTTP metrics and the Web Shell metrics ring remain daemon-global. Adding a
workspace metric dimension requires a separate cardinality and dashboard
compatibility review.

## Compatibility and boundaries

This change does not alter routes, request or response schemas, SDKs,
capabilities, persistence, authentication, trust ordering, archive leases,
bridge error mapping, or session execution. It does not add public telemetry
attributes.

The telemetry middleware is installed after bearer authentication, rate
limiting, and JSON parsing, so requests rejected by those earlier gates remain
outside this request-span coverage. Implicit HEAD/OPTIONS, access-log behavior,
rate-limit path normalization, workspace session-group routes,
workspace-qualified organization, ACP/WebSocket telemetry, and enabling
secondary branch/fork/cd execution are out of scope.

## Verification

- A drift guard compares the explicit legacy routes registered with Express to
the catalog and asserts the 48/41/7 inventory.
- Matcher tests cover case, trailing slash, encoded slash, Unicode, malformed
encoding, permission id validation, method/path mismatch, and canonical
labels.
- Middleware tests cover deferred attribution, first-selection-wins, hash
caching, telemetry failures, one-time settlement, SSE metrics, heartbeat, and
status exclusions.
- Route tests cover live-owner, creation, restore, and transcript publication
for primary, secondary, untrusted, missing, ambiguous, and conflict cases.
- A dual-workspace outfile test verifies secondary, primary-bound, and omitted
hashes without exposing raw workspace paths.
20 changes: 10 additions & 10 deletions docs/developers/daemon/02-serve-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@

**Middleware** (`packages/cli/src/serve/auth.ts` and `server.ts`):

| Middleware, in registration order | Purpose | Notes |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `denyBrowserOriginCors` / `allowOriginCors` | Deny all `Origin` headers by default; switch to an allowlist when `--allow-origin <pattern>` is configured. | See [`12-auth-security.md`](./12-auth-security.md). |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps each HTTP request in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include route, sessionId, clientId, and status code. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |
| Middleware, in registration order | Purpose | Notes |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `denyBrowserOriginCors` / `allowOriginCors` | Deny all `Origin` headers by default; switch to an allowlist when `--allow-origin <pattern>` is configured. | See [`12-auth-security.md`](./12-auth-security.md). |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |

**Subsystems**:

Expand Down
Loading
Loading