Skip to content

Add OpenTelemetry spans across SSR handler and extract pipeline - #226

Open
Uttkarsh-Srivastava wants to merge 3 commits into
mainfrom
feature/main-observability
Open

Uttkarsh-Srivastava wants to merge 3 commits into
mainfrom
feature/main-observability

Conversation

@Uttkarsh-Srivastava

Copy link
Copy Markdown
Collaborator

Summary

Adds OpenTelemetry tracing across the SSR request pipeline so that individual stages of each request surface as
spans in the trace. Inspiration was taken from feature/handler-observability-v3; only the observability
instrumentation was pulled in — unrelated refactors on that branch (CSS cache rewrite, per-route ChunkExtractor
cache, safe-area removal, new onAppServerSide* hooks, fetcherData.error flow change) are intentionally left out.

Changes

src/otel.js

  • Adds withObservability (async-safe) and withSyncObservability wrappers that start an active span, record
    exceptions, and close the span in finally.
  • Adds dual transport support for traces and metrics (grpc and http), selected via new traceProtocol /
    metricProtocol options.
  • Metrics are now opt-in — initialised only when metricProtocol is provided.
  • Adds ParentBasedSampler + TraceIdRatioBasedSampler driven by a new samplingRate option (default 1.0).
  • Exposes batchProcessorConfig, instrumentations, and grpcCredentials passthroughs on init().

src/server/renderer/extract.js

  • Wraps the default export (extractAssets) and cacheAndFetchAssets with withSyncObservability.
  • Existing CSS-cache / preload-JS behaviour is unchanged.

src/server/renderer/handler.js

  • Defines SSR_SERVICE (process.env.SERVICE_NAMEpwa-${APPLICATION}-node-server-otel).
  • Wraps user lifecycle hooks (onRouteMatch, onFetcherSuccess, onFetcherError, onRenderError,
    onRequestError) via a traceHandlerHook helper that no-ops when the hook isn't a function.
  • Splits getMatchRoutes into an internal _getMatchRoutes (recursive, no span-per-frame) and a wrapped outer
    span.
  • Isolates the dry-run renderToString inside getMatchRoutes into its own renderToString span.
  • Wraps App.serverSideFunction, serverDataFetcher, getMetaData, and renderMarkUp.
  • Wraps res.write(firstFoldCss), res.write(firstFoldJS), and res.end() during the onAllReady stream
    completion so TTFB-relevant writes are visible on the trace.
  • Default export is now withObservability(_handler, "handler") — every request yields a root SSR span.

Safe-area header handling, extractor creation per-request, and the existing error-flow semantics are preserved
exactly as on main.

Span tree (per SSR request)

handler
├── getMatchRoutes
│ └── renderToString (dry-run for ChunkExtractor)
├── onRouteMatch (if defined)
├── App.serverSideFunction
├── serverDataFetcher
├── getMetaData
├── onFetcherSuccess / onFetcherError
└── renderMarkUp
├── res.write.firstFoldCss
├── res.write.firstFoldJS
├── res.end
└── onRenderError (on failure)

Dependencies

src/otel.js now imports gRPC exporters and sampler primitives in addition to the existing HTTP exporters.
Consuming apps must ensure the following are installed (install command for reference):

npm install --save \
  @opentelemetry/auto-instrumentations-node@^0.64.1 \
  @opentelemetry/core@^2.0.0 \
  @opentelemetry/exporter-trace-otlp-grpc@^0.208.0 \
  @opentelemetry/instrumentation-express@^0.54.0 \
  @opentelemetry/instrumentation-http@^0.205.0 \
  @opentelemetry/resources@^2.0.0 \
  @opentelemetry/sdk-node@^0.205.0

Backward compatibility

- init() defaults (traceProtocol: "grpc", traceUrl: "http://localhost:4317", metrics disabled when metricProtocol is
 omitted) are a behavioural change vs. the prior HTTP-only default. Existing consumers that relied on HTTP + metrics
 must pass traceProtocol: "http", traceUrl: ".../v1/traces", and metricProtocol: "http" explicitly.
- All span wrappers are transparent — return values and thrown errors are preserved.

Test plan

- Start an app on this build with an OTLP collector reachable at localhost:4317 (gRPC) and confirm one root handler
span per request with the child tree above
- Verify the renderToString dry-run span fires only on a cache-miss route (first hit)
- Verify user hooks (onRouteMatch, onFetcherSuccess, etc.) show up as spans when defined in the template and are
absent when not
- Exercise App.serverSideFunction throwing — confirm onRenderError span records the exception and status
- Switch traceProtocol: "http" and confirm traces still flow through the HTTP exporter
- Enable metricProtocol: "http" and confirm custom gauges (CPU, memory) emit at the configured interval
- Run the existing SSR integration suite to confirm no response-body regression from the res.write/res.end wrapping

Instrument the SSR request path with withObservability / withSyncObservability
wrappers around handler, getMatchRoutes, renderToString dry-run, App.serverSideFunction,
serverDataFetcher, getMetaData, renderMarkUp, res.write/end, and user lifecycle hooks.
otel.js now exposes the wrappers and supports gRPC/HTTP exporters with parent-based
sampling and optional metrics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@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.

Uttkarsh-Srivastava and others added 2 commits April 23, 2026 13:21
Adds the otel packages required by src/otel.js as peerDependencies so
consuming apps provide a single shared installation — avoiding duplicate
otel instances across the framework and host app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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