@scayle/opentelemetry
v0.1.0-alpha.1
Published
SCAYLE OpenTelemetry module for Inertia based storefront applications
Maintainers
Keywords
Readme
@scayle/opentelemetry
OpenTelemetry integration for Storefront Application V3. Provides a Vite plugin for build-time SDK initialization and a Hono middleware for request instrumentation.
Features
- HTTP request tracing with OpenTelemetry semantic conventions
- OTLP trace and metrics export
- Node auto-instrumentations (HTTP, undici/fetch, runtime metrics)
- Path filtering and normalization for route names
- Request/response header capture
- Works in both dev server and production builds
Installation
The package is included in the V3 template by default. To add it manually:
pnpm add @scayle/opentelemetryQuick Start
1. Add the Vite plugin
// vite.config.ts
import { defineConfig } from 'vite'
import storefrontBuild from '@scayle/storefront-build'
import { opentelemetryPlugin } from '@scayle/opentelemetry/vite'
export default defineConfig({
plugins: [
storefrontBuild({
serverEntry: './src/server/index.ts',
ssrEntry: './src/client/ssr.ts',
indexEntry: './src/client/index.html',
}),
opentelemetryPlugin(),
],
envPrefix: ['STOREFRONT_', 'OTEL_'], // Required for OTEL env vars in dev
})2. Add the Hono middleware
// src/server/index.ts
import { Hono } from 'hono'
import { opentelemetry } from '@scayle/opentelemetry'
const app = new Hono()
// Add early in the middleware chain, after static file serving
app.use(
opentelemetry({
pathReplace: ['^/(en|de|fr)/', '/:locale/'],
requestHeaders: ['x-request-id'],
}),
)Configuration
Environment Variables
The SDK reads standard OpenTelemetry environment variables at startup:
| Variable | Description | Example |
| ----------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
| OTEL_SERVICE_NAME | Sets the service name in traces | storefront-v3 |
| OTEL_RESOURCE_ATTRIBUTES | Additional resource attributes | deployment.environment=production,service.version=1.0.0 |
| OTEL_EXPORTER_OTLP_ENDPOINT | OTLP collector endpoint | http://localhost:4318 |
| OTEL_EXPORTER_OTLP_HEADERS | Headers for OTLP requests | api-key=secret |
| OTEL_TRACES_SAMPLER_ARG | Sampling argument | 0.1 |
| OTEL_TRACES_EXPORTER | Trace exporter selection. One of otlp (default), console, none. | console |
| OTEL_METRICS_EXPORTER | Metric exporter selection. One of otlp (default), console, none. | none |
Unknown values for OTEL_TRACES_EXPORTER and OTEL_METRICS_EXPORTER log a single warn (logger namespace opentelemetry) and fall back to otlp. The matching OTEL_LOGS_EXPORTER env var is intentionally not honored: the storefront bridges OTEL log records into tslog via TslogLogRecordExporter, so log output already reaches the console without an OTLP collector.
See the OpenTelemetry Environment Variable Specification for the complete list of supported variables.
Important: Dev Server Configuration
Vite filters environment variables by prefix. To ensure OTEL variables are available in dev, add OTEL_ to envPrefix in vite.config.ts:
export default defineConfig({
envPrefix: ['STOREFRONT_', 'OTEL_'],
// ...
})In production (running node .output/server/index.mjs directly), all environment variables are available and this is not needed.
Middleware Options
app.use(
opentelemetry({
// Regex pattern for paths to ignore (no spans created)
pathBlocklist: '^/health|/_assets/',
// Normalize route names (e.g., /de/products -> /:locale/products)
pathReplace: ['^/(en|de|fr)/', '/:locale/'],
// Request headers to capture as span attributes
requestHeaders: ['x-request-id'],
// Response headers to capture as span attributes
responseHeaders: ['x-trace-id', 'cache-control'],
// Custom filter function (return true to skip)
ignoreRequestHook: (c) => c.req.path.startsWith('/_nuxt'),
}),
)Vite Plugin Options
opentelemetryPlugin({
// Enable/disable the plugin (default: true)
enabled: true,
// Module specifiers to include in import-in-the-middle hooks
include: ['@scayle/*'],
// Module specifiers to exclude
exclude: ['node_modules'],
// Skip auto-instrumentation for matching request paths
// (passed to @opentelemetry/instrumentation-http and -undici).
// Must be self-contained — serialized via .toString() into the prod entry.
filterPath: (path) => path === '/api/up',
})Health probe filtering
The defaults suppress traces for the /api/up health probe at both layers:
// vite.config.ts — auto-instrumentation (the only layer that fires for /api/up
// in production, since the route is registered before global middleware in the
// boilerplate)
import { defaultPathFilter } from '@scayle/opentelemetry'
import { opentelemetryPlugin } from '@scayle/opentelemetry/vite'
opentelemetryPlugin({ filterPath: defaultPathFilter })
// src/server/index.ts — Hono middleware (defense-in-depth, covers tenants
// who reorder routes so /api/up flows through global middleware)
import {
defaultOpenTelemetryConfig,
opentelemetry,
} from '@scayle/opentelemetry'
app.use(opentelemetry(defaultOpenTelemetryConfig))To filter additional paths (e.g. a custom /api/ready probe), replace defaultPathFilter with your own self-contained function and extend pathBlocklist:
opentelemetryPlugin({
filterPath: (path) => path === '/api/up' || path === '/api/ready',
})
app.use(
opentelemetry({
...defaultOpenTelemetryConfig,
pathBlocklist: '^/api/(up|ready)$',
}),
)How It Works
Production Build
The Vite plugin wraps the server entry to:
- Register
import-in-the-middlehooks before any app modules load - Initialize the NodeSDK with OTLP exporters and auto-instrumentations
- Dynamically import the original server entry
This ensures all modules (including dependencies) get properly instrumented.
Dev Server
The plugin uses Vite's configureServer hook to:
- Register
import-in-the-middlehooks when the dev server starts - Initialize the NodeSDK with a shared tracer provider
- The Hono middleware (loaded via
ssrLoadModule) picks up the initialized tracer
Note: Some auto-instrumentations (like http) may have limited coverage in dev if Node's built-in modules are loaded before configureServer runs. The Hono middleware spans always work.
Architecture
Request → Hono Middleware → Span created → Next handler
↓
Response → Span attributes set → Span exportedThe middleware creates a child span under the active OpenTelemetry context for each HTTP request. It captures:
- Method, path, scheme, status code
- Route pattern (via
c.req.routePath) - Client address, user agent
- Query string
- Configured request/response headers
- Errors (5xx responses mark span as ERROR)
Auto-instrumentation policy
initSDK() registers an explicit, curated set of OpenTelemetry instrumentations matched to the V3 reference stack. It does NOT use @opentelemetry/auto-instrumentations-node. The metapackage bundles 40+ patchers. With 32 of them irrelevant to V3 (Hono, undici fetch, node-redis, tslog), it paid measurable boot and per-module-load cost (visible as elevated makeSyncRequest activity in the import-in-the-middle hook) for no observability benefit. An explicit list also fails loudly when upstream adds a new patcher we did not opt into.
The kept set:
| Instrumentation | Why kept |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @opentelemetry/instrumentation-http | Inbound HTTP entry span. Backbone of the route view in trace UIs. |
| @opentelemetry/instrumentation-undici | Outbound fetch client (every SAPI call from the storefront). |
| @opentelemetry/instrumentation-dns | DNS lookup spans. Cheap, surfaces resolver outliers. |
| @opentelemetry/instrumentation-redis | node-redis command spans. 0.66.0 supports redis >=2.6.0 <6 through its v4-v5 patcher, so the scayle-kv driver's redis 5.12.1 client is covered. Client-level metrics are a separate signal, emitted natively by node-redis (see Native node-redis metrics). |
| @opentelemetry/instrumentation-runtime-node | Node runtime metrics (heap, event loop, GC). Used by V3 runtime dashboards. |
Plus @orpc/otel's ORPCInstrumentation for the API router.
Tenants that diverge from this stack (e.g. add the AWS SDK for S3 / SQS calls) opt back in via additionalInstrumentations (next section).
Native node-redis metrics
Separate from the redis spans above, initSDK() also turns on node-redis's own OpenTelemetry metrics, which shipped in redis 5.12.0 (OpenTelemetry.init() from the redis package). After sdk.start(), the SDK calls that bootstrap so the scayle-kv driver's client emits client-level metrics through the same metric pipeline the SDK already configures (OTLP by default, or whatever OTEL_METRICS_EXPORTER selects). Spans and metrics are different signals; both run.
The bootstrap is best-effort: it uses a guarded dynamic import('redis'), so a missing redis (it is an optional peer dependency) or a double OpenTelemetry.init() is logged via diag and never blocks startup.
Enabled metric groups
node-redis groups its metrics. The SDK enables the four groups relevant to the kv driver in a clustered deployment: command, connection-basic, connection-advanced, and resiliency. The cluster-specific signals (connection handoff on slot migration, relaxed timeouts during maintenance) live in connection-basic. connection-advanced adds per-pool saturation metrics, which matter because the cluster client keeps one connection pool per node.
| Metric | Instrument | Unit | Group | Description |
| ----------------------------------------- | ------------- | ---------------- | --------------------- | -------------------------------------------------------------------------------- |
| db.client.operation.duration | Histogram | s | command | Duration of a client operation (includes retries). |
| db.client.connection.count | UpDownCounter | {connection} | connection-basic | Current number of active connections. |
| db.client.connection.create_time | Histogram | s | connection-basic | Time taken to open a new connection. |
| redis.client.connection.handoff | Counter | {handoff} | connection-basic | Connections handed off to another node (e.g. after a MOVING / slot migration). |
| redis.client.connection.relaxed_timeout | UpDownCounter | {relaxation} | connection-basic | Timeout relaxations applied after a server maintenance notification. |
| db.client.connection.wait_time | Histogram | s | connection-advanced | Time spent waiting for an available connection from the pool. |
| redis.client.connection.closed | Counter | {connection} | connection-advanced | Total number of closed connections (carries a close-reason attribute). |
| redis.client.errors | Counter | {error} | resiliency | All errors, both returned to the caller and handled internally. |
| redis.client.maintenance.notifications | Counter | {notification} | resiliency | Maintenance notifications received from the server. |
Each data point carries db.system.name=redis, db.namespace (DB index), server.address, server.port, db.client.connection.pool.name, and redis.client.library (e.g. node-redis:5.12.1).
connection-advancedalso declaresdb.client.connection.pending_requests, but node-redis5.12.1ships it without a populating callback, so it currently emits no data points.
Groups left off
pubsub (redis.client.pubsub.messages), streaming (redis.client.stream.lag), and client-side-caching (redis.client.csc.*) are not enabled, because the scayle-kv driver does not use those features. The enabled set is fixed in initSDK.
Viewing metrics locally
Set OTEL_METRICS_EXPORTER=console to print metrics to stdout without a collector. Unlike spans (which batch and flush within a few seconds), the metric reader flushes on its periodic interval (the SDK default, ~60s), so allow up to a minute before the first metric appears, or until the process shuts down gracefully.
initSDK(filterPath?, options?)
Starts the OpenTelemetry NodeSDK with OTLP exporters and the curated instrumentation set.
Parameters
filterPath?: (path: string) => boolean: paths returningtrueare skipped byinstrumentation-httpandinstrumentation-undici. Use for static assets and health probes.options?: InitSDKOptionsadditionalInstrumentations?: Instrumentation[]: extra instrumentations appended to the curated set.
Example: tenant using the AWS SDK
import { initSDK } from '@scayle/opentelemetry/sdk-init'
import { defaultPathFilter } from '@scayle/opentelemetry'
import { AwsInstrumentation } from '@opentelemetry/instrumentation-aws-sdk'
initSDK(defaultPathFilter, {
additionalInstrumentations: [new AwsInstrumentation()],
})Span helpers
traceSsrRender(render)
Wraps an SSR render callback in a vue_ssr_render span so Vue render time and output size appear as a discrete child of inertia_render in the observability platform, separate from Inertia composition and prop work.
The helper lives in this package (rather than @scayle/storefront) so the storefront SDK does not gain a vue dependency. The span shape stays SDK-controlled. Future renames or attribute changes do not touch tenant forks.
Attributes
vue.html_bytes:Buffer.byteLengthon the rendered string. No extra work, the value is already a string at this point.
Example
import { renderToString } from '@vue/server-renderer'
import { traceSsrRender } from '@scayle/opentelemetry'
createInertiaApp({
render: (app) => traceSsrRender(() => renderToString(app)),
// ...
})Errors
The wrapped callback's rejection is recorded on the span via recordSpanError and rethrown. The helper never swallows errors.
Existing helpers
opentelemetry(...): Hono request middleware. Produces the entry server span and propagates the route name to the parent HTTP span.defaultPathFilter: pre-built filter that skips/api/up.defaultOpenTelemetryConfig: pre-built middleware config with V3-standard headers.recordSpanError(span, err, logger): marks a span as ERROR, records the exception, and emits a structured log entry.
Undici hook attributes
initSDK() configures instrumentation-undici with requestHook and responseHook to surface payload sizes and content-encoding directly on every client span. All attributes read from existing wire headers. No body decoding.
| Attribute | Source | When populated |
| --------------------------------------- | ---------------------------------- | ------------------------------------------------------- |
| http.request.body.size | content-length request header | When present (POST/PUT/PATCH with explicit length). |
| http.response.body.size | content-length response header | When the upstream sends one. Chunked transfer omits it. |
| http.response.header.content_encoding | content-encoding response header | When the upstream compresses the body (gzip, br). |
These attributes surface unexpectedly large SAPI payloads (e.g. broad with=siblings,siblings.images includes on the listing endpoint) directly in the trace UI without decoding span payloads.
Troubleshooting
Service shows as unknown_service:node
Set OTEL_SERVICE_NAME environment variable. Remember to add OTEL_ prefix to Vite's envPrefix in dev.
No spans exported
Check OTEL_EXPORTER_OTLP_ENDPOINT is set correctly. The SDK defaults to http://localhost:4318 for OTLP/HTTP.
Want to see spans locally without an OTLP collector
Set OTEL_TRACES_EXPORTER=console and the SDK will print each span as JSON on stdout. Combine with OTEL_METRICS_EXPORTER=console for metrics, or OTEL_TRACES_EXPORTER=none / OTEL_METRICS_EXPORTER=none to disable the respective signal entirely. Note that metrics flush on the reader's periodic interval (~60s by default), so they appear later than spans, not immediately. See Native node-redis metrics for what the redis client emits.
Static assets are being traced
Add a pathBlocklist pattern to filter them: pathBlocklist: '^/_nuxt|^/assets/'
Out of Scope
- Browser/client-side tracing (server-side only)
- Vercel preset support (Node server only)
- OpenTelemetry Logs API (using span events for now)
