diff --git a/docs/package.json b/docs/package.json
index b507ce5a..6793424d 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -12,6 +12,15 @@
"dependencies": {
"@docsearch/react": "3",
"@lazarv/react-server": "workspace:^",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
+ "@opentelemetry/resources": "^2.0.0",
+ "@opentelemetry/sdk-metrics": "^2.0.0",
+ "@opentelemetry/sdk-node": "^0.200.0",
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
+ "@opentelemetry/semantic-conventions": "^1.28.0",
"@uidotdev/usehooks": "^2.4.1",
"algoliasearch": "^4.24.0",
"highlight.js": "^11.9.0",
diff --git a/docs/react-server.development.config.json b/docs/react-server.development.config.json
new file mode 100644
index 00000000..4dffdffb
--- /dev/null
+++ b/docs/react-server.development.config.json
@@ -0,0 +1,5 @@
+{
+ "telemetry": {
+ "enabled": true
+ }
+}
diff --git a/docs/src/pages/en/(pages)/features/observability.mdx b/docs/src/pages/en/(pages)/features/observability.mdx
new file mode 100644
index 00000000..65ccdfc2
--- /dev/null
+++ b/docs/src/pages/en/(pages)/features/observability.mdx
@@ -0,0 +1,417 @@
+---
+title: Observability
+category: Features
+order: 16
+---
+
+import Link from "../../../../components/Link.jsx";
+
+# Observability
+
+`@lazarv/react-server` provides built-in [OpenTelemetry](https://opentelemetry.io/) integration for full observability in both development and production. When enabled, the runtime automatically instruments HTTP requests, SSR rendering, server functions, and middleware — emitting distributed traces and metrics without any application code changes.
+
+All OpenTelemetry dependencies are **optional** and loaded lazily. When telemetry is disabled (the default), there is **zero runtime overhead** — all instrumentation resolves to no-op objects.
+
+
+## Getting Started
+
+
+### Install OpenTelemetry packages
+
+```bash
+pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/sdk-metrics @opentelemetry/core @opentelemetry/resources @opentelemetry/semantic-conventions
+```
+
+### Enable telemetry
+
+You can enable telemetry in any of these ways:
+
+**1. Configuration file** — add a `telemetry` section to your `react-server.config.mjs`:
+
+```js
+export default {
+ telemetry: {
+ enabled: true,
+ serviceName: "my-app",
+ },
+};
+```
+
+**2. Environment variable** — set the standard OpenTelemetry endpoint:
+
+```bash
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+```
+
+**3. Runtime-specific env var:**
+
+```bash
+REACT_SERVER_TELEMETRY=true
+```
+
+When enabled, the runtime initializes the OpenTelemetry SDK on server startup and shuts it down gracefully when the server closes.
+
+
+## Configuration
+
+
+All telemetry settings live under the `telemetry` key in your configuration file:
+
+```js
+export default {
+ telemetry: {
+ // Enable/disable telemetry (default: false)
+ enabled: true,
+
+ // Service name reported to your backend (default: package name or "@lazarv/react-server")
+ serviceName: "my-app",
+
+ // OTLP endpoint (default: "http://localhost:4318")
+ endpoint: "http://localhost:4318",
+
+ // Exporter type: "otlp" | "console" | "dev-console" (default: auto-detected)
+ exporter: "otlp",
+
+ // Sampling rate 0.0–1.0 (default: 1.0 — sample everything)
+ sampleRate: 1.0,
+
+ // Metrics configuration
+ metrics: {
+ // Enable/disable metrics collection (default: true when telemetry is enabled)
+ enabled: true,
+
+ // Export interval in milliseconds (default: 30000)
+ interval: 30000,
+ },
+ },
+};
+```
+
+### Environment variables
+
+The following environment variables are respected:
+
+| Variable | Description |
+| --- | --- |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint. Setting this also enables telemetry. |
+| `OTEL_SERVICE_NAME` | Service name override. |
+| `REACT_SERVER_TELEMETRY` | Set to `"true"` to enable telemetry. |
+
+Standard OpenTelemetry environment variables (`OTEL_*`) are passed through to the SDK.
+
+
+## Built-in Spans
+
+
+When telemetry is enabled, the runtime automatically creates the following trace spans:
+
+### HTTP Request Span
+
+**`HTTP Request`** — Root span for every incoming HTTP request. Extracts W3C TraceContext from incoming headers and injects trace context into response headers.
+
+| Attribute | Description |
+| --- | --- |
+| `http.method` | HTTP method (GET, POST, etc.) |
+| `http.url` | Full request URL |
+| `http.target` | Request path |
+| `http.host` | Host header |
+| `http.scheme` | Protocol (http/https) |
+| `http.user_agent` | User-Agent header |
+| `http.status_code` | Response status code (set after response) |
+| `http.response_content_type` | Response Content-Type (set after response) |
+| `net.peer.ip` | Client IP address |
+
+### Middleware Spans
+
+**`Middleware: {displayName}`** — One span per middleware in the compose chain. Each span measures only the middleware's own work — calling `next()` ends the span before the next middleware runs.
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.middleware.index` | Position in the middleware chain (0-based) |
+| `react_server.middleware.name` | Middleware function name |
+| `react_server.middleware.display_name` | Human-readable name (e.g. "CORS", "Static Files", "SSR Handler") |
+
+### Render Spans
+
+The renderer creates two nested **`Render`** spans per request:
+
+1. **RSC Render** — outer span wrapping the full RSC→SSR pipeline
+2. **SSR Render** — inner span (child of RSC) for HTML stream rendering
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.render_type` | `"RSC"` or `"SSR"` |
+| `react_server.outlet` | Outlet name or `"PAGE_ROOT"` |
+| `http.url` | Request URL |
+
+### Server Function Span
+
+**`Server Function`** — Span for each server function invocation.
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.server_function.id` | Function identifier |
+| `react_server.server_function.is_form` | Whether invoked via form submission |
+| `react_server.server_function.has_error` | Whether the function produced an error (set after execution) |
+
+### Cache Spans
+
+**`Cache Lookup`** — Span for each `useCache()` call. Dynamically renamed to **`Cache Hit`** or **`Cache Miss → Recompute`** based on the result.
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.cache.provider` | Cache provider name (or `"default"`) |
+| `react_server.cache.ttl` | TTL value (or `"Infinity"`) |
+| `react_server.cache.force` | Whether cache was force-refreshed |
+| `react_server.cache.hit` | `true` on hit, `false` on miss (set after lookup) |
+
+### Server Startup Span
+
+**`Server Startup`** — Span covering server initialization (both dev and production).
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.mode` | `"development"` or `"production"` |
+| `react_server.root` | Application root or `"file-router"` |
+
+### Vite Dev Server Init Span
+
+**`Vite Dev Server Init`** — Span for Vite dev server creation (development only).
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.vite.mode` | Vite mode |
+| `react_server.vite.force` | Whether dependency optimization was forced |
+
+### Vite Plugin Hook Spans
+
+**`Vite plugin [{pluginName}].{hookName}`** — In development, every Vite plugin hook (`resolveId`, `load`, `transform`, `buildStart`, `buildEnd`, `handleHotUpdate`) is automatically instrumented.
+
+| Attribute | Description |
+| --- | --- |
+| `react_server.vite.plugin` | Plugin name |
+| `react_server.vite.hook` | Hook name |
+| `react_server.vite.module_id` | Module being processed (for `resolveId`, `load`, `transform`) |
+
+
+## Built-in Metrics
+
+
+The following metrics are automatically recorded:
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `http.server.request.duration` | Histogram (ms) | Duration of HTTP requests |
+| `http.server.active_requests` | UpDownCounter | Number of in-flight HTTP requests |
+| `react_server.server_function.duration` | Histogram (ms) | Duration of server function execution |
+| `react_server.rsc.render.duration` | Histogram (ms) | Duration of RSC rendering |
+| `react_server.dom.render.duration` | Histogram (ms) | Duration of SSR DOM rendering |
+| `react_server.cache.hits` | Counter | Number of cache hits |
+| `react_server.cache.misses` | Counter | Number of cache misses |
+
+
+## Telemetry API
+
+
+Import from `@lazarv/react-server/telemetry` to extend built-in telemetry with custom spans and metrics in your server components, server functions, or middleware.
+
+### `withSpan(name, attributes?, fn)`
+
+Execute a function within a child span:
+
+```js
+import { withSpan } from "@lazarv/react-server/telemetry";
+
+export async function fetchProducts() {
+ return withSpan("db.query", { "db.system": "postgres" }, async (span) => {
+ const rows = await db.query("SELECT * FROM products");
+ span.setAttribute("db.row_count", rows.length);
+ return rows;
+ });
+}
+```
+
+### `getSpan()`
+
+Get the current request span to add attributes or events:
+
+```js
+import { getSpan } from "@lazarv/react-server/telemetry";
+
+export function MyComponent() {
+ const span = getSpan();
+ span.addEvent("component.render", { component: "MyComponent" });
+ // ...
+}
+```
+
+### `getTracer()`
+
+Get the active OpenTelemetry tracer for manual span creation:
+
+```js
+import { getTracer } from "@lazarv/react-server/telemetry";
+
+const tracer = getTracer();
+const span = tracer.startSpan("custom.operation");
+try {
+ // ... your code
+} finally {
+ span.end();
+}
+```
+
+### `getMeter()`
+
+Get the active OpenTelemetry meter for custom metrics:
+
+```js
+import { getMeter } from "@lazarv/react-server/telemetry";
+
+const meter = getMeter();
+const counter = meter.createCounter("my_app.api_calls", {
+ description: "Number of external API calls",
+});
+counter.add(1, { "api.name": "stripe" });
+```
+
+### `getOtelContext()`
+
+Get the OTel context for the current request. Useful for advanced propagation scenarios:
+
+```js
+import { getOtelContext } from "@lazarv/react-server/telemetry";
+
+const ctx = getOtelContext();
+```
+
+### `injectTraceContext(headers)`
+
+Inject W3C trace context into outgoing headers for distributed tracing across services:
+
+```js
+import { injectTraceContext } from "@lazarv/react-server/telemetry";
+
+const headers = new Headers();
+await injectTraceContext(headers);
+const res = await fetch("https://api.example.com/data", { headers });
+```
+
+
+## Trace Propagation
+
+
+The runtime automatically:
+
+1. **Extracts** W3C TraceContext headers (`traceparent`, `tracestate`) from incoming requests
+2. **Propagates** context through the middleware chain, SSR handler, and server functions
+3. **Injects** trace context into outgoing response headers
+
+This means traces from upstream services (API gateways, load balancers) are automatically correlated with react-server traces, and downstream services can continue the trace.
+
+
+## Dev Console Exporter
+
+
+In development, when you enable telemetry without setting an OTLP endpoint, the runtime uses a pretty-printed console exporter that renders a compact trace tree in your terminal:
+
+```
+ GET /about 200 45.2ms
+ ├─ Middleware: CORS ░ 0.3ms
+ ├─ Middleware: Cookies ░ 0.1ms
+ ├─ Middleware: SSR Handler ░░░░░░ 42.1ms
+ │ ├─ Render RSC ░░░░ 18.3ms
+ │ └─ Render SSR ░░░░░ 22.4ms
+ ├─ Vite plugin [vite:resolve].resolveId: ×47 ░ 3.2ms
+ ├─ Vite plugin [vite:css].transform: ×12 ░ 1.1ms
+ └─ 8 spans (<1ms)
+```
+
+Features:
+- **Color-coded durations**: green (< 20ms), yellow (20–100ms), red (> 100ms)
+- **Proportional timing bars** showing relative duration within each trace
+- **Hierarchical tree** using span parent-child relationships
+- **Grouped Vite spans**: fast (green) Vite plugin hook spans are grouped by name with a count; slow or errored spans are shown individually
+- **Collapsed micro-spans**: spans shorter than 1ms are summarized in a single line
+
+To use the dev console exporter explicitly:
+
+```js
+export default {
+ telemetry: {
+ enabled: true,
+ exporter: "dev-console",
+ },
+};
+```
+
+
+## Production Setup
+
+
+### Jaeger
+
+Run Jaeger locally with OTLP support:
+
+```bash
+docker run -d --name jaeger \
+ -p 16686:16686 \
+ -p 4318:4318 \
+ jaegertracing/all-in-one:latest
+```
+
+Then enable telemetry:
+
+```bash
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm react-server start
+```
+
+Open `http://localhost:16686` to view traces.
+
+### Grafana / Tempo
+
+Configure your OTLP endpoint in your config:
+
+```js
+export default {
+ telemetry: {
+ enabled: true,
+ endpoint: "https://tempo.grafana.net/otlp",
+ serviceName: "my-production-app",
+ },
+};
+```
+
+### Honeycomb / Datadog / New Relic
+
+Most observability platforms support OTLP ingestion. Set the endpoint and any required headers via environment variables:
+
+```bash
+OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
+OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=your-api-key"
+```
+
+
+## Edge Runtime
+
+
+Telemetry is also supported in edge runtimes (Cloudflare Workers, Netlify Edge Functions, etc.) with a lightweight tracer. Due to platform constraints, only traces are supported in edge — metrics are not available.
+
+When building for edge, the bundler automatically handles OpenTelemetry packages:
+- **Packages installed** → bundled into the worker, OTLP export or console fallback
+- **Packages not installed** → resolved to empty modules, zero overhead
+
+The edge telemetry uses `BasicTracerProvider` with `SimpleSpanProcessor` and attempts to use the OTLP HTTP exporter, falling back to console output when it's not available.
+
+
+## Zero Overhead When Disabled
+
+
+When telemetry is not enabled:
+
+- No OpenTelemetry packages are loaded
+- All span and metric operations resolve to no-op objects
+- The `withSpan()` helper simply calls your function directly
+- `getTracer()` and `getMeter()` return no-op instances that discard all data
+
+This ensures there is no performance impact on applications that don't use telemetry.
diff --git a/docs/src/pages/en/features.(index).mdx b/docs/src/pages/en/features.(index).mdx
index ebdabc0d..292064f5 100644
--- a/docs/src/pages/en/features.(index).mdx
+++ b/docs/src/pages/en/features.(index).mdx
@@ -17,3 +17,5 @@ You can also learn about some small, but useful modes in this section, like [par
You can learn about how to implement a micro-frontend architecture in the [micro-frontends](/features/micro-frontends) section. The runtime provides a set of tools to help you implement micro-frontends in your app. You can use the `RemoteComponent` component to load a micro-frontend from a remote URL and render it in your app using server-side rendering. Server-side rendering supported `iframe` fragments for React applications!
The runtime also provides [Workers](/features/worker) to offload heavy computations to separate threads using the `"use worker"` directive. On the server, worker functions run in Node.js Worker Threads, and on the client, they run in Web Workers — all with transparent RSC-based serialization. Workers can return plain values, React elements with Suspense, ReadableStreams, and deferred Promises.
+
+For production readiness, the runtime includes built-in [Observability](/features/observability) via OpenTelemetry integration. When enabled, it automatically instruments HTTP requests, SSR rendering, server actions, and middleware with distributed traces and metrics — with zero overhead when disabled.
diff --git a/examples/hello-world/package.json b/examples/hello-world/package.json
index 499a2d6d..eb53c288 100644
--- a/examples/hello-world/package.json
+++ b/examples/hello-world/package.json
@@ -12,6 +12,15 @@
"test-env": "node -e \"console.log(process.env._)\""
},
"dependencies": {
- "@lazarv/react-server": "workspace:^"
+ "@lazarv/react-server": "workspace:^",
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/core": "^2.0.0",
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
+ "@opentelemetry/resources": "^2.0.0",
+ "@opentelemetry/sdk-metrics": "^2.0.0",
+ "@opentelemetry/sdk-node": "^0.200.0",
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
+ "@opentelemetry/semantic-conventions": "^1.28.0"
}
}
diff --git a/examples/hello-world/react-server.config.mjs b/examples/hello-world/react-server.config.mjs
new file mode 100644
index 00000000..bcd7d0bc
--- /dev/null
+++ b/examples/hello-world/react-server.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ telemetry: {
+ enabled: true,
+ },
+};
diff --git a/package.json b/package.json
index 0096cc02..ac7c84ff 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"oxlint --fix"
]
},
- "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a",
+ "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"pnpm": {
"overrides": {
"react-click-away-listener>react": "0.0.0-experimental-3bc2d414-20260304",
@@ -81,6 +81,17 @@
"supertest>superagent": "9.0.2",
"browserslist": "4.28.1",
"caniuse-lite": "1.0.30001761"
- }
+ },
+ "onlyBuiltDependencies": [
+ "@nestjs/core",
+ "@parcel/watcher",
+ "@swc/core",
+ "@vercel/speed-insights",
+ "better-sqlite3",
+ "esbuild",
+ "playwright-chromium",
+ "protobufjs",
+ "simple-git-hooks"
+ ]
}
}
diff --git a/packages/react-server/cache/index.mjs b/packages/react-server/cache/index.mjs
index 6c788d14..13dcaa72 100644
--- a/packages/react-server/cache/index.mjs
+++ b/packages/react-server/cache/index.mjs
@@ -16,6 +16,7 @@ import {
MEMORY_CACHE_CONTEXT,
PRERENDER_CACHE,
} from "../server/symbols.mjs";
+import { getTracer, getOtelContext } from "../server/telemetry.mjs";
export { StorageCache, memoryDriver as default, CACHE_MISS };
@@ -88,6 +89,22 @@ export async function useCache(
}
const key = cache.rawCanonicalKey(keys);
+ const providerName = provider?.name ?? "default";
+
+ // ── Telemetry: cache operation span (all calls are synchronous) ──
+ const tracer = getTracer();
+ const parentCtx = getOtelContext();
+ const cacheSpan = tracer.startSpan(
+ "Cache Lookup",
+ {
+ attributes: {
+ "react_server.cache.provider": providerName,
+ "react_server.cache.ttl": ttl === Infinity ? "Infinity" : String(ttl),
+ "react_server.cache.force": force,
+ },
+ },
+ parentCtx ?? undefined
+ );
// HACK: concurrency workaround to avoid race condition on the lock
await new Promise((resolve) => setImmediate(resolve));
@@ -105,6 +122,8 @@ export async function useCache(
result = await cache.get(keys);
if (force || result === CACHE_MISS) {
+ cacheSpan.setAttribute("react_server.cache.hit", false);
+ cacheSpan.updateName("Cache Miss → Recompute");
let value = promise;
if (typeof import.meta.env !== "undefined" && import.meta.env.DEV) {
@@ -133,6 +152,9 @@ export async function useCache(
ttl: ttl ?? Infinity,
provider,
});
+ } else {
+ cacheSpan.setAttribute("react_server.cache.hit", true);
+ cacheSpan.updateName("Cache Hit");
}
lock.delete(key);
@@ -140,9 +162,12 @@ export async function useCache(
} catch (e) {
lock.delete(key);
release?.();
+ cacheSpan.setStatus({ code: 2, message: e?.message });
+ cacheSpan.recordException(e);
error = e;
}
+ cacheSpan.end();
if (error) throw error;
return result;
}
diff --git a/packages/react-server/config/schema.d.ts b/packages/react-server/config/schema.d.ts
index ef563070..b6db590b 100644
--- a/packages/react-server/config/schema.d.ts
+++ b/packages/react-server/config/schema.d.ts
@@ -665,6 +665,59 @@ export interface ServerFunctionsConfig {
previousSecretFiles?: string[];
}
+// ───── Telemetry config ─────
+
+export interface TelemetryMetricsConfig {
+ /**
+ * Enable/disable metrics collection.
+ * @default true (when telemetry is enabled)
+ */
+ enabled?: boolean;
+
+ /**
+ * Metrics export interval in milliseconds.
+ * @default 30000
+ */
+ interval?: number;
+}
+
+export interface TelemetryConfig {
+ /**
+ * Enable/disable telemetry.
+ * Also enabled by OTEL_EXPORTER_OTLP_ENDPOINT or REACT_SERVER_TELEMETRY env vars.
+ */
+ enabled?: boolean;
+
+ /**
+ * Service name reported to your observability backend.
+ * @default package name or "@lazarv/react-server"
+ */
+ serviceName?: string;
+
+ /**
+ * OTLP collector endpoint.
+ * @default "http://localhost:4318"
+ */
+ endpoint?: string;
+
+ /**
+ * Exporter type.
+ * @default auto-detected
+ */
+ exporter?: "otlp" | "console" | "dev-console";
+
+ /**
+ * Sampling rate 0.0–1.0.
+ * @default 1.0
+ */
+ sampleRate?: number;
+
+ /**
+ * Metrics sub-configuration.
+ */
+ metrics?: TelemetryMetricsConfig;
+}
+
// ───── MDX config ─────
export interface MdxConfig {
@@ -1006,6 +1059,13 @@ export interface ReactServerConfig {
*/
serverFunctions?: ServerFunctionsConfig;
+ /**
+ * OpenTelemetry observability configuration.
+ * When enabled, the runtime instruments HTTP requests, rendering, server functions, and middleware.
+ * @example `telemetry: { enabled: true, serviceName: "my-app" }`
+ */
+ telemetry?: TelemetryConfig;
+
/**
* File-router layout configuration.
* @example `layout: { include: ["layout.jsx"] }`
diff --git a/packages/react-server/config/schema.json b/packages/react-server/config/schema.json
index 4d569b83..104d48f0 100644
--- a/packages/react-server/config/schema.json
+++ b/packages/react-server/config/schema.json
@@ -1123,6 +1123,51 @@
},
"additionalProperties": false,
"description": "MDX processing configuration."
+ },
+ "telemetry": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Enable/disable telemetry. Also enabled by OTEL_EXPORTER_OTLP_ENDPOINT or REACT_SERVER_TELEMETRY env vars."
+ },
+ "serviceName": {
+ "type": "string",
+ "description": "Service name reported to your observability backend. Default: package name or \"@lazarv/react-server\"."
+ },
+ "endpoint": {
+ "type": "string",
+ "description": "OTLP collector endpoint. Default: \"http://localhost:4318\"."
+ },
+ "exporter": {
+ "enum": ["otlp", "console", "dev-console"],
+ "description": "Exporter type: \"otlp\" | \"console\" | \"dev-console\". Default: auto-detected."
+ },
+ "sampleRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Sampling rate 0.0–1.0. Default: 1.0 (sample everything)."
+ },
+ "metrics": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Enable/disable metrics collection. Default: true when telemetry is enabled."
+ },
+ "interval": {
+ "type": "integer",
+ "minimum": 1000,
+ "description": "Metrics export interval in milliseconds. Default: 30000."
+ }
+ },
+ "additionalProperties": false,
+ "description": "Metrics sub-configuration."
+ }
+ },
+ "additionalProperties": false,
+ "description": "OpenTelemetry observability configuration. When enabled, the runtime instruments HTTP requests, rendering, server functions, and middleware."
}
},
"additionalProperties": false
diff --git a/packages/react-server/config/schema.mjs b/packages/react-server/config/schema.mjs
index 0ad8a6c0..aa641700 100644
--- a/packages/react-server/config/schema.mjs
+++ b/packages/react-server/config/schema.mjs
@@ -224,6 +224,25 @@ export const DESCRIPTIONS = {
"mdx.remarkPlugins": "Remark plugins for MDX processing.",
"mdx.rehypePlugins": "Rehype plugins for MDX processing.",
"mdx.components": "Path to the MDX components file.",
+
+ // telemetry.*
+ telemetry:
+ "OpenTelemetry observability configuration. When enabled, the runtime instruments HTTP requests, rendering, server functions, and middleware.",
+ "telemetry.enabled":
+ "Enable/disable telemetry. Also enabled by OTEL_EXPORTER_OTLP_ENDPOINT or REACT_SERVER_TELEMETRY env vars.",
+ "telemetry.serviceName":
+ 'Service name reported to your observability backend. Default: package name or "@lazarv/react-server".',
+ "telemetry.endpoint":
+ 'OTLP collector endpoint. Default: "http://localhost:4318".',
+ "telemetry.exporter":
+ 'Exporter type: "otlp" | "console" | "dev-console". Default: auto-detected.',
+ "telemetry.sampleRate":
+ "Sampling rate 0.0\u20131.0. Default: 1.0 (sample everything).",
+ "telemetry.metrics": "Metrics sub-configuration.",
+ "telemetry.metrics.enabled":
+ "Enable/disable metrics collection. Default: true when telemetry is enabled.",
+ "telemetry.metrics.interval":
+ "Metrics export interval in milliseconds. Default: 30000.",
};
// ───── JSON Schema generator ─────
@@ -885,6 +904,45 @@ export function generateJsonSchema() {
},
"mdx"
),
+
+ // ── telemetry.* ──
+ telemetry: prop(
+ {
+ type: "object",
+ properties: {
+ enabled: prop({ type: "boolean" }, "telemetry.enabled"),
+ serviceName: prop({ type: "string" }, "telemetry.serviceName"),
+ endpoint: prop({ type: "string" }, "telemetry.endpoint"),
+ exporter: prop(
+ { enum: ["otlp", "console", "dev-console"] },
+ "telemetry.exporter"
+ ),
+ sampleRate: prop(
+ { type: "number", minimum: 0, maximum: 1 },
+ "telemetry.sampleRate"
+ ),
+ metrics: prop(
+ {
+ type: "object",
+ properties: {
+ enabled: prop(
+ { type: "boolean" },
+ "telemetry.metrics.enabled"
+ ),
+ interval: prop(
+ { type: "integer", minimum: 1000 },
+ "telemetry.metrics.interval"
+ ),
+ },
+ additionalProperties: false,
+ },
+ "telemetry.metrics"
+ ),
+ },
+ additionalProperties: false,
+ },
+ "telemetry"
+ ),
},
additionalProperties: false,
};
diff --git a/packages/react-server/config/validate.mjs b/packages/react-server/config/validate.mjs
index 31c8a66d..656b95c6 100644
--- a/packages/react-server/config/validate.mjs
+++ b/packages/react-server/config/validate.mjs
@@ -441,6 +441,27 @@ const REACT_SERVER_SCHEMA = {
})
),
+ // ── telemetry.* ──
+ telemetry: optional(
+ objectShape({
+ enabled: optional(is.boolean),
+ serviceName: optional(is.string),
+ endpoint: optional(is.string),
+ exporter: optional(enumOf("otlp", "console", "dev-console")),
+ sampleRate: optional(
+ custom((v) => is.number(v) && v >= 0 && v <= 1, "number (0 – 1)")
+ ),
+ metrics: optional(
+ objectShape({
+ enabled: optional(is.boolean),
+ interval: optional(
+ custom((v) => Number.isInteger(v) && v >= 1000, "integer (>= 1000)")
+ ),
+ })
+ ),
+ })
+ ),
+
// ── File-router child config ──
layout: optional(oneOf(is.object, is.function)),
page: optional(oneOf(is.object, is.function)),
@@ -582,6 +603,15 @@ const EXAMPLES = {
serverFunctions: `serverFunctions: { secret: "my-secret-key" }`,
"serverFunctions.secret": `serverFunctions: { secret: "my-secret-key" }`,
"serverFunctions.secretFile": `serverFunctions: { secretFile: "./secret.pem" }`,
+ telemetry: `telemetry: { enabled: true, serviceName: "my-app" }`,
+ "telemetry.enabled": `telemetry: { enabled: true }`,
+ "telemetry.serviceName": `telemetry: { serviceName: "my-app" }`,
+ "telemetry.endpoint": `telemetry: { endpoint: "http://localhost:4318" }`,
+ "telemetry.exporter": `telemetry: { exporter: "otlp" } // or "dev-console" or "console"`,
+ "telemetry.sampleRate": `telemetry: { sampleRate: 1.0 }`,
+ "telemetry.metrics": `telemetry: { metrics: { enabled: true, interval: 30000 } }`,
+ "telemetry.metrics.enabled": `telemetry: { metrics: { enabled: true } }`,
+ "telemetry.metrics.interval": `telemetry: { metrics: { interval: 30000 } }`,
mdx: `mdx: { remarkPlugins: [...], rehypePlugins: [...] }`,
"mdx.remarkPlugins": `mdx: { remarkPlugins: [remarkGfm] }`,
"mdx.rehypePlugins": `mdx: { rehypePlugins: [rehypeHighlight] }`,
diff --git a/packages/react-server/lib/build/edge.mjs b/packages/react-server/lib/build/edge.mjs
index 8249cf3d..5dee6c0b 100644
--- a/packages/react-server/lib/build/edge.mjs
+++ b/packages/react-server/lib/build/edge.mjs
@@ -6,7 +6,9 @@ import replace from "@rollup/plugin-replace";
import { build as viteBuild } from "vite";
import { forRoot } from "../../config/index.mjs";
+import { resolveTelemetryConfig } from "../../server/telemetry.mjs";
+import optionalDeps from "../plugins/optional-deps.mjs";
import * as sys from "../sys.mjs";
import customLogger from "./custom-logger.mjs";
import { fileListingReporterPlugin } from "./output-filter.mjs";
@@ -28,6 +30,11 @@ const cwd = sys.cwd();
export default async function edgeBuild(root, options) {
const config = forRoot();
+ // When telemetry is disabled at build time, force-empty all @opentelemetry/*
+ // packages so they are excluded from the edge bundle entirely.
+ const telemetryEnabled = resolveTelemetryConfig(config) !== null;
+ const otelForceEmpty = telemetryEnabled ? [] : [/^@opentelemetry\//];
+
const viteConfigEdge = {
root: cwd,
configFile: false,
@@ -170,9 +177,13 @@ export default async function edgeBuild(root, options) {
if (id.startsWith("node:") || /manifest\.json/.test(id)) {
return true;
}
+ // @opentelemetry/* is NOT externalized — edge runtimes have no
+ // node_modules. The optionalDeps plugin with forceEmpty resolves
+ // them to empty modules when telemetry is disabled.
return false;
},
plugins: [
+ optionalDeps([/^@opentelemetry\//], { forceEmpty: otelForceEmpty }),
replace({
preventAssignment: true,
"import.meta.url": JSON.stringify("file:///C:/worker.mjs"),
@@ -287,6 +298,7 @@ export default async function edgeBuild(root, options) {
},
},
plugins: [
+ optionalDeps([/^@opentelemetry\//], { forceEmpty: otelForceEmpty }),
{
name: "react-server:edge",
enforce: "pre",
diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs
index e8eb2e4c..f558c6ff 100644
--- a/packages/react-server/lib/build/server.mjs
+++ b/packages/react-server/lib/build/server.mjs
@@ -8,8 +8,10 @@ import glob from "fast-glob";
import { build as viteBuild } from "vite";
import { forRoot } from "../../config/index.mjs";
+import { resolveTelemetryConfig } from "../../server/telemetry.mjs";
import configPrebuilt from "../plugins/config-prebuilt.mjs";
import fileRouter from "../plugins/file-router/plugin.mjs";
+import optionalDeps from "../plugins/optional-deps.mjs";
import fixEsbuildOptionsPlugin from "../plugins/fix-esbuildoptions.mjs";
import importRemotePlugin from "../plugins/import-remote.mjs";
@@ -76,6 +78,12 @@ export default async function serverBuild(root, options, clientManifestBus) {
}
const config = forRoot();
+
+ // When telemetry is disabled at build time, force-empty all @opentelemetry/*
+ // packages so they are excluded from edge bundles entirely.
+ const telemetryEnabled = resolveTelemetryConfig(config) !== null;
+ const otelForceEmpty = telemetryEnabled ? [] : [/^@opentelemetry\//];
+
const clientManifest = new Map();
const serverManifest = new Map();
const buildPlugins = [
@@ -151,6 +159,7 @@ export default async function serverBuild(root, options, clientManifestBus) {
"@lazarv/react-server/storage-cache",
"@lazarv/react-server/http-context",
/^@lazarv\/react-server\/dist\//,
+ /^@opentelemetry\//,
]);
const ssrExternal = createExternal([
/manifest\.json/,
@@ -162,9 +171,12 @@ export default async function serverBuild(root, options, clientManifestBus) {
"@lazarv/react-server/storage-cache",
"@lazarv/react-server/http-context",
/^@lazarv\/react-server\/dist\//,
+ /^@opentelemetry\//,
]);
- // Edge external - only externalize node builtins, bundle everything else
+ // Edge external - only externalize node builtins, bundle everything else.
+ // When telemetry is disabled, also externalize @opentelemetry/* so the
+ // bundler never traces their (heavy) dependency trees.
const edgeExternal = (id, parentId, isResolved) => {
if (isBuiltin(id)) {
return true;
@@ -177,6 +189,10 @@ export default async function serverBuild(root, options, clientManifestBus) {
if (/^@lazarv\/react-server\/dist\//.test(id)) {
return true;
}
+ // Externalize @opentelemetry/* when telemetry is disabled
+ if (!telemetryEnabled && /^@opentelemetry\//.test(id)) {
+ return true;
+ }
return false;
};
@@ -710,6 +726,13 @@ export default async function serverBuild(root, options, clientManifestBus) {
);
},
plugins: [
+ ...(options.edge
+ ? [
+ optionalDeps([/^@opentelemetry\//], {
+ forceEmpty: otelForceEmpty,
+ }),
+ ]
+ : []),
manifestRegistry(),
resolveWorkspace(),
...(options.edge ? [preloadManifestVirtual(options)] : []),
@@ -996,6 +1019,13 @@ export default async function serverBuild(root, options, clientManifestBus) {
? ssrExternal
: external,
plugins: [
+ ...(options.edge
+ ? [
+ optionalDeps([/^@opentelemetry\//], {
+ forceEmpty: otelForceEmpty,
+ }),
+ ]
+ : []),
manifestRegistry(),
resolveWorkspace(),
...(options.edge ? [preloadManifestVirtual(options)] : []),
diff --git a/packages/react-server/lib/dev/create-server.mjs b/packages/react-server/lib/dev/create-server.mjs
index aef773a5..ba4959b9 100644
--- a/packages/react-server/lib/dev/create-server.mjs
+++ b/packages/react-server/lib/dev/create-server.mjs
@@ -72,6 +72,13 @@ import { getServerCors } from "../utils/server-config.mjs";
import createLogger from "./create-logger.mjs";
import { HybridEvaluator } from "./hybrid-evaluator.mjs";
import ssrHandler from "./ssr-handler.mjs";
+import telemetryHooks from "../plugins/telemetry-hooks.mjs";
+import {
+ resolveTelemetryConfig,
+ initTelemetry,
+ shutdownTelemetry,
+ getTracer,
+} from "../../server/telemetry.mjs";
const cwd = sys.cwd();
const workspaceRoot = findPackageRoot(join(cwd, "..")) ?? cwd;
@@ -82,6 +89,20 @@ export default async function createServer(root, options) {
options.outDir = ".react-server";
}
const config = getRuntime(CONFIG_CONTEXT)?.[CONFIG_ROOT];
+
+ // ── Telemetry: initialize OpenTelemetry SDK ──
+ const telemetryConfig = resolveTelemetryConfig(config);
+ await initTelemetry(telemetryConfig);
+
+ // ── Telemetry: server startup span ──
+ const startupTracer = getTracer();
+ const startupSpan = startupTracer.startSpan("Server Startup", {
+ attributes: {
+ "react_server.mode": "development",
+ "react_server.root": root || "file-router",
+ },
+ });
+
let worker = null;
if (sys.isDeno) {
const { renderProcessSpawn } = await import("./render-process-spawn.mjs");
@@ -248,6 +269,7 @@ export default async function createServer(root, options) {
asset(),
optimizeDeps(),
reactServerLive(options.httpServer, config),
+ ...(telemetryConfig ? [telemetryHooks()] : []),
],
cacheDir:
config.cacheDir ||
@@ -499,7 +521,15 @@ export default async function createServer(root, options) {
}
}
+ // ── Telemetry: Vite dev server creation span ──
+ const viteCreateSpan = startupTracer.startSpan("Vite Dev Server Init", {
+ attributes: {
+ "react_server.vite.mode": options.mode || "development",
+ "react_server.vite.force": !!options.force,
+ },
+ });
const viteDevServer = await createViteDevServer(viteConfig);
+ viteCreateSpan.end();
if (config.envDir !== false) {
if (globalThis.__react_server_prev_env_keys__) {
@@ -925,7 +955,7 @@ export default async function createServer(root, options) {
});
const initialHandlers = await Promise.all([
- async (context) => {
+ async function devTooling(context) {
if (context.url.pathname === "/__react_server_console__") {
try {
await handleClientConsole(context.request.body);
@@ -973,7 +1003,7 @@ export default async function createServer(root, options) {
}
if (config.base) {
- initialHandlers.unshift(async (context) => {
+ initialHandlers.unshift(async function basePathStrip(context) {
if (context.url.pathname.startsWith(config.base)) {
context.url.pathname =
context.url.pathname.slice(config.base.length) || "/";
@@ -1002,6 +1032,9 @@ export default async function createServer(root, options) {
})
);
+ // ── Telemetry: end startup span ──
+ startupSpan.end();
+
const localHostnames = new Set([
"localhost",
"127.0.0.1",
@@ -1018,6 +1051,7 @@ export default async function createServer(root, options) {
});
},
close: () => {
+ shutdownTelemetry();
viteDevServer.close();
},
ws: viteDevServer.environments.client.hot,
diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs
index eb837f27..6092585e 100644
--- a/packages/react-server/lib/dev/ssr-handler.mjs
+++ b/packages/react-server/lib/dev/ssr-handler.mjs
@@ -24,6 +24,8 @@ import {
MAIN_MODULE,
MEMORY_CACHE_CONTEXT,
MODULE_LOADER,
+ OTEL_SPAN,
+ OTEL_CONTEXT,
REDIRECT_CONTEXT,
RENDER,
RENDER_CONTEXT,
@@ -52,7 +54,7 @@ export default async function ssrHandler(root) {
const renderStream = createWorker();
const moduleCacheStorage = new AsyncLocalStorage();
- return async (httpContext) => {
+ return async function ssr(httpContext) {
return new Promise((resolve, reject) => {
try {
const noCache =
@@ -81,6 +83,9 @@ export default async function ssrHandler(root) {
[COLLECT_STYLESHEETS]: collectStylesheets,
[ACTION_CONTEXT]: {},
[RENDER_STREAM]: renderStream,
+ // Propagate OTel span from the HTTP layer
+ [OTEL_SPAN]: httpContext._otelSpan ?? null,
+ [OTEL_CONTEXT]: httpContext._otelCtx ?? null,
},
async () => {
try {
@@ -180,7 +185,8 @@ export default async function ssrHandler(root) {
};
context$(RENDER_HANDLER, handler);
- return resolve(await handler());
+ const result = await handler();
+ return resolve(result);
} catch (e) {
logger.error(e);
return errorHandler(e).then(resolve, reject);
diff --git a/packages/react-server/lib/handlers/not-found.mjs b/packages/react-server/lib/handlers/not-found.mjs
index 881279b4..230764ef 100644
--- a/packages/react-server/lib/handlers/not-found.mjs
+++ b/packages/react-server/lib/handlers/not-found.mjs
@@ -1,5 +1,5 @@
export default async function notFoundHandler() {
- return async () => {
+ return async function notFound() {
return new Response("Not Found", { status: 404 });
};
}
diff --git a/packages/react-server/lib/handlers/static.mjs b/packages/react-server/lib/handlers/static.mjs
index 634f5540..0a2aa229 100644
--- a/packages/react-server/lib/handlers/static.mjs
+++ b/packages/react-server/lib/handlers/static.mjs
@@ -45,7 +45,7 @@ export default async function staticHandler(dir, options = {}) {
const fileCache = new Map();
- return async (context) => {
+ return async function serveStatic(context) {
if (context.request.method !== "GET") {
return;
}
diff --git a/packages/react-server/lib/handlers/trailing-slash.mjs b/packages/react-server/lib/handlers/trailing-slash.mjs
index c3f9c9a8..fa11324e 100644
--- a/packages/react-server/lib/handlers/trailing-slash.mjs
+++ b/packages/react-server/lib/handlers/trailing-slash.mjs
@@ -1,5 +1,8 @@
export default async function trailingSlash() {
- return async ({ url: { pathname }, request: { method } }) => {
+ return async function trailingSlashRedirect({
+ url: { pathname },
+ request: { method },
+ }) {
if (
pathname !== "/" &&
pathname.endsWith("/") &&
diff --git a/packages/react-server/lib/http/middleware.mjs b/packages/react-server/lib/http/middleware.mjs
index c16fb390..428ac39a 100644
--- a/packages/react-server/lib/http/middleware.mjs
+++ b/packages/react-server/lib/http/middleware.mjs
@@ -7,6 +7,11 @@ import { compose } from "./middlewares/compose.mjs";
import { ContextStorage } from "../../server/context.mjs";
import { getRuntime } from "../../server/runtime.mjs";
import { AFTER_CONTEXT, LOGGER_CONTEXT } from "../../server/symbols.mjs";
+import {
+ getMetrics,
+ startRequestSpan,
+ injectTraceContext,
+} from "../../server/telemetry.mjs";
export function createContext(
request,
@@ -46,6 +51,8 @@ export function createMiddleware(handler, options = {}) {
const { origin, trustProxy = false, defaultNotFound = false } = options;
const run = normalizeHandler(handler);
return async function nodeAdapter(req, res, next) {
+ let ctx;
+ let metrics;
try {
const headersObj = req.headers || {};
const xfProto = headerFirst(headersObj["x-forwarded-proto"]);
@@ -94,7 +101,7 @@ export function createMiddleware(handler, options = {}) {
const request = new Request(fullUrl, requestInit);
const abortController = new AbortController();
const { signal } = abortController;
- const ctx = createContext(request, {
+ ctx = createContext(request, {
origin,
runtime: "node",
signal,
@@ -111,6 +118,28 @@ export function createMiddleware(handler, options = {}) {
ctx.ip = ip;
ctx.host = host;
ctx.protocol = protocol;
+
+ // ── Telemetry: start root HTTP span ──
+ const requestStart = performance.now();
+ metrics = getMetrics();
+ metrics?.httpActiveRequests.add(1, { "http.method": req.method });
+
+ const { span: rootSpan, otelCtx } = await startRequestSpan(
+ `HTTP Request`,
+ headersObj,
+ {
+ "http.method": req.method,
+ "http.url": fullUrl,
+ "http.target": req.url,
+ "http.host": host,
+ "http.scheme": protocol,
+ "http.user_agent": headersObj["user-agent"] || "",
+ "net.peer.ip": ip || "",
+ }
+ );
+ ctx._otelSpan = rootSpan;
+ ctx._otelCtx = otelCtx;
+
let response = await run(ctx);
if (!response) {
if (defaultNotFound && !next)
@@ -123,6 +152,15 @@ export function createMiddleware(handler, options = {}) {
if (ctx._setCookies?.length)
for (const c of ctx._setCookies)
response.headers.append("set-cookie", c);
+
+ // ── Telemetry: record response attributes ──
+ rootSpan.setAttribute("http.status_code", response.status);
+ rootSpan.setAttribute(
+ "http.response_content_type",
+ response.headers.get("content-type") || ""
+ );
+ await injectTraceContext(response.headers);
+
res.statusCode = response.status;
for (const [k, v] of response.headers.entries()) res.setHeader(k, v);
if (req.method === "HEAD" || !response.body) {
@@ -176,6 +214,16 @@ export function createMiddleware(handler, options = {}) {
req.off("aborted", onDisconnect);
}
+ // ── Telemetry: finish root span and record metrics ──
+ const duration = performance.now() - requestStart;
+ rootSpan.end();
+ metrics?.httpActiveRequests.add(-1, { "http.method": req.method });
+ metrics?.httpRequestDuration.record(duration, {
+ "http.method": req.method,
+ "http.status_code": res.statusCode,
+ "http.route": req.url,
+ });
+
try {
const { afterHooks } = ctx;
if (afterHooks) {
@@ -194,6 +242,20 @@ export function createMiddleware(handler, options = {}) {
logger.error(e);
}
} catch (e) {
+ // ── Telemetry: record error on root span ──
+ if (ctx?._otelSpan) {
+ try {
+ ctx._otelSpan.setStatus({
+ code: 2 /* SpanStatusCode.ERROR */,
+ message: e?.message,
+ });
+ ctx._otelSpan.recordException(e);
+ ctx._otelSpan.end();
+ metrics?.httpActiveRequests.add(-1, { "http.method": req.method });
+ } catch {
+ // no-op if OTel not available
+ }
+ }
if (e.name !== "AbortError" && e.message !== "aborted") {
if (next) next(e);
else internalError(res, e);
diff --git a/packages/react-server/lib/http/middlewares/compose.mjs b/packages/react-server/lib/http/middlewares/compose.mjs
index 57b4b4a3..3dfe5ce9 100644
--- a/packages/react-server/lib/http/middlewares/compose.mjs
+++ b/packages/react-server/lib/http/middlewares/compose.mjs
@@ -1,3 +1,28 @@
+import {
+ getTracer,
+ getOtelContext,
+ makeSpanContext,
+} from "../../../server/telemetry.mjs";
+
+// ── Human-readable middleware display names ──
+const MIDDLEWARE_DISPLAY_NAMES = {
+ devTooling: "Dev Tooling",
+ basePathStrip: "Base Path",
+ prerenderInit: "Prerender Init",
+ serveStatic: "Static Files",
+ trailingSlashRedirect: "Trailing Slash",
+ notFound: "Not Found Handler",
+ corsMiddleware: "CORS",
+ cookieMiddleware: "Cookies",
+ ssr: "SSR Handler",
+ composed: "Middleware Chain",
+};
+
+function middlewareDisplayName(fn, index) {
+ const raw = fn.name || `anonymous#${index}`;
+ return MIDDLEWARE_DISPLAY_NAMES[raw] ?? raw;
+}
+
export function compose(middlewares) {
if (!Array.isArray(middlewares))
throw new TypeError("middlewares must be an array");
@@ -15,9 +40,58 @@ export function compose(middlewares) {
const fn = middlewares[i];
if (!fn) return undefined;
context.next = () => dispatch(i + 1);
- const result = await fn(context);
- if (result === undefined) return context.next();
- return result;
+
+ // ── Telemetry: per-middleware span ──
+ const tracer = getTracer();
+ const parentCtx = context._otelCtx ?? getOtelContext();
+ const displayName = middlewareDisplayName(fn, i);
+ const span = tracer.startSpan(
+ `Middleware: ${displayName}`,
+ {
+ attributes: {
+ "react_server.middleware.index": i,
+ "react_server.middleware.name": fn.name || `anonymous#${i}`,
+ "react_server.middleware.display_name": displayName,
+ },
+ },
+ parentCtx ?? undefined
+ );
+ // Propagate middleware span as parent context so inner spans
+ // (e.g. RSC/SSR Render) nest under this middleware
+ const prevOtelCtx = context._otelCtx;
+ context._otelCtx = makeSpanContext(span, parentCtx);
+
+ // Wrap next() so the current middleware span ends before dispatching
+ // the next middleware. This ensures each span only measures its own work.
+ const originalNext = context.next;
+ let spanEnded = false;
+ context.next = () => {
+ if (!spanEnded) {
+ span.end();
+ spanEnded = true;
+ }
+ context._otelCtx = prevOtelCtx;
+ return originalNext();
+ };
+ try {
+ const result = await fn(context);
+ // If middleware returned without calling next(), end span here
+ if (!spanEnded) {
+ span.end();
+ spanEnded = true;
+ }
+ context._otelCtx = prevOtelCtx;
+ if (result === undefined) return context.next();
+ return result;
+ } catch (error) {
+ span.recordException(error);
+ if (!spanEnded) {
+ span.end();
+ spanEnded = true;
+ }
+ context._otelCtx = prevOtelCtx;
+ throw error;
+ }
}
return dispatch(0);
};
diff --git a/packages/react-server/lib/plugins/import-remote.mjs b/packages/react-server/lib/plugins/import-remote.mjs
index beb24748..ce7cd879 100644
--- a/packages/react-server/lib/plugins/import-remote.mjs
+++ b/packages/react-server/lib/plugins/import-remote.mjs
@@ -38,6 +38,10 @@ export default function(props) {
},
async handler(code, id) {
try {
+ if (!/from\s+["']https?:\/\//.test(code)) {
+ return null;
+ }
+
const ast = await parse(code, id);
let hasRemoteImport = false;
diff --git a/packages/react-server/lib/plugins/optimize-deps.mjs b/packages/react-server/lib/plugins/optimize-deps.mjs
index f7d5b382..241d9c73 100644
--- a/packages/react-server/lib/plugins/optimize-deps.mjs
+++ b/packages/react-server/lib/plugins/optimize-deps.mjs
@@ -8,10 +8,9 @@ import {
bareImportRE,
hasClientComponents,
hasClientComponentsAsync,
- isModule,
+ isESMSyntaxAsync,
isRootModule,
nodeResolve,
- readFileCachedAsync,
tryStat,
} from "../utils/module.mjs";
@@ -149,6 +148,15 @@ export { findPackagesWithClientComponents };
export default function optimizeDeps() {
let clientComponentPackages = null;
+ // Cache stat results to avoid repeated synchronous statSync syscalls
+ const statCache = new Map();
+ function isFileCached(filePath) {
+ if (statCache.has(filePath)) return statCache.get(filePath);
+ const result = tryStat(filePath)?.isFile() ?? false;
+ statCache.set(filePath, result);
+ return result;
+ }
+
return {
name: "react-server:optimize-deps",
enforce: "pre",
@@ -167,6 +175,9 @@ export default function optimizeDeps() {
}
},
async resolveId(specifier, importer, resolveOptions) {
+ // Skip virtual modules - they're handled by their respective plugins
+ if (specifier[0] === "\0") return null;
+
try {
const resolved = await this.resolve(specifier, importer, {
...resolveOptions,
@@ -177,7 +188,7 @@ export default function optimizeDeps() {
(this.environment.name === "rsc" ||
this.environment.name === "ssr") &&
/\.[cm]?js$/.test(path) &&
- tryStat(path)?.isFile()
+ isFileCached(path)
) {
// Check if this is a bare import (not relative/absolute)
const isBareImport =
@@ -189,25 +200,9 @@ export default function optimizeDeps() {
return resolved;
}
- // Check if file is ESM by parsing it (more reliable than package.json type)
- let fileIsESM = isModule(path);
- if (!fileIsESM && path.endsWith(".js")) {
- try {
- const content = await readFileCachedAsync(path);
- if (content) {
- const ast = this.parse(content);
- fileIsESM = ast.body.some(
- (node) =>
- node.type === "ImportDeclaration" ||
- node.type === "ExportNamedDeclaration" ||
- node.type === "ExportDefaultDeclaration" ||
- node.type === "ExportAllDeclaration"
- );
- }
- } catch {
- // If parsing fails, fall back to package.json type
- }
- }
+ // Check if file is ESM using cached extension + regex detection
+ // (.mjs/.cjs resolved by extension, .js by cached content regex scan)
+ const fileIsESM = await isESMSyntaxAsync(path);
// If the resolved file is CJS, externalize it
if (!fileIsESM) {
@@ -249,7 +244,7 @@ export default function optimizeDeps() {
(specifier[0] !== "." && specifier[0] !== "/")) &&
applyAlias(alias, specifier) === specifier &&
!isRootModule(path) &&
- tryStat(path)?.isFile()
+ isFileCached(path)
) {
// Don't optimize packages with client components - optimization breaks
// React Context because optimized bundles create separate context instances
diff --git a/packages/react-server/lib/plugins/optional-deps.mjs b/packages/react-server/lib/plugins/optional-deps.mjs
new file mode 100644
index 00000000..25d96728
--- /dev/null
+++ b/packages/react-server/lib/plugins/optional-deps.mjs
@@ -0,0 +1,62 @@
+import { createRequire } from "node:module";
+
+const __require = createRequire(import.meta.url);
+
+const VIRTUAL_EMPTY = "\0virtual:optional-dep-empty";
+
+/**
+ * Vite/Rolldown plugin that resolves optional dependencies to an empty module
+ * when they aren't installed. This is essential for edge builds (e.g.
+ * Cloudflare Workers) where there's no node_modules at runtime.
+ *
+ * Behaviour:
+ * - Package matches `forceEmpty` → always resolve to empty module
+ * - Package installed → let the bundler resolve & bundle it normally
+ * - Package NOT installed → resolve to a virtual empty module (noop)
+ *
+ * @param {RegExp[]} patterns - Array of regexes matching package specifiers
+ * @param {{ forceEmpty?: RegExp[] }} [opts] - Additional options
+ * @param {RegExp[]} [opts.forceEmpty] - Patterns to always resolve to empty,
+ * even if the package is installed (e.g. @opentelemetry/* when telemetry is disabled)
+ */
+export default function optionalDeps(patterns, { forceEmpty = [] } = {}) {
+ const resolved = new Map();
+
+ function isOptional(id) {
+ return patterns.some((re) => re.test(id));
+ }
+
+ function isForceEmpty(id) {
+ return forceEmpty.some((re) => re.test(id));
+ }
+
+ function canResolve(id) {
+ if (resolved.has(id)) return resolved.get(id);
+ try {
+ __require.resolve(id);
+ resolved.set(id, true);
+ return true;
+ } catch {
+ resolved.set(id, false);
+ return false;
+ }
+ }
+
+ return {
+ name: "react-server:optional-deps",
+ enforce: "pre",
+ resolveId(id) {
+ if (isOptional(id) && (isForceEmpty(id) || !canResolve(id))) {
+ return VIRTUAL_EMPTY;
+ }
+ // let the default resolver handle it
+ return null;
+ },
+ load(id) {
+ if (id === VIRTUAL_EMPTY) {
+ return "export default {};";
+ }
+ return null;
+ },
+ };
+}
diff --git a/packages/react-server/lib/plugins/telemetry-hooks.mjs b/packages/react-server/lib/plugins/telemetry-hooks.mjs
new file mode 100644
index 00000000..da9795f9
--- /dev/null
+++ b/packages/react-server/lib/plugins/telemetry-hooks.mjs
@@ -0,0 +1,145 @@
+/**
+ * Vite plugin that instruments all other plugins' hooks with OpenTelemetry spans.
+ *
+ * When telemetry is enabled, this plugin wraps the `resolveId`, `load`,
+ * `transform`, `buildStart`, `buildEnd`, `configResolved`, `configureServer`,
+ * and `handleHotUpdate` hooks on every plugin so each invocation is individually
+ * traced.
+ *
+ * This gives full visibility into how long each Vite plugin hook takes — even
+ * built-in plugins.
+ */
+
+const TRACED_HOOKS = [
+ "resolveId",
+ "load",
+ "transform",
+ "buildStart",
+ "buildEnd",
+ "handleHotUpdate",
+];
+
+/**
+ * Wraps a single hook function to emit an OTel span on each call.
+ */
+function wrapHook(
+ hookName,
+ pluginName,
+ originalHook,
+ getTracer,
+ getOtelContext
+) {
+ // Hooks can be either a function or an object { handler, order, ... }
+ if (
+ typeof originalHook === "object" &&
+ originalHook !== null &&
+ originalHook.handler
+ ) {
+ return {
+ ...originalHook,
+ handler: wrapHookFn(
+ hookName,
+ pluginName,
+ originalHook.handler,
+ getTracer,
+ getOtelContext
+ ),
+ };
+ }
+ if (typeof originalHook === "function") {
+ return wrapHookFn(
+ hookName,
+ pluginName,
+ originalHook,
+ getTracer,
+ getOtelContext
+ );
+ }
+ return originalHook;
+}
+
+function wrapHookFn(hookName, pluginName, fn, getTracer, getOtelContext) {
+ const wrapped = async function (...args) {
+ const tracer = getTracer();
+ // Check if it's the noop tracer (has no actual recording)
+ if (!tracer || !tracer.startSpan) return fn.apply(this, args);
+
+ const parentCtx = getOtelContext?.() ?? undefined;
+ const span = tracer.startSpan(
+ `Vite plugin [${pluginName}].${hookName}: `,
+ {
+ attributes: {
+ "react_server.vite.plugin": pluginName,
+ "react_server.vite.hook": hookName,
+ ...(hookName === "resolveId" && args[0]
+ ? { "react_server.vite.module_id": String(args[0]).slice(0, 200) }
+ : {}),
+ ...(hookName === "load" && args[0]
+ ? { "react_server.vite.module_id": String(args[0]).slice(0, 200) }
+ : {}),
+ ...(hookName === "transform" && args[1]
+ ? { "react_server.vite.module_id": String(args[1]).slice(0, 200) }
+ : {}),
+ },
+ },
+ parentCtx
+ );
+
+ try {
+ const result = await fn.apply(this, args);
+ span.end();
+ return result;
+ } catch (error) {
+ span.setStatus({ code: 2, message: error?.message });
+ span.recordException(error);
+ span.end();
+ throw error;
+ }
+ };
+
+ // Preserve function name for debugging
+ Object.defineProperty(wrapped, "name", { value: fn.name || hookName });
+ return wrapped;
+}
+
+/**
+ * Creates a Vite plugin that instruments all other plugins' hooks.
+ *
+ * Must be added as the LAST plugin so that `configResolved` sees all plugins.
+ * Only added when telemetry is enabled (tracer already initialized).
+ */
+export default function telemetryHooks() {
+ let telemetryMod = null;
+ const telemetryReady = import("../../server/telemetry.mjs").then((mod) => {
+ telemetryMod = mod;
+ });
+
+ return {
+ name: "react-server:telemetry-hooks",
+ enforce: "pre",
+ async configResolved(resolvedConfig) {
+ // Wait for the telemetry module to be loaded
+ await telemetryReady;
+ if (!telemetryMod) return;
+
+ const { getTracer, getOtelContext } = telemetryMod;
+
+ // Wrap hooks on all other plugins
+ for (const plugin of resolvedConfig.plugins) {
+ if (plugin.name === "react-server:telemetry-hooks") continue;
+
+ for (const hookName of TRACED_HOOKS) {
+ if (plugin[hookName]) {
+ plugin[hookName] = wrapHook(
+ hookName,
+ plugin.name,
+ plugin[hookName],
+ getTracer,
+ getOtelContext
+ );
+ }
+ }
+ }
+ },
+ };
+}
diff --git a/packages/react-server/lib/start/create-server.mjs b/packages/react-server/lib/start/create-server.mjs
index 67934ed7..4d6d4257 100644
--- a/packages/react-server/lib/start/create-server.mjs
+++ b/packages/react-server/lib/start/create-server.mjs
@@ -22,6 +22,12 @@ import {
MEMORY_CACHE_CONTEXT,
WORKER_THREAD,
} from "../../server/symbols.mjs";
+import {
+ resolveTelemetryConfig,
+ initTelemetry,
+ shutdownTelemetry,
+ getTracer,
+} from "../../server/telemetry.mjs";
import notFoundHandler from "../handlers/not-found.mjs";
import staticHandler from "../handlers/static.mjs";
import trailingSlashHandler from "../handlers/trailing-slash.mjs";
@@ -52,6 +58,19 @@ export default async function createServer(root, options) {
const config = getRuntime(CONFIG_CONTEXT)?.[CONFIG_ROOT] ?? {};
+ // ── Telemetry: initialize OpenTelemetry SDK ──
+ const telemetryConfig = resolveTelemetryConfig(config);
+ await initTelemetry(telemetryConfig);
+
+ // ── Telemetry: server startup span ──
+ const startupTracer = getTracer();
+ const startupSpan = startupTracer.startSpan("Server Startup", {
+ attributes: {
+ "react_server.mode": "production",
+ "react_server.root": root || "file-router",
+ },
+ });
+
const initialRuntime = {
[MEMORY_CACHE_CONTEXT]: new StorageCache(memoryDriver),
};
@@ -67,7 +86,9 @@ export default async function createServer(root, options) {
const publicDir =
typeof config.public === "string" ? config.public : "public";
const initialHandlers = await Promise.all([
- async () => PrerenderStorage.enterWith({}),
+ async function prerenderInit() {
+ PrerenderStorage.enterWith({});
+ },
staticHandler(join(cwd, options.outDir, "dist"), {
cwd: join(options.outDir, "dist"),
}),
@@ -90,7 +111,7 @@ export default async function createServer(root, options) {
notFoundHandler(),
]);
if (config.base) {
- initialHandlers.unshift(async (context) => {
+ initialHandlers.unshift(async function basePathStrip(context) {
if (context.url.pathname.startsWith(config.base)) {
context.url.pathname =
context.url.pathname.slice(config.base.length) || "/";
@@ -193,5 +214,15 @@ export default async function createServer(root, options) {
});
}
+ // ── Telemetry: flush on server close ──
+ if (httpServer) {
+ httpServer.on("close", () => {
+ shutdownTelemetry();
+ });
+ }
+
+ // ── Telemetry: end startup span ──
+ startupSpan.end();
+
return server;
}
diff --git a/packages/react-server/lib/start/edge.mjs b/packages/react-server/lib/start/edge.mjs
index 7378104e..9c415cf0 100644
--- a/packages/react-server/lib/start/edge.mjs
+++ b/packages/react-server/lib/start/edge.mjs
@@ -8,6 +8,10 @@ import {
MEMORY_CACHE_CONTEXT,
WORKER_THREAD,
} from "../../server/symbols.mjs";
+import {
+ resolveTelemetryConfig,
+ initEdgeTelemetry,
+} from "../../server/telemetry.mjs";
import notFoundHandler from "../handlers/not-found.mjs";
import trailingSlashHandler from "../handlers/trailing-slash.mjs";
import { getServerCors } from "../utils/server-config.mjs";
@@ -32,6 +36,12 @@ export function reactServer(root, options = {}, initialConfig = {}) {
runtime$(CONFIG_CONTEXT, config);
await createLogger({ logger: console, ...config[CONFIG_ROOT] });
+ // ── Telemetry: initialize for edge runtime ──
+ const telemetryConfig = resolveTelemetryConfig(
+ config?.[CONFIG_ROOT] ?? {}
+ );
+ await initEdgeTelemetry(telemetryConfig);
+
if (!options.outDir) {
options.outDir = ".react-server";
}
diff --git a/packages/react-server/lib/start/ssr-handler.mjs b/packages/react-server/lib/start/ssr-handler.mjs
index 7ce82203..bcfef6cd 100644
--- a/packages/react-server/lib/start/ssr-handler.mjs
+++ b/packages/react-server/lib/start/ssr-handler.mjs
@@ -32,6 +32,8 @@ import {
MEMORY_CACHE_CONTEXT,
MODULE_LOADER,
MODULE_CACHE,
+ OTEL_SPAN,
+ OTEL_CONTEXT,
POSTPONE_CONTEXT,
POSTPONE_STATE,
PRELUDE_HTML,
@@ -229,7 +231,7 @@ export default async function ssrHandler(root, options = {}) {
});
};
- return async (httpContext) => {
+ return async function ssr(httpContext) {
const noCache =
httpContext.request.headers.get("cache-control") === "no-cache";
@@ -261,6 +263,9 @@ export default async function ssrHandler(root, options = {}) {
[ERROR_BOUNDARY]: ErrorBoundary,
[MODULE_CACHE]: moduleCacheStorage,
[LINK_QUEUE]: linkQueueStorage,
+ // Propagate OTel span from the HTTP layer into per‑request context
+ [OTEL_SPAN]: httpContext._otelSpan ?? null,
+ [OTEL_CONTEXT]: httpContext._otelCtx ?? null,
},
async () => {
if (!noCache) {
@@ -362,7 +367,14 @@ export default async function ssrHandler(root, options = {}) {
if (getContext(POSTPONE_CONTEXT) === null) {
context$(POSTPONE_CONTEXT, true);
}
- render(Component, {}, { middlewareError }).then(resolve, reject);
+ render(Component, {}, { middlewareError }).then(
+ (result) => {
+ resolve(result);
+ },
+ (err) => {
+ reject(err);
+ }
+ );
}
);
});
diff --git a/packages/react-server/package.json b/packages/react-server/package.json
index 7240d7fe..05c41625 100644
--- a/packages/react-server/package.json
+++ b/packages/react-server/package.json
@@ -163,6 +163,10 @@
"types": "./worker/index.d.ts",
"default": "./worker/index.mjs"
},
+ "./telemetry": {
+ "types": "./telemetry/index.d.ts",
+ "default": "./telemetry/index.mjs"
+ },
"./cache/*": "./cache/*",
"./client/*": "./client/*",
"./server/*": "./server/*",
@@ -246,6 +250,46 @@
"devDependencies": {
"@types/node": "^20.10.0"
},
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@opentelemetry/core": "^1.0.0 || ^2.0.0",
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.50.0 || ^1.0.0",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.50.0 || ^1.0.0",
+ "@opentelemetry/resources": "^1.0.0 || ^2.0.0",
+ "@opentelemetry/sdk-metrics": "^1.0.0 || ^2.0.0",
+ "@opentelemetry/sdk-node": "^0.50.0 || ^1.0.0",
+ "@opentelemetry/sdk-trace-base": "^1.0.0 || ^2.0.0",
+ "@opentelemetry/semantic-conventions": "^1.0.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@opentelemetry/sdk-node": {
+ "optional": true
+ },
+ "@opentelemetry/sdk-trace-base": {
+ "optional": true
+ },
+ "@opentelemetry/exporter-trace-otlp-http": {
+ "optional": true
+ },
+ "@opentelemetry/exporter-metrics-otlp-http": {
+ "optional": true
+ },
+ "@opentelemetry/sdk-metrics": {
+ "optional": true
+ },
+ "@opentelemetry/core": {
+ "optional": true
+ },
+ "@opentelemetry/resources": {
+ "optional": true
+ },
+ "@opentelemetry/semantic-conventions": {
+ "optional": true
+ }
+ },
"engines": {
"node": ">=20.10.0"
}
diff --git a/packages/react-server/server/dev-trace-exporter.mjs b/packages/react-server/server/dev-trace-exporter.mjs
new file mode 100644
index 00000000..d4fc802a
--- /dev/null
+++ b/packages/react-server/server/dev-trace-exporter.mjs
@@ -0,0 +1,419 @@
+/**
+ * A pretty-printed console span exporter for development.
+ *
+ * Buffers spans per trace and renders a compact tree view with:
+ * - Request summary header (method, path, status, total duration)
+ * - Hierarchical tree using parent-child relationships
+ * - Color-coded durations (green / yellow / red)
+ * - Proportional timing bars
+ * - Key attributes displayed inline
+ *
+ * Used automatically in dev mode when telemetry is enabled.
+ */
+
+import colors from "picocolors";
+
+// ── Thresholds & layout ───────────────────────────────────────────────
+
+const SLOW_MS = 100;
+const WARN_MS = 20;
+const BAR_WIDTH = 12;
+const MIN_DURATION_MS = 1; // Hide spans shorter than this from dev console
+
+// ── Helpers ───────────────────────────────────────────────────────────
+
+function hrTimeToMs(hrTime) {
+ if (Array.isArray(hrTime)) return hrTime[0] * 1000 + hrTime[1] / 1e6;
+ return 0;
+}
+
+function formatDuration(ms) {
+ if (ms < 0.01) return colors.dim("—");
+ if (ms < 1) return colorDuration(`${(ms * 1000).toFixed(0)}µs`, ms);
+ if (ms < 1000) return colorDuration(`${ms.toFixed(1)}ms`, ms);
+ return colorDuration(`${(ms / 1000).toFixed(2)}s`, ms);
+}
+
+function colorDuration(text, ms) {
+ if (ms >= SLOW_MS) return colors.red(text);
+ if (ms >= WARN_MS) return colors.yellow(text);
+ return colors.green(text);
+}
+
+function timingBar(ms, maxMs) {
+ if (maxMs <= 0 || ms <= 0) return "";
+ const filled = Math.max(1, Math.round((ms / maxMs) * BAR_WIDTH));
+ const bar = "░".repeat(Math.min(filled, BAR_WIDTH));
+ if (ms >= SLOW_MS) return colors.red(bar);
+ if (ms >= WARN_MS) return colors.yellow(bar);
+ return colors.dim(bar);
+}
+
+function statusBadge(code) {
+ if (code >= 500) return colors.red(colors.bold(String(code)));
+ if (code >= 400) return colors.yellow(String(code));
+ if (code >= 300) return colors.cyan(String(code));
+ if (code >= 200) return colors.green(String(code));
+ return colors.dim(String(code));
+}
+
+function methodColor(method) {
+ switch (method) {
+ case "GET":
+ return colors.cyan;
+ case "POST":
+ return colors.green;
+ case "PUT":
+ return colors.yellow;
+ case "DELETE":
+ return colors.red;
+ case "PATCH":
+ return colors.magenta;
+ default:
+ return colors.white;
+ }
+}
+
+/** Pick the most useful inline detail from span attributes. */
+function inlineDetail(name, attrs) {
+ const parts = [];
+ // Cache operations: show hit/miss status
+ if (attrs["react_server.cache.hit"] !== undefined) {
+ parts.push(
+ attrs["react_server.cache.hit"]
+ ? colors.green("HIT")
+ : colors.yellow("MISS")
+ );
+ if (
+ attrs["react_server.cache.provider"] &&
+ attrs["react_server.cache.provider"] !== "default"
+ )
+ parts.push(colors.dim(`(${attrs["react_server.cache.provider"]})`));
+ }
+ // Render type
+ if (attrs["react_server.render_type"])
+ parts.push(colors.magenta(attrs["react_server.render_type"]));
+ // Server functions: show function ID
+ if (attrs["react_server.server_function.id"])
+ parts.push(colors.yellow(attrs["react_server.server_function.id"]));
+ // Vite plugin hooks: show module being processed
+ if (attrs["react_server.vite.module_id"]) {
+ let modId = attrs["react_server.vite.module_id"];
+ // Shorten long module paths
+ if (modId.length > 60) modId = "…" + modId.slice(-55);
+ parts.push(colors.dim(modId));
+ }
+ return parts.length ? " " + parts.join(" ") : "";
+}
+
+// ── Tree construction & rendering ─────────────────────────────────────
+
+function buildTree(spans) {
+ const childrenOf = new Map();
+
+ for (const span of spans) {
+ childrenOf.set(span.spanContext().spanId, []);
+ }
+
+ const roots = [];
+ for (const span of spans) {
+ const parentId = span.parentSpanContext?.spanId;
+ if (parentId && childrenOf.has(parentId)) {
+ childrenOf.get(parentId).push(span);
+ } else {
+ roots.push(span);
+ }
+ }
+
+ for (const children of childrenOf.values()) {
+ children.sort((a, b) => hrTimeToMs(a.startTime) - hrTimeToMs(b.startTime));
+ }
+
+ return { roots, childrenOf };
+}
+
+function isVitePluginSpan(span) {
+ return span.name.startsWith("Vite plugin [");
+}
+
+/**
+ * Group green-duration Vite plugin spans by name.
+ * Yellow/red/error spans are kept individual. Non-Vite spans pass through.
+ *
+ * Returns a flat list of "render items" in original order:
+ * { type: "span", span } — individual span
+ * { type: "group", name, count, totalMs } — collapsed group
+ */
+function groupViteSpans(spans, childrenOf) {
+ // First pass: bucket green Vite spans by name
+ const greenBuckets = new Map(); // name → span[]
+ const items = []; // ordered render items (placeholder for groups)
+
+ for (const span of spans) {
+ const durationMs = hrTimeToMs(span.endTime) - hrTimeToMs(span.startTime);
+ const isVite = isVitePluginSpan(span);
+ const isGreen = durationMs < WARN_MS;
+ const hasError = span.status?.code === 2;
+ const hasChildren =
+ (childrenOf.get(span.spanContext().spanId) || []).length > 0;
+
+ if (isVite && isGreen && !hasError && !hasChildren) {
+ if (!greenBuckets.has(span.name)) {
+ greenBuckets.set(span.name, []);
+ // Insert a group placeholder at position of first occurrence
+ items.push({ type: "group", name: span.name, _bucket: span.name });
+ }
+ greenBuckets.get(span.name).push(span);
+ } else {
+ items.push({ type: "span", span });
+ }
+ }
+
+ // Second pass: resolve group placeholders
+ const result = [];
+ for (const item of items) {
+ if (item.type === "group") {
+ const bucket = greenBuckets.get(item._bucket);
+ if (!bucket || bucket.length === 0) continue;
+ const totalMs = bucket.reduce(
+ (sum, s) => sum + (hrTimeToMs(s.endTime) - hrTimeToMs(s.startTime)),
+ 0
+ );
+ result.push({
+ type: "group",
+ name: item.name,
+ count: bucket.length,
+ totalMs,
+ });
+ } else {
+ result.push(item);
+ }
+ }
+ return result;
+}
+
+function renderTree(spans, childrenOf, maxMs, prefix) {
+ const lines = [];
+
+ // Separate spans by visibility: >= MIN_DURATION_MS shown individually, rest collapsed
+ const visibleSpans = [];
+ let skippedCount = 0;
+ let skippedTotalMs = 0;
+
+ for (const span of spans) {
+ const durationMs = hrTimeToMs(span.endTime) - hrTimeToMs(span.startTime);
+ if (durationMs < MIN_DURATION_MS) {
+ skippedCount++;
+ skippedTotalMs += durationMs;
+ // Also count all descendants as skipped
+ const countDescendants = (spanId) => {
+ const kids = childrenOf.get(spanId) || [];
+ for (const kid of kids) {
+ skippedCount++;
+ skippedTotalMs += hrTimeToMs(kid.endTime) - hrTimeToMs(kid.startTime);
+ countDescendants(kid.spanContext().spanId);
+ }
+ };
+ countDescendants(span.spanContext().spanId);
+ } else {
+ visibleSpans.push(span);
+ }
+ }
+
+ // Group green Vite plugin spans by name+hook
+ const renderItems = groupViteSpans(visibleSpans, childrenOf);
+
+ for (let i = 0; i < renderItems.length; i++) {
+ const item = renderItems[i];
+ const isLast = i === renderItems.length - 1 && skippedCount === 0;
+ const connector = isLast ? "└─" : "├─";
+ const childPrefix = isLast ? " " : "│ ";
+
+ if (item.type === "group") {
+ // Collapsed Vite plugin group
+ const bar = timingBar(item.totalMs, maxMs);
+ const dur = formatDuration(item.totalMs);
+ const barStr = bar ? " " + bar : "";
+ const countStr = colors.dim(` ×${item.count}`);
+
+ lines.push(
+ `${colors.dim(prefix + connector)} ${colors.white(item.name)}${countStr}${barStr} ${dur}`
+ );
+ continue;
+ }
+
+ // Individual span
+ const span = item.span;
+ const children = childrenOf.get(span.spanContext().spanId) || [];
+ const durationMs = hrTimeToMs(span.endTime) - hrTimeToMs(span.startTime);
+ const name = span.name;
+ const detail = inlineDetail(name, span.attributes || {});
+
+ const hasError = span.status?.code === 2;
+ const nameStr = hasError ? colors.red(name) : colors.white(name);
+ const errorSuffix =
+ hasError && span.status?.message
+ ? " " + colors.red(colors.dim(span.status.message))
+ : "";
+
+ const bar = timingBar(durationMs, maxMs);
+ const dur = formatDuration(durationMs);
+ const barStr = bar ? " " + bar : "";
+
+ lines.push(
+ `${colors.dim(prefix + connector)} ${nameStr}${detail}${errorSuffix}${barStr} ${dur}`
+ );
+
+ if (children.length > 0) {
+ lines.push(
+ ...renderTree(children, childrenOf, maxMs, prefix + childPrefix)
+ );
+ }
+ }
+
+ // Summary line for all collapsed (sub-threshold) spans
+ if (skippedCount > 0) {
+ const connector = "└─";
+ const summary = colors.dim(
+ `${skippedCount} span${skippedCount > 1 ? "s" : ""} (<${MIN_DURATION_MS}ms)`
+ );
+ lines.push(`${colors.dim(prefix + connector)} ${summary}`);
+ }
+
+ return lines;
+}
+
+// ── Exporter ──────────────────────────────────────────────────────────
+
+/**
+ * Dev console span exporter.
+ *
+ * Buffers spans by traceId for a short window, then renders the full
+ * trace as a tree. This is needed because SimpleSpanProcessor exports
+ * one span at a time as each span ends.
+ */
+export class DevConsoleSpanExporter {
+ constructor(options = {}) {
+ this._stopped = false;
+ this._buffer = new Map(); // traceId → { spans[], timer, hasRoot }
+ this._maxWait = options.maxWait ?? 5000;
+ }
+
+ export(spans, resultCallback) {
+ if (this._stopped) {
+ resultCallback({ code: 1 });
+ return;
+ }
+
+ for (const span of spans) {
+ const traceId = span.spanContext().traceId;
+ if (!this._buffer.has(traceId)) {
+ this._buffer.set(traceId, { spans: [], timer: null, hasRoot: false });
+ }
+ const entry = this._buffer.get(traceId);
+ entry.spans.push(span);
+
+ // A span without a parent is the root — the trace is complete
+ if (!span.parentSpanContext?.spanId) {
+ entry.hasRoot = true;
+ }
+
+ if (entry.hasRoot) {
+ // Root arrived — flush immediately
+ if (entry.timer) clearTimeout(entry.timer);
+ // Use a microtask so any remaining spans from the same tick arrive first
+ entry.timer = setTimeout(() => this._flush(traceId), 5);
+ } else if (!entry.timer) {
+ // Safety timeout — flush even if root never arrives
+ entry.timer = setTimeout(() => this._flush(traceId), this._maxWait);
+ }
+ }
+
+ resultCallback({ code: 0 });
+ }
+
+ _flush(traceId) {
+ const entry = this._buffer.get(traceId);
+ if (!entry) return;
+ this._buffer.delete(traceId);
+ this._render(entry.spans);
+ }
+
+ _render(spans) {
+ if (spans.length === 0) return;
+
+ const { roots, childrenOf } = buildTree(spans);
+
+ for (const root of roots) {
+ const rootId = root.spanContext().spanId;
+ const rootDuration =
+ hrTimeToMs(root.endTime) - hrTimeToMs(root.startTime);
+ const children = childrenOf.get(rootId) || [];
+ const attrs = root.attributes || {};
+
+ // Skip standalone Vite spans (module resolution, transforms, etc.)
+ // They are only useful as children of an HTTP request trace.
+ if (root.name.startsWith("Vite ")) {
+ continue;
+ }
+
+ // Skip other non-HTTP root spans that are below threshold
+ if (
+ !root.name.startsWith("HTTP") &&
+ root.name !== "HTTP Request" &&
+ rootDuration < MIN_DURATION_MS
+ ) {
+ continue;
+ }
+
+ // ── Header line ──
+ if (root.name.startsWith("HTTP") || root.name === "HTTP Request") {
+ const method = attrs["http.method"] || "???";
+ const target = attrs["http.target"] || attrs["http.url"] || "/";
+ const statusCode = attrs["http.status_code"];
+ const colorFn = methodColor(method);
+
+ const parts = [
+ colorFn(colors.bold(method)),
+ colors.white(target),
+ statusCode != null ? statusBadge(statusCode) : null,
+ formatDuration(rootDuration),
+ ].filter(Boolean);
+
+ console.log(`\n ${parts.join(" ")}`);
+ } else {
+ // Non-HTTP root span
+ console.log(
+ `\n ${colors.white(colors.bold(root.name))} ${formatDuration(rootDuration)}`
+ );
+ }
+
+ // ── Child tree ──
+ if (children.length > 0) {
+ const lines = renderTree(children, childrenOf, rootDuration, " ");
+ for (const line of lines) {
+ console.log(line);
+ }
+ }
+ }
+ }
+
+ shutdown() {
+ for (const [, entry] of this._buffer) {
+ if (entry.timer) clearTimeout(entry.timer);
+ this._render(entry.spans);
+ }
+ this._buffer.clear();
+ this._stopped = true;
+ return Promise.resolve();
+ }
+
+ forceFlush() {
+ for (const [, entry] of this._buffer) {
+ if (entry.timer) clearTimeout(entry.timer);
+ this._render(entry.spans);
+ }
+ this._buffer.clear();
+ return Promise.resolve();
+ }
+}
diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx
index 92923084..f4e7e26a 100644
--- a/packages/react-server/server/render-rsc.jsx
+++ b/packages/react-server/server/render-rsc.jsx
@@ -49,6 +49,12 @@ import {
SERVER_FUNCTION_NOT_FOUND,
} from "@lazarv/react-server/server/symbols.mjs";
import { ServerFunctionNotFoundError } from "./action-state.mjs";
+import {
+ getTracer,
+ getMetrics,
+ getOtelContext,
+ makeSpanContext,
+} from "./telemetry.mjs";
import { cwd } from "../lib/sys.mjs";
import { clientReferenceMap } from "@lazarv/react-server/dist/server/client-reference-map";
import { serverReferenceMap as _serverReferenceMap } from "@lazarv/react-server/dist/server/server-reference-map";
@@ -213,7 +219,44 @@ export async function render(Component, props = {}, options = {}) {
}
}
- const { data, actionId, error } = await action();
+ const { data, actionId, error } = await (async () => {
+ // ── Telemetry: server function span ──
+ const tracer = getTracer();
+ const parentCtx = getOtelContext();
+ const actionSpan = tracer.startSpan(
+ "Server Function",
+ {
+ attributes: {
+ "react_server.server_function.id":
+ serverActionHeader || "form-action",
+ "react_server.server_function.is_form": !!isFormData,
+ },
+ },
+ parentCtx ?? undefined
+ );
+ const actionStart = performance.now();
+ try {
+ const result = await action();
+ actionSpan.setAttribute(
+ "react_server.server_function.has_error",
+ !!result.error
+ );
+ if (result.error) {
+ actionSpan.recordException(result.error);
+ }
+ return result;
+ } catch (e) {
+ actionSpan.recordException(e);
+ throw e;
+ } finally {
+ actionSpan.end();
+ const metrics = getMetrics();
+ metrics?.actionDuration.record(performance.now() - actionStart, {
+ "react_server.server_function.id":
+ serverActionHeader || "form-action",
+ });
+ }
+ })();
if (error?.name === SERVER_FUNCTION_NOT_FOUND) {
const e = new ServerFunctionNotFoundError();
@@ -505,6 +548,19 @@ export async function render(Component, props = {}, options = {}) {
}
let hasError = false;
+ const rscRenderTracer = getTracer();
+ const rscRenderParentCtx = getOtelContext();
+ const rscFlightSpan = rscRenderTracer.startSpan(
+ "Render",
+ {
+ attributes: {
+ "react_server.render_type": "RSC",
+ "react_server.outlet": outlet || "PAGE_ROOT",
+ "http.url": context.url?.href,
+ },
+ },
+ rscRenderParentCtx ?? undefined
+ );
const stream = new ReadableStream({
type: "bytes",
async start(controller) {
@@ -627,6 +683,15 @@ export async function render(Component, props = {}, options = {}) {
ttl
);
}
+
+ // End RSC flight rendering span
+ if (hasError) {
+ rscFlightSpan.setStatus({
+ code: 2,
+ message: "RSC rendering error",
+ });
+ }
+ rscFlightSpan.end();
},
});
} else if (renderContext.flags.isHTML || renderContext.flags.isRemote) {
@@ -673,6 +738,23 @@ export async function render(Component, props = {}, options = {}) {
context$(HTTP_RESPONSE, responsePromise);
let hasError = false;
+
+ // ── Telemetry: RSC rendering span (wraps the full RSC→SSR pipeline) ──
+ const renderTracer = getTracer();
+ const renderParentCtx = getOtelContext();
+
+ const rscSpan = renderTracer.startSpan(
+ "Render",
+ {
+ attributes: {
+ "react_server.render_type": "RSC",
+ "react_server.outlet": outlet || "PAGE_ROOT",
+ "http.url": context.url?.href,
+ },
+ },
+ renderParentCtx ?? undefined
+ );
+
const flight = server.renderToReadableStream(
app,
clientReferenceMap({ remote: remote || remoteRSC, origin }),
@@ -693,12 +775,27 @@ export async function render(Component, props = {}, options = {}) {
}
);
+ // ── Telemetry: SSR rendering span (child of RSC, consumes flight stream → HTML) ──
+ const rscSpanCtx = makeSpanContext(rscSpan, renderParentCtx);
+ const ssrSpan = renderTracer.startSpan(
+ "Render",
+ {
+ attributes: {
+ "react_server.render_type": "SSR",
+ "react_server.outlet": outlet || "PAGE_ROOT",
+ "http.url": context.url?.href,
+ },
+ },
+ rscSpanCtx
+ );
+
const contextStore = ContextStorage.getStore();
const { onPostponed, prerender } = context;
const prelude = getContext(PRELUDE_HTML);
const postponed = getContext(POSTPONE_STATE);
const importMap = getContext(IMPORT_MAP);
let isStarted = false;
+
const stream = await renderStream({
stream: flight,
bootstrapModules:
@@ -779,6 +876,8 @@ export async function render(Component, props = {}, options = {}) {
...httpStatus,
headers,
});
+ ssrSpan.end();
+ rscSpan.end();
resolveResponse(response);
resolve(response);
});
@@ -789,6 +888,12 @@ export async function render(Component, props = {}, options = {}) {
}
(logger ?? console).error(e);
hasError = true;
+ ssrSpan.setStatus({ code: 2, message: e?.message });
+ ssrSpan.recordException(e);
+ ssrSpan.end();
+ rscSpan.setStatus({ code: 2, message: e?.message });
+ rscSpan.recordException(e);
+ rscSpan.end();
if (!isStarted) {
ContextStorage.run(contextStore, async () => {
context$(HTTP_STATUS, {
diff --git a/packages/react-server/server/symbols.mjs b/packages/react-server/server/symbols.mjs
index 962cc06d..176e4440 100644
--- a/packages/react-server/server/symbols.mjs
+++ b/packages/react-server/server/symbols.mjs
@@ -60,3 +60,9 @@ export const CONSOLE_PROXY = Symbol.for("CONSOLE_PROXY");
export const EXEC_OPTIONS = Symbol.for("EXEC_OPTIONS");
export const ABORT_SIGNAL = Symbol.for("ABORT_SIGNAL");
export const AFTER_CONTEXT = Symbol.for("AFTER_CONTEXT");
+export const OTEL_API = Symbol.for("OTEL_API");
+export const OTEL_TRACER = Symbol.for("OTEL_TRACER");
+export const OTEL_METER = Symbol.for("OTEL_METER");
+export const OTEL_SPAN = Symbol.for("OTEL_SPAN");
+export const OTEL_CONTEXT = Symbol.for("OTEL_CONTEXT");
+export const OTEL_SDK = Symbol.for("OTEL_SDK");
diff --git a/packages/react-server/server/telemetry.mjs b/packages/react-server/server/telemetry.mjs
new file mode 100644
index 00000000..36c39b91
--- /dev/null
+++ b/packages/react-server/server/telemetry.mjs
@@ -0,0 +1,608 @@
+/**
+ * Core OpenTelemetry integration for @lazarv/react-server.
+ *
+ * This module provides:
+ * - SDK initialization (`initTelemetry`)
+ * - Helpers to create / retrieve spans from ContextStorage
+ * - Built-in metrics (request duration, active requests, render timings)
+ * - Trace-context propagation (W3C TraceContext)
+ *
+ * All OTel dependencies are loaded lazily so that the runtime has zero
+ * overhead when telemetry is disabled (default in production unless configured).
+ */
+
+import { getContext, context$ } from "./context.mjs";
+import { getRuntime, runtime$ } from "./runtime.mjs";
+import {
+ OTEL_API,
+ OTEL_TRACER,
+ OTEL_METER,
+ OTEL_SPAN,
+ OTEL_CONTEXT,
+ OTEL_SDK,
+ LOGGER_CONTEXT,
+} from "./symbols.mjs";
+
+// ─── Noop fallbacks ──────────────────────────────────────────────────────────
+
+const NOOP_SPAN = {
+ setAttribute() {
+ return this;
+ },
+ setAttributes() {
+ return this;
+ },
+ addEvent() {
+ return this;
+ },
+ setStatus() {
+ return this;
+ },
+ end() {},
+ isRecording() {
+ return false;
+ },
+ recordException() {},
+ spanContext() {
+ return { traceId: "", spanId: "", traceFlags: 0 };
+ },
+ updateName() {
+ return this;
+ },
+};
+
+const NOOP_TRACER = {
+ startSpan() {
+ return NOOP_SPAN;
+ },
+ startActiveSpan(_name, ...args) {
+ const fn = args[args.length - 1];
+ return fn(NOOP_SPAN);
+ },
+};
+
+const NOOP_COUNTER = {
+ add() {},
+};
+const NOOP_HISTOGRAM = {
+ record() {},
+};
+const NOOP_UP_DOWN_COUNTER = {
+ add() {},
+};
+const NOOP_METER = {
+ createCounter() {
+ return NOOP_COUNTER;
+ },
+ createHistogram() {
+ return NOOP_HISTOGRAM;
+ },
+ createUpDownCounter() {
+ return NOOP_UP_DOWN_COUNTER;
+ },
+ createObservableGauge() {
+ return { addCallback() {} };
+ },
+};
+
+// ─── Lazy OTel API access ────────────────────────────────────────────────────
+
+let _api = null;
+
+function getApi() {
+ return _api ?? getRuntime(OTEL_API) ?? null;
+}
+
+async function otelApi() {
+ if (!_api) {
+ _api = await import("@opentelemetry/api");
+ }
+ return _api;
+}
+
+// ─── Public helpers ──────────────────────────────────────────────────────────
+
+/**
+ * Returns the active tracer (or a no-op tracer when telemetry is disabled).
+ */
+export function getTracer() {
+ return getRuntime(OTEL_TRACER) ?? NOOP_TRACER;
+}
+
+/**
+ * Returns the active meter (or a no-op meter when telemetry is disabled).
+ */
+export function getMeter() {
+ return getRuntime(OTEL_METER) ?? NOOP_METER;
+}
+
+/**
+ * Get the current request span from the per-request ContextStorage.
+ * Returns a noop span when telemetry is off or called outside a request.
+ */
+export function getSpan() {
+ return getContext(OTEL_SPAN) ?? NOOP_SPAN;
+}
+
+/**
+ * Get the OTel context for the current request.
+ */
+export function getOtelContext() {
+ return getContext(OTEL_CONTEXT) ?? null;
+}
+
+/**
+ * Store a span (and optional OTel context) in the per-request ContextStorage.
+ */
+export function setSpan(span, otelCtx) {
+ context$(OTEL_SPAN, span);
+ if (otelCtx) {
+ context$(OTEL_CONTEXT, otelCtx);
+ }
+}
+
+/**
+ * Create a new OTel context with the given span set as the active span.
+ * Use this to propagate parent context so child spans are properly nested.
+ *
+ * This function is **synchronous** to avoid introducing async boundaries
+ * in the render pipeline (which would break AsyncLocalStorage propagation
+ * and cache behavior). It relies on `_api` being populated eagerly by
+ * `initTelemetry()` / `initEdgeTelemetry()` before any request is handled.
+ */
+export function makeSpanContext(span, parentCtx) {
+ const api = getApi();
+ if (!api || span === NOOP_SPAN) return undefined;
+ return api.trace.setSpan(parentCtx || api.ROOT_CONTEXT, span);
+}
+
+/**
+ * Execute `fn` within a child span of the current request span.
+ *
+ * @param {string} name - Span name
+ * @param {Record} [attributes] - Initial span attributes
+ * @param {(span: any) => Promise} fn - Function to execute inside the span
+ * @returns {Promise}
+ *
+ * @example
+ * ```js
+ * import { withSpan } from "@lazarv/react-server/telemetry";
+ *
+ * const result = await withSpan("db.query", { "db.system": "postgres" }, async (span) => {
+ * const rows = await db.query("SELECT ...");
+ * span.setAttribute("db.rows", rows.length);
+ * return rows;
+ * });
+ * ```
+ */
+export async function withSpan(name, attributes, fn) {
+ if (typeof attributes === "function") {
+ fn = attributes;
+ attributes = {};
+ }
+
+ const tracer = getTracer();
+ if (tracer === NOOP_TRACER) {
+ return fn(NOOP_SPAN);
+ }
+
+ const parentOtelCtx = getOtelContext();
+ const api = await otelApi();
+ const ctx = parentOtelCtx ?? api.context.active();
+ const span = tracer.startSpan(name, { attributes }, ctx);
+
+ try {
+ const result = await api.context.with(api.trace.setSpan(ctx, span), () =>
+ fn(span)
+ );
+ span.setStatus({ code: api.SpanStatusCode.OK });
+ return result;
+ } catch (error) {
+ span.setStatus({
+ code: api.SpanStatusCode.ERROR,
+ message: error?.message,
+ });
+ span.recordException(error);
+ throw error;
+ } finally {
+ span.end();
+ }
+}
+
+// ─── Built-in metrics ────────────────────────────────────────────────────────
+
+let _metrics = null;
+
+/**
+ * Get (or lazily create) the built-in metrics instruments.
+ */
+export function getMetrics() {
+ if (_metrics) return _metrics;
+ const meter = getMeter();
+ if (meter === NOOP_METER) return null;
+
+ _metrics = {
+ httpRequestDuration: meter.createHistogram("http.server.request.duration", {
+ description: "Duration of HTTP requests",
+ unit: "ms",
+ }),
+ httpActiveRequests: meter.createUpDownCounter(
+ "http.server.active_requests",
+ {
+ description: "Number of active HTTP requests",
+ }
+ ),
+ rscRenderDuration: meter.createHistogram(
+ "react_server.rsc.render.duration",
+ {
+ description: "Duration of RSC rendering",
+ unit: "ms",
+ }
+ ),
+ domRenderDuration: meter.createHistogram(
+ "react_server.dom.render.duration",
+ {
+ description: "Duration of SSR DOM rendering",
+ unit: "ms",
+ }
+ ),
+ actionDuration: meter.createHistogram(
+ "react_server.server_function.duration",
+ {
+ description: "Duration of server function execution",
+ unit: "ms",
+ }
+ ),
+ cacheHits: meter.createCounter("react_server.cache.hits", {
+ description: "Number of cache hits",
+ }),
+ cacheMisses: meter.createCounter("react_server.cache.misses", {
+ description: "Number of cache misses",
+ }),
+ };
+ return _metrics;
+}
+
+// ─── SDK Initialization ──────────────────────────────────────────────────────
+
+/**
+ * Resolve the telemetry config. Returns null when telemetry should be disabled.
+ *
+ * Telemetry is enabled when:
+ * 1. `config.telemetry.enabled` is explicitly `true`, OR
+ * 2. The `OTEL_EXPORTER_OTLP_ENDPOINT` env var is set, OR
+ * 3. The `REACT_SERVER_TELEMETRY` env var is "true"
+ */
+export function resolveTelemetryConfig(config) {
+ const env = typeof process !== "undefined" ? process.env : {};
+ const telemetryConfig = config?.telemetry ?? {};
+
+ // Explicit opt-out via env var (e.g. set in test runner config)
+ if (env.REACT_SERVER_TELEMETRY === "false") {
+ return null;
+ }
+
+ const enabled =
+ telemetryConfig.enabled === true ||
+ !!env.OTEL_EXPORTER_OTLP_ENDPOINT ||
+ env.REACT_SERVER_TELEMETRY === "true";
+
+ if (!enabled) return null;
+
+ const isDev = env.NODE_ENV === "development" || env.NODE_ENV === undefined;
+
+ return {
+ serviceName:
+ telemetryConfig.serviceName ??
+ env.OTEL_SERVICE_NAME ??
+ config?.name ??
+ "@lazarv/react-server",
+ endpoint:
+ telemetryConfig.endpoint ??
+ env.OTEL_EXPORTER_OTLP_ENDPOINT ??
+ "http://localhost:4318",
+ exporter:
+ telemetryConfig.exporter ??
+ (env.OTEL_EXPORTER_OTLP_ENDPOINT
+ ? "otlp"
+ : isDev
+ ? "dev-console"
+ : "otlp"),
+ sampleRate: telemetryConfig.sampleRate ?? 1.0,
+ propagators: telemetryConfig.propagators ?? ["w3c"],
+ metrics: {
+ enabled: telemetryConfig.metrics?.enabled !== false,
+ interval: telemetryConfig.metrics?.interval ?? 30000,
+ },
+ };
+}
+
+/**
+ * Initialize the OpenTelemetry SDK for Node.js runtime.
+ * Stores the tracer and meter in RuntimeContextStorage.
+ *
+ * @param {object} telemetryConfig - Resolved config from `resolveTelemetryConfig()`
+ * @returns {Promise