Skip to content

fix(ssr): surface response stream errors in logs and OTel traces#321

Open
sauravvarma wants to merge 1 commit into
mainfrom
fix/ssr-response-stream-error-observability
Open

fix(ssr): surface response stream errors in logs and OTel traces#321
sauravvarma wants to merge 1 commit into
mainfrom
fix/ssr-response-stream-error-observability

Conversation

@sauravvarma

Copy link
Copy Markdown
Collaborator

Summary

Makes response stream errors during SSR streaming observable. Today they are invisible: Node swallows errors like ERR_STREAM_WRITE_AFTER_END when the response has no error listener, so the write is dropped, nothing is logged anywhere, and a truncated document ships silently. That silence is exactly how the streaming race in #320 went unnoticed (every SSR response on react-dom 19 currently loses React's closing </body></html> chunk with a swallowed write-after-end on every request).

This PR is observability only. It does not fix the race (#320 tracks that); it makes the failure, and any future response stream error, impossible to miss in logs and traces.

What changed

src/server/renderer/handler.jsx

  • renderMarkUp now attaches a single error listener to res before streaming starts. Every stream error is logged with the request URL:

    Response stream error while streaming SSR for /: Error [ERR_STREAM_WRITE_AFTER_END]: write after end
        at write_ (node:_http_outgoing:900:11)
        at ServerResponse.write (node:_http_outgoing:849:15)
    
  • The listener is guarded by a WeakSet because renderMarkUp can run twice for one response (the fetcher error path re-renders with a 404) and error listeners must not stack.

src/otel.js

  • New createStreamErrorRecorder(serviceName, name = "responseStreamError") helper. Stream errors fire on the response's error event, typically after the span that performed the offending write has already ended, and recordException on an ended span is silently dropped. The helper therefore captures context.active() while the request span is alive and, per error, emits a dedicated responseStreamError span into the same trace: status ERROR, exception event, and an error.type attribute carrying the error code. Same parenting approach as responseFlushMiddleware's post-end spans.
  • The handler wires this up only when OTEL_ENABLE is set, through the existing lazy otel.js import; when tracing is off, the recorder is null and only the console log runs.

How it was verified

  1. Lint parity: eslint on both files reports the same 5 pre-existing errors as main, nothing new.
  2. OTel recorder semantics (in-memory exporter, @opentelemetry/sdk-trace-node): recorder created inside an active renderMarkUp span, span ended, error fired on a later tick. All checks pass: error span exported, same traceId as the request span, parented to the renderMarkUp span, status ERROR with the error message, exception event recorded, error.type = ERR_STREAM_WRITE_AFTER_END.
  3. End to end via apps/catalyst-core-test (packed current branch, catalyst build, NODE_ENV=production catalyst serve): every request to / now produces the log line above; on this branch's main the same requests produce no output at all.
  4. No behavior change: the served document is byte-shape identical before and after (doctype present, same <script src> count, and, per SSR streaming race: pipe(res) in onShellReady competes with res.write()/res.end() in onAllReady, truncating every SSR response #320, still no </html>; that bug is intentionally untouched here).

A side benefit: response error paths that end in res.destroy(err) (fatal render errors, client disconnects) now also have a listener, so they are logged instead of relying on Node's default handling.

Refs #320.

Node swallows response stream errors such as ERR_STREAM_WRITE_AFTER_END
when res has no error listener: the write is dropped, nothing is logged,
and a truncated document ships silently. This is how the streaming race
in #320 went unnoticed in production.

Attach one guarded error listener per response in renderMarkUp that logs
every stream error with the request URL. When OTEL_ENABLE is set, the
error is also recorded as a dedicated responseStreamError span in the
request's trace via the new createStreamErrorRecorder helper in otel.js.
The recorder captures the ambient context while the request span is
still active, because the error event usually fires after that span has
ended and recordException on an ended span is dropped.

Observability only: streaming behavior and response bytes are unchanged.
The race itself is tracked in #320.
@deputydev-agent

Copy link
Copy Markdown

DeputyDev will no longer review pull requests automatically.To request a review, simply comment #review on your pull request—this will trigger an on-demand review whenever you need it.

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.

1 participant