Skip to content

refactor: unify server-side observability logging#293

Open
ben-fornefeld wants to merge 2 commits intomainfrom
fix/server-side-observability
Open

refactor: unify server-side observability logging#293
ben-fornefeld wants to merge 2 commits intomainfrom
fix/server-side-observability

Conversation

@ben-fornefeld
Copy link
Copy Markdown
Member

Summary

  • unify request-scoped server logging across actions, tRPC, and routes
  • preserve 400 validation errors like duplicate team members instead of downgrading them to internal errors
  • log unobfuscated repo errors as warnings and prefix logs with the originating request path

Testing

  • bun test src/test/unit/repo-error-from-http.test.ts src/test/unit/request-observability.test.ts
  • bunx biome check src/core/shared/clients/logger/logger.ts src/core/shared/clients/logger/request-observability.ts src/core/server/actions/client.ts src/core/server/adapters/errors.ts src/core/server/api/middlewares/telemetry.ts 'src/app/api/trpc/[trpc]/route.ts' src/core/server/trpc/init.ts 'src/app/api/teams/[teamSlug]/metrics/route.ts' src/core/shared/errors.ts src/test/unit/repo-error-from-http.test.ts src/test/unit/request-observability.test.ts

Notes

  • full tsc --noEmit remains noisy because of pre-existing .next and route typing issues unrelated to this change

Copilot AI review requested due to automatic review settings April 11, 2026 02:04
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web Ready Ready Preview, Comment Apr 11, 2026 2:13am
web-juliett Ready Ready Preview, Comment Apr 11, 2026 2:13am

Request Review

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 11, 2026

PR Summary

Medium Risk
Medium risk because it changes request-context propagation and structured logging across actions/tRPC/routes and adjusts HTTP→RepoError mapping for 400s, which could affect error classification and observability signals if miswired.

Overview
Unifies request-scoped observability across server actions, tRPC, and route handlers by introducing a shared RequestObservabilityContext (URL/path/transport/handler) that’s propagated via OpenTelemetry context.

Logs are now automatically enriched with request_url/request_path (and prefixed by path in the log message) and actions additionally record team_slug. tRPC contexts (HTTP + RSC) and telemetry middleware now carry this request context, and the teams/[teamSlug]/metrics route is wrapped to attach it.

Error handling tweaks: repoErrorFromHttp(400, ...) now produces a validation repo error (preserving the message), and repo-error logging now records whether a message was obfuscated instead of skipping unobfuscated cases. New unit tests cover the 400 mapping and request-context derivation/log-prefixing.

Reviewed by Cursor Bugbot for commit fd60dfe. Bugbot is set up for automated code reviews on this repo. Configure here.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2d901f037

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

session: undefined as Session | undefined,
user: undefined as User | undefined,
teamId: undefined as string | undefined,
requestObservability: undefined as RequestObservabilityContext | undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep passed request observability in tRPC context

createTRPCContext now accepts opts.requestObservability, but this return object immediately overwrites it with undefined, so every tRPC request loses the request-scoped metadata provided by src/app/api/trpc/[trpc]/route.ts and src/trpc/server.tsx. As a result, telemetry/logging in startTelemetryMiddleware always falls back to generic /api/trpc context instead of the actual originating path/handler, which breaks the new observability behavior this change is intended to add.

Useful? React with 👍 / 👎.

@ben-fornefeld ben-fornefeld changed the title Unify server-side observability logging refactor: unify server-side observability logging Apr 11, 2026
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c2d901f. Configure here.

session: undefined as Session | undefined,
user: undefined as User | undefined,
teamId: undefined as string | undefined,
requestObservability: undefined as RequestObservabilityContext | undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tRPC context always discards requestObservability input

High Severity

createTRPCContext spreads opts (which includes requestObservability) but then explicitly overrides it with requestObservability: undefined. Since the explicit property comes after ...opts, the caller-provided observability context is always silently discarded. Both the HTTP route handler and the RSC caller compute and pass requestObservability, but it never reaches the telemetry middleware's ctx.requestObservability, which always falls back to a generic default. This defeats the core purpose of this PR for tRPC requests.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c2d901f. Configure here.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR unifies request-scoped server logging/observability across server actions, tRPC (RSC + HTTP), and route handlers by introducing a shared request observability context, while also preserving HTTP 400s as validation errors and improving repo error logging payloads.

Changes:

  • Add a request observability context helper (derived from request/headers) and use it to prefix log messages with the originating request path.
  • Update logger plumbing to enrich log context from the active OpenTelemetry context.
  • Treat HTTP 400s as validation RepoErrors and adjust repo error logging fields; add unit tests for both behaviors.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/trpc/server.tsx Adds request observability context to RSC tRPC context creation.
src/core/shared/errors.ts Preserves HTTP 400 errors as validation repo errors.
src/core/shared/clients/logger/request-observability.ts Introduces request observability context creation/propagation + message prefixing helper.
src/core/shared/clients/logger/logger.ts Enriches log payloads/messages using request observability from OpenTelemetry context.
src/core/server/trpc/init.ts Extends tRPC context shape to include optional request observability.
src/core/server/api/middlewares/telemetry.ts Wraps tRPC middleware execution with request observability context.
src/core/server/adapters/errors.ts Logs unobfuscated repo errors too; adds observed message + obfuscation metadata to payload.
src/core/server/actions/client.ts Wraps action execution with request observability derived from Next headers; logs request path/url + team slug.
src/app/api/trpc/[trpc]/route.ts Adds request observability context to HTTP tRPC route context creation.
src/app/api/teams/[teamSlug]/metrics/route.ts Wraps route handler in request observability context.
src/test/unit/request-observability.test.ts Adds unit tests for request context derivation and message prefix formatting.
src/test/unit/repo-error-from-http.test.ts Adds unit tests for 400 validation handling and 500 obfuscation behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

session: undefined as Session | undefined,
user: undefined as User | undefined,
teamId: undefined as string | undefined,
requestObservability: undefined as RequestObservabilityContext | undefined,
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createTRPCContext currently overwrites the passed opts.requestObservability by setting requestObservability: undefined after spreading opts. This makes ctx.requestObservability always undefined, so the new request-scoped logging context never propagates into tRPC middlewares. Remove the override or set requestObservability from opts so the value survives into ctx.

Suggested change
requestObservability: undefined as RequestObservabilityContext | undefined,

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +120
return {
child(bindings: Record<string, unknown>) {
return baseLogger.child(bindings)
},
fatal(context: ILoggerContext, message?: string, ...args: unknown[]) {
const enrichedContext = enrichLogContext(context)
baseLogger.fatal(
enrichedContext,
formatRequestLogMessage(message, enrichedContext),
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logger implementation assumes calls are always l.<level>(contextObj, message?, ...args), but the repo still contains pino-style call sites like l.info('step:...') and l.info('step:...', { ... }) (e.g. src/__test__/development/sandbox-events.test.ts, src/__test__/development/traffic.test.ts). With the current wrapper those will mis-handle arguments (string treated as context; object treated as message, leading to [object Object] and lost fields). Either add runtime argument-shape detection/overloads to keep pino-compatible call signatures, or update the remaining call sites to the { key, ... } + optional message pattern.

Copilot uses AI. Check for mistakes.
Comment on lines 119 to 124
error: RepoError
) {
const publicMessage = getPublicRepoErrorMessage(error)
if (publicMessage === error.message) {
return
}

const observedMessage = getObservedErrorMessage(error)
const span = trace.getActiveSpan()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Removing the early-return guard from logObfuscatedRepoError causes double-logging for all expected repo errors (validation, not_found, conflict): once in logObfuscatedRepoError itself and again in the downstream telemetry handler (endTelemetryMiddleware for tRPC, handleServerError for actions). The action transport produces three log entries per error. Consider keeping the guard and only adding the was_obfuscated/observed_message fields, or suppressing the downstream log when the transport adapter already logged.

Extended reasoning...

What the bug is and how it manifests

Before this PR, logObfuscatedRepoError contained an early-return guard: if (publicMessage === error.message) { return }. For validation, not_found, and conflict errors, getPublicRepoErrorMessage returns error.message unchanged, so the condition was always true and the function returned without logging. The single log entry came exclusively from the downstream telemetry handler. This PR removes the guard so logObfuscatedRepoError now always calls l.warn() for expected errors (via the isExpectedRepoError branch), and the downstream handlers still log as before — producing duplicates.

The specific code paths that trigger it

tRPC path: throwTRPCErrorFromRepoError -> logObfuscatedRepoError('trpc', error) -> l.warn with key 'transport:trpc:repo_error' (NEW). The thrown TRPCError(BAD_REQUEST/NOT_FOUND/CONFLICT) propagates to endTelemetryMiddleware, where isExpectedTRPCError() returns true -> l.warn with key 'trpc:procedure_failure' (pre-existing). Total: 2 entries (was 1).

Action path: toActionErrorFromRepoError -> logObfuscatedRepoError('action', error) -> l.warn key 'transport:action:repo_error' (NEW). ActionError(expected: true) caught by handleServerError -> l.warn key 'action_client:expected_server_error' (pre-existing). Middleware sees result.serverError -> l.warn key 'action_client:failure' (pre-existing). Total: 3 entries (was 2).

Why existing code does not prevent it

The telemetry handlers have no awareness that logObfuscatedRepoError already logged the same event at the transport adapter layer. Before the PR, the guard in logObfuscatedRepoError implicitly coordinated with them by staying silent for non-obfuscated errors. Removing the guard without suppressing the downstream logs breaks this implicit contract.

Addressing the refutations

Two refutations argue this is intentional design: (1) unauthorized/forbidden already double-logged before this PR, and (2) the two entries carry genuinely different context (repo-layer detail vs. procedure timing/input). Both points are fair. However, they describe a pre-existing accepted pattern for obfuscated errors where the transport-layer context adds real value — not necessarily a deliberate extension to unobfuscated errors where both logs emit the same public message. The PR description says 'log unobfuscated repo errors as warnings', which is achievable while keeping the guard — by adding was_obfuscated, observed_message, and transport fields before the early return rather than removing it.

What the impact is

Every validation failure (e.g., duplicate team member, invalid input) and every 404 now emits 2-3 warning log lines instead of 1-2. In production these are among the most common expected errors, so this materially inflates log volume and can obscure signal in dashboards or alerting thresholds that monitor warning rates.

Step-by-step proof

  1. Client calls a tRPC mutation that hits a duplicate-entry constraint in the repo layer.
  2. Repo returns RepoError{ code: 'conflict', message: 'Team member already exists', status: 409 }.
  3. throwTRPCErrorFromRepoError(error) is called.
  4. Inside: getPublicRepoErrorMessage(error) returns 'Team member already exists' (conflict -> returns error.message). isExpectedRepoError(error) returns true. l.warn({ key: 'transport:trpc:repo_error', was_obfuscated: false, ... }) fires. LOG ENTRY Refactor to Server Components #1.
  5. new TRPCError({ code: 'CONFLICT', message: 'Team member already exists' }) is thrown.
  6. endTelemetryMiddleware catches the failure: result.ok === false, isExpectedTRPCError(error) returns true for CONFLICT. l.warn({ key: 'trpc:procedure_failure', ... }) fires. LOG ENTRY Finalize MLP #2.

How to fix it

The minimal fix restores the guard while still capturing the new fields:

function logObfuscatedRepoError(transport, error) {
const publicMessage = getPublicRepoErrorMessage(error)
const observedMessage = getObservedErrorMessage(error)
const wasObfuscated = publicMessage !== error.message

if (!wasObfuscated) {
return // downstream telemetry handler will log; skip double-log
}
// ... log with was_obfuscated/observed_message/transport fields
}

Alternatively, accept the double log but remove the redundant downstream log when was_obfuscated === false.

Comment on lines 16 to 23
return {
...opts,
session: undefined as Session | undefined,
user: undefined as User | undefined,
teamId: undefined as string | undefined,
requestObservability: undefined as RequestObservabilityContext | undefined,
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In createTRPCContext (src/core/server/trpc/init.ts), the return object spreads ...opts (which carries the caller-supplied requestObservability) but then unconditionally overrides it with requestObservability: undefined on the last line—so ctx.requestObservability is always undefined. As a result, startTelemetryMiddleware always falls back to the hardcoded {request_path: '/api/trpc'} path instead of the rich referer-derived context that both callers (the HTTP tRPC route and the RSC caller) compute and pass in, defeating the core observability improvement this PR introduces. Fix: remove the explicit requestObservability: undefined line, since the value is already present via ...opts.

Extended reasoning...

What the bug is

In src/core/server/trpc/init.ts, createTRPCContext returns an object literal that first spreads ...opts and then explicitly sets requestObservability: undefined as RequestObservabilityContext | undefined as the last property. In JavaScript object literals, later properties silently win over earlier ones with the same key, so the spread value of opts.requestObservability is always thrown away regardless of what the caller passes.

The specific code path

// src/core/server/trpc/init.ts (lines 16–23)
return {
  ...opts,                    // ← opts.requestObservability spread here
  session: undefined,
  user: undefined,
  teamId: undefined,
  requestObservability: undefined as RequestObservabilityContext | undefined,  // ← always wins
}

Both callers compute a rich RequestObservabilityContext and pass it to createTRPCContext:

  • src/app/api/trpc/[trpc]/route.ts: calls createRequestObservabilityContext({ requestUrl: req.headers.get('referer') ?? req.url, ... })
  • src/trpc/server.tsx: calls createRequestObservabilityContextFromHeaders(heads, { preferReferer: true, ... })

Both are silently discarded.

Why existing code doesn't prevent it

TypeScript's type annotation on the requestObservability line (as RequestObservabilityContext | undefined) was presumably added to ensure the return type includes that field for downstream type-checking, but the side-effect is that the runtime value is unconditionally set to undefined. There are no tests that verify the returned context actually carries through the caller's value.

Impact

In startTelemetryMiddleware (src/core/server/api/middlewares/telemetry.ts, lines 118–124):

const requestObservability =
  ctx.requestObservability ??
  ({ request_path: '/api/trpc', transport: 'trpc', handler_name: procedurePath } as const)

Because ctx.requestObservability is always undefined, the fallback is always used. Every tRPC log will show request_path: '/api/trpc' regardless of the actual page that triggered the request, making per-route log analysis impossible and defeating the stated goal of this PR.

Step-by-step proof

  1. A browser on page /dashboard/acme/sandboxes makes a tRPC mutation.
  2. The browser attaches a referer: https://app.example.com/dashboard/acme/sandboxes header.
  3. The HTTP route handler calls createRequestObservabilityContext({ requestUrl: req.headers.get('referer') }), producing { request_path: '/dashboard/acme/sandboxes', transport: 'trpc', handler_name: 'http' }.
  4. It passes this as requestObservability to createTRPCContext.
  5. Inside createTRPCContext, the spread captures it at key requestObservability.
  6. The explicit requestObservability: undefined on the final line overwrites it.
  7. startTelemetryMiddleware receives ctx.requestObservability === undefined and logs request_path: '/api/trpc' instead.

How to fix

Remove line 21 (requestObservability: undefined as RequestObservabilityContext | undefined) entirely. The field is already present via ...opts and TypeScript will infer the correct type from the parameter declaration. Alternatively, change it to requestObservability: opts.requestObservability to make the intent explicit.

Note on the duplicate refutation

One verifier argued this is a duplicate of bug_001. That concern is about review-process deduplication, not about whether the bug is real. The bug is clearly real and confirmed by three independent verifiers; the fix and impact are identical, but the bug itself is legitimate and worth flagging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants