@astroscope/opentelemetry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,449 @@
1
+ # @astroscope/opentelemetry
2
+
3
+ OpenTelemetry support for Astro SSR.
4
+
5
+ ## Examples
6
+
7
+ - [demo/opentelemetry](../../demo/opentelemetry) - Integration-based tracing (works in dev and production)
8
+ - [demo/opentelemetry-native](../../demo/opentelemetry-native) - Native ESM auto-instrumentation (production only)
9
+
10
+ ## Why?
11
+
12
+ OpenTelemetry's auto-instrumentation relies on monkey-patching Node.js modules, which has two challenges:
13
+
14
+ 1. **ESM support is still experimental** - Node.js ESM modules can't be monkey-patched like CommonJS. The OpenTelemetry team has been working on this for years ([#1946](https://github.com/open-telemetry/opentelemetry-js/issues/1946), [#4553](https://github.com/open-telemetry/opentelemetry-js/issues/4553)), and while there's progress, it requires experimental loader hooks that aren't yet stable.
15
+
16
+ 2. **Vite dev mode loads modules before instrumentation** - Auto-instrumentation must run before any instrumented modules (like `http`) are imported. In Vite's dev mode, modules are loaded dynamically, making it impossible to instrument them in time.
17
+
18
+ This package handles both issues by creating spans directly in Astro's request lifecycle - no monkey-patching required. It works in both dev mode and production.
19
+
20
+ ### Comparison
21
+
22
+ | Feature | @astroscope/opentelemetry | Native auto-instrumentation |
23
+ |---------|---------------------------|----------------------------|
24
+ | Works in dev mode | ✅ | ❌ |
25
+ | Works in production | ✅ | ✅ |
26
+ | Incoming HTTP requests | ✅ | ✅ |
27
+ | Outgoing fetch requests | ✅ | ❌ (ESM not supported) |
28
+ | Astro actions | ✅ (named spans) | ❌ |
29
+ | Component tracing | ✅ (`<Trace>` component) | ❌ |
30
+ | Metrics (Prometheus-compatible) | ✅ | ✅ |
31
+ | Other libraries | ❌ | ✅ (varies by library) |
32
+ | Setup complexity | Simple | Requires `--import` flag |
33
+ | Bundle size | Minimal | Heavy (30+ packages) |
34
+ | Cold start impact | Negligible | Significant |
35
+
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install @astroscope/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/sdk-node
41
+ ```
42
+
43
+ ## Setup
44
+
45
+ ### 1. Initialize the SDK
46
+
47
+ The OpenTelemetry SDK must be initialized before traces can be collected. Use [`@astroscope/boot`](../boot) for proper lifecycle management:
48
+
49
+ ```ts
50
+ // src/boot.ts
51
+ import { NodeSDK } from "@opentelemetry/sdk-node";
52
+
53
+ const sdk = new NodeSDK({
54
+ // configuration at https://opentelemetry.io/docs/languages/js/getting-started/nodejs/
55
+ });
56
+
57
+ export function onStartup() {
58
+ sdk.start();
59
+ }
60
+
61
+ export async function onShutdown() {
62
+ await sdk.shutdown();
63
+ }
64
+ ```
65
+
66
+ Note, since this integration creates spans directly, you don't need to
67
+
68
+ - add any instrumentations to the SDK configuration
69
+ - use specific import order for auto-instrumentation (since none is used)
70
+
71
+ ### 2. Add the integration
72
+
73
+ ```ts
74
+ // astro.config.ts
75
+ import { defineConfig } from "astro/config";
76
+ import boot from "@astroscope/boot";
77
+ import { opentelemetry } from "@astroscope/opentelemetry";
78
+
79
+ export default defineConfig({
80
+ integrations: [opentelemetry(), boot()], // opentelemetry() should come as early as possible in the list
81
+ });
82
+ ```
83
+
84
+ This automatically:
85
+ - Adds middleware to trace incoming HTTP requests
86
+ - Instruments `fetch()` to trace outgoing requests
87
+ - Uses `RECOMMENDED_EXCLUDES` to skip static assets
88
+ - Provides `<Trace>` component for tracing specific sections or components
89
+
90
+ ## Integration Options
91
+
92
+ ```ts
93
+ opentelemetry({
94
+ instrumentations: {
95
+ http: {
96
+ enabled: true, // default: true
97
+ exclude: [...RECOMMENDED_EXCLUDES, { exact: "/health" }],
98
+ },
99
+ fetch: {
100
+ enabled: true, // default: true
101
+ },
102
+ },
103
+ })
104
+ ```
105
+
106
+ ### `instrumentations.http`
107
+
108
+ Controls incoming HTTP request tracing via middleware.
109
+
110
+ | Option | Type | Default | Description |
111
+ |--------|------|---------|-------------|
112
+ | `enabled` | `boolean` | `true` | Enable/disable HTTP tracing |
113
+ | `exclude` | `ExcludePattern[]` | `RECOMMENDED_EXCLUDES` | Paths to exclude from tracing |
114
+
115
+ ### `instrumentations.fetch`
116
+
117
+ Controls outgoing fetch request tracing.
118
+
119
+ | Option | Type | Default | Description |
120
+ |--------|------|---------|-------------|
121
+ | `enabled` | `boolean` | `true` | Enable/disable fetch tracing |
122
+
123
+ ## Metrics
124
+
125
+ To export metrics, configure a metrics reader in your SDK (e.g., Prometheus exporter):
126
+
127
+ ```ts
128
+ // src/boot.ts
129
+ import { NodeSDK } from "@opentelemetry/sdk-node";
130
+ import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
131
+
132
+ const prometheusExporter = new PrometheusExporter({ port: 9464 });
133
+
134
+ const sdk = new NodeSDK({
135
+ serviceName: "my-astro-app",
136
+ metricReader: prometheusExporter,
137
+ });
138
+
139
+ export function onStartup() {
140
+ sdk.start();
141
+ }
142
+ ```
143
+
144
+ or use OTLP metrics exporter (for Grafana, Datadog, etc.):
145
+
146
+ ```ts
147
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
148
+ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
149
+
150
+ const metricReader = new PeriodicExportingMetricReader({
151
+ exporter: new OTLPMetricExporter(),
152
+ exportIntervalMillis: 60000,
153
+ });
154
+
155
+ const sdk = new NodeSDK({ metricReader });
156
+ ```
157
+
158
+ ### Collected Metrics
159
+
160
+ | Metric | Type | Unit | Description |
161
+ |--------|------|------|-------------|
162
+ | `http.server.request.duration` | Histogram | seconds | Duration of incoming HTTP requests |
163
+ | `http.server.active_requests` | UpDownCounter | requests | Number of active HTTP requests |
164
+ | `http.client.request.duration` | Histogram | seconds | Duration of outgoing fetch requests |
165
+ | `astro.action.duration` | Histogram | seconds | Duration of Astro action executions |
166
+
167
+ ### Metric Attributes
168
+
169
+ **HTTP Server metrics:**
170
+ - `http.request.method` - HTTP method (GET, POST, etc.)
171
+ - `http.route` - Request path
172
+ - `http.response.status_code` - Response status code
173
+
174
+ **HTTP Client (fetch) metrics:**
175
+ - `http.request.method` - HTTP method
176
+ - `server.address` - Target hostname
177
+ - `http.response.status_code` - Response status code
178
+
179
+ **Astro Action metrics:**
180
+ - `astro.action.name` - Action name (e.g., `newsletter.subscribe`)
181
+ - `http.response.status_code` - Response status code
182
+ ```
183
+
184
+ ### Host & Runtime Metrics
185
+
186
+ For system-level and Node.js runtime metrics (CPU, memory, event loop, GC), add these packages:
187
+
188
+ ```bash
189
+ npm install @opentelemetry/host-metrics @opentelemetry/instrumentation-runtime-node
190
+ ```
191
+
192
+ ```ts
193
+ // src/boot.ts
194
+ import { NodeSDK } from "@opentelemetry/sdk-node";
195
+ import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
196
+ import { HostMetrics } from "@opentelemetry/host-metrics";
197
+ import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
198
+
199
+ const sdk = new NodeSDK({
200
+ serviceName: "my-astro-app",
201
+ metricReader: new PrometheusExporter({ port: 9464 }),
202
+ instrumentations: [new RuntimeNodeInstrumentation()],
203
+ });
204
+
205
+ let hostMetrics: HostMetrics;
206
+
207
+ export function onStartup() {
208
+ sdk.start();
209
+
210
+ // the host metrics should be called after sdk.start()
211
+ hostMetrics = new HostMetrics({ name: "my-astro-app" });
212
+ hostMetrics.start();
213
+ }
214
+ ```
215
+
216
+ This adds:
217
+ - **Host metrics**: `process.cpu.*`, `system.cpu.*`, `system.memory.*`, `system.network.*`
218
+ - **Runtime metrics**: `nodejs.eventloop.delay.*`, `nodejs.gc.duration`, `nodejs.eventloop.utilization`
219
+
220
+ ## Component Tracing
221
+
222
+ Trace specific sections or components using the `<Trace>` component:
223
+
224
+ ```astro
225
+ ---
226
+ import { Trace } from "@astroscope/opentelemetry/components";
227
+ ---
228
+
229
+ <Trace name="hero">
230
+ <HeroSection />
231
+ </Trace>
232
+
233
+ <Trace name="sidebar">
234
+ <Sidebar />
235
+ </Trace>
236
+ ```
237
+
238
+ ### Props
239
+
240
+ | Prop | Type | Default | Description |
241
+ |------|------|---------|-------------|
242
+ | `name` | `string` | required | Span name |
243
+ | `params` | `Record<string, AttributeValue>` | `{}` | Custom span attributes |
244
+ | `enabled` | `boolean` | `true` | Enable/disable tracing (useful for conditional tracing) |
245
+ | `withTimings` | `boolean` | `false` | Enable accurate duration measurement |
246
+
247
+ ### Streaming vs Timing Mode
248
+
249
+ By default, `<Trace>` preserves Astro's streaming behavior by creating an instant marker span (`>> name`). This is safe to use anywhere without affecting performance.
250
+
251
+ ```astro
252
+ <!-- Default: streaming preserved, instant span -->
253
+ <Trace name="section">
254
+ <SlowComponent />
255
+ <FastComponent />
256
+ </Trace>
257
+ ```
258
+
259
+ For accurate render duration measurement, use `withTimings`:
260
+
261
+ ```astro
262
+ <!-- With timing: accurate duration, but buffers content -->
263
+ <Trace name="data-table" withTimings>
264
+ <DataTable data={data} />
265
+ </Trace>
266
+ ```
267
+
268
+ **Important:** `withTimings` buffers all children before streaming. Use it only when:
269
+ - Wrapping a **single component** where you need timing
270
+ - You understand it will block streaming for that section
271
+
272
+ ### Span Names
273
+
274
+ | Mode | Span Name | Description |
275
+ |------|-----------|-------------|
276
+ | `withTimings={false}` | `>> section-name` | Instant marker, no duration |
277
+ | `withTimings={true}` | `RENDER section-name` | Full render duration |
278
+
279
+ ### Conditional Tracing
280
+
281
+ Use `enabled` to conditionally trace (e.g., in recursive components):
282
+
283
+ ```astro
284
+ ---
285
+ const { depth = 0 } = Astro.props;
286
+ ---
287
+
288
+ <Trace name="tree-node" enabled={depth < 3} params={{ depth }}>
289
+ <TreeNode>
290
+ {children.map(child => <Astro.self depth={depth + 1} {...child} />)}
291
+ </TreeNode>
292
+ </Trace>
293
+ ```
294
+
295
+ ### Context Propagation
296
+
297
+ Nested `<Trace>` components with `withTimings` create proper parent-child relationships:
298
+
299
+ ```astro
300
+ <Trace name="page" withTimings>
301
+ <Trace name="header" withTimings>
302
+ <Header />
303
+ </Trace>
304
+ <Trace name="content" withTimings>
305
+ <Content />
306
+ </Trace>
307
+ </Trace>
308
+ ```
309
+
310
+ Results in:
311
+ ```
312
+ RENDER page
313
+ ├── RENDER header
314
+ └── RENDER content
315
+ ```
316
+
317
+ ## Exclude Patterns
318
+
319
+ **Pattern types:**
320
+
321
+ ```ts
322
+ exclude: [
323
+ { prefix: "/_astro" }, // path.startsWith("/_astro")
324
+ { exact: "/health" }, // path === "/health"
325
+ { pattern: /^\/api\/internal/ } // regex.test(path)
326
+ ]
327
+ ```
328
+
329
+ **Pre-built exclude lists:**
330
+
331
+ | Export | Description |
332
+ |--------|-------------|
333
+ | `RECOMMENDED_EXCLUDES` | All excludes below combined |
334
+ | `DEV_EXCLUDES` | Vite/Astro dev server (`/@vite/`, `/@fs/`, etc.) |
335
+ | `ASTRO_STATIC_EXCLUDES` | Astro static assets (`/_astro/`, `/_image`) |
336
+ | `STATIC_EXCLUDES` | Common static files (`/assets/`, `/favicon.ico`, etc.) |
337
+
338
+ ```ts
339
+ import {
340
+ opentelemetry,
341
+ RECOMMENDED_EXCLUDES,
342
+ } from "@astroscope/opentelemetry";
343
+
344
+ opentelemetry({
345
+ instrumentations: {
346
+ http: {
347
+ enabled: true,
348
+ exclude: [...RECOMMENDED_EXCLUDES, { exact: "/health" }],
349
+ },
350
+ },
351
+ })
352
+ ```
353
+
354
+ ## Trace Context Propagation
355
+
356
+ The middleware automatically extracts `traceparent` and `tracestate` headers from incoming requests, allowing traces to span across services.
357
+
358
+ ## Manual Setup
359
+
360
+ If you prefer manual control instead of using the integration:
361
+
362
+ ### Manual middleware
363
+
364
+ ```ts
365
+ // src/middleware.ts
366
+ import { sequence } from "astro:middleware";
367
+ import {
368
+ createOpenTelemetryMiddleware,
369
+ RECOMMENDED_EXCLUDES,
370
+ } from "@astroscope/opentelemetry";
371
+
372
+ export const onRequest = sequence(
373
+ createOpenTelemetryMiddleware({
374
+ exclude: [...RECOMMENDED_EXCLUDES, { exact: "/health" }],
375
+ })
376
+ );
377
+ ```
378
+
379
+ ### Manual fetch instrumentation
380
+
381
+ ```ts
382
+ // src/boot.ts
383
+ import { instrumentFetch } from "@astroscope/opentelemetry";
384
+
385
+ export function onStartup() {
386
+ instrumentFetch();
387
+ }
388
+ ```
389
+
390
+ ## Alternative: Native ESM Auto-Instrumentation
391
+
392
+ If you only need tracing in production builds, you can use OpenTelemetry's native ESM loader hooks instead of this middleware. This approach uses Node.js module hooks to auto-instrument libraries like `http`, `express`, `pg`, etc.
393
+
394
+ **Advantages:**
395
+
396
+ - Full auto-instrumentation (HTTP client requests, database queries, etc.)
397
+ - No middleware code required
398
+
399
+ **Disadvantages:**
400
+
401
+ - Only works in production builds (not in Vite dev mode)
402
+ - Not all instrumentations support ESM yet ([tracking issue](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/1942))
403
+
404
+ **Recommendation:** Use this package for Astro-specific tracing (HTTP, fetch, actions). For database and other library instrumentation, add only the specific instrumentations you need (e.g., `@opentelemetry/instrumentation-pg`) rather than `@opentelemetry/auto-instrumentations-node`, which pulls dozens of packages - most of which won't work in ESM anyway.
405
+
406
+ **Note:** When combining with native auto-instrumentation, you can disable the HTTP middleware (to avoid duplicate incoming request spans) while keeping fetch instrumentation (enabled by default):
407
+
408
+ ```ts
409
+ opentelemetry({
410
+ instrumentations: {
411
+ http: { enabled: false }, // let native instrumentation work for incoming requests
412
+ // fetch remains enabled by default - native doesn't support it in ESM yet
413
+ },
414
+ })
415
+ ```
416
+
417
+ ### Setup
418
+
419
+ 1. Create a `register.mjs` file:
420
+
421
+ ```js
422
+ // register.mjs
423
+ import { register } from "node:module";
424
+ import { NodeSDK } from "@opentelemetry/sdk-node";
425
+ import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
426
+
427
+ register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);
428
+
429
+ const sdk = new NodeSDK({
430
+ serviceName: "my-astro-app",
431
+ instrumentations: [getNodeAutoInstrumentations()],
432
+ });
433
+
434
+ sdk.start();
435
+
436
+ process.on("SIGTERM", () => {
437
+ sdk.shutdown().finally(() => process.exit(0));
438
+ });
439
+ ```
440
+
441
+ 2. Start your production server with the `--import` flag:
442
+
443
+ ```bash
444
+ node --import=./register.mjs ./dist/server/entry.mjs
445
+ ```
446
+
447
+ ## License
448
+
449
+ MIT
@@ -0,0 +1,146 @@
1
+ import {
2
+ recordHttpRequestDuration,
3
+ recordHttpRequestStart
4
+ } from "./chunk-UPNRPRAW.js";
5
+
6
+ // src/middleware.ts
7
+ import {
8
+ SpanKind,
9
+ SpanStatusCode,
10
+ context,
11
+ propagation,
12
+ trace
13
+ } from "@opentelemetry/api";
14
+ import { RPCType, setRPCMetadata } from "@opentelemetry/core";
15
+ var LIB_NAME = "@astroscope/opentelemetry";
16
+ var ACTIONS_PREFIX = "/_actions/";
17
+ function matchesPattern(path, pattern) {
18
+ if ("pattern" in pattern) {
19
+ return pattern.pattern.test(path);
20
+ }
21
+ if ("prefix" in pattern) {
22
+ return path.startsWith(pattern.prefix);
23
+ }
24
+ return path === pattern.exact;
25
+ }
26
+ function shouldExclude(ctx, exclude) {
27
+ if (!exclude) return false;
28
+ if (typeof exclude === "function") {
29
+ return exclude(ctx);
30
+ }
31
+ const path = ctx.url.pathname;
32
+ return exclude.some((pattern) => matchesPattern(path, pattern));
33
+ }
34
+ function getClientIp(request) {
35
+ return request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? request.headers.get("x-real-ip") ?? request.headers.get("cf-connecting-ip") ?? // Cloudflare
36
+ void 0;
37
+ }
38
+ function createOpenTelemetryMiddleware(options = {}) {
39
+ const tracer = trace.getTracer(LIB_NAME);
40
+ return async (ctx, next) => {
41
+ if (shouldExclude(ctx, options.exclude)) {
42
+ return next();
43
+ }
44
+ const { request, url } = ctx;
45
+ const input = {
46
+ traceparent: request.headers.get("traceparent"),
47
+ tracestate: request.headers.get("tracestate")
48
+ };
49
+ const parentContext = propagation.extract(context.active(), input);
50
+ const clientIp = getClientIp(request);
51
+ const contentLength = request.headers.get("content-length");
52
+ const spanOptions = {
53
+ attributes: {
54
+ "http.request.method": request.method,
55
+ "url.full": request.url,
56
+ "url.path": url.pathname,
57
+ "url.query": url.search.slice(1),
58
+ // Remove leading "?"
59
+ "url.scheme": url.protocol.replace(":", ""),
60
+ "server.address": url.hostname,
61
+ "server.port": url.port ? parseInt(url.port) : url.protocol === "https:" ? 443 : 80,
62
+ "user_agent.original": request.headers.get("user-agent") ?? "",
63
+ ...contentLength && { "http.request.body.size": parseInt(contentLength) },
64
+ ...clientIp && { "client.address": clientIp }
65
+ },
66
+ kind: SpanKind.SERVER
67
+ };
68
+ const isAction = url.pathname.startsWith(ACTIONS_PREFIX);
69
+ const actionName = url.pathname.slice(ACTIONS_PREFIX.length).replace(/\/$/, "");
70
+ const spanName = isAction ? `ACTION ${actionName}` : `${request.method} ${url.pathname}`;
71
+ const span = tracer.startSpan(spanName, spanOptions, parentContext);
72
+ const spanContext = trace.setSpan(parentContext, span);
73
+ const rpcMetadata = { type: RPCType.HTTP, span };
74
+ const metricsEnabled = options.metrics ?? false;
75
+ const startTime = metricsEnabled ? performance.now() : 0;
76
+ const endActiveRequest = metricsEnabled ? recordHttpRequestStart({ method: request.method, route: url.pathname }) : void 0;
77
+ return context.with(
78
+ setRPCMetadata(spanContext, rpcMetadata),
79
+ async () => {
80
+ const finalize = (status, responseSize) => {
81
+ span.setAttribute("http.response.status_code", status);
82
+ span.setAttribute("http.response.body.size", responseSize);
83
+ if (status >= 400) {
84
+ span.setStatus({
85
+ code: SpanStatusCode.ERROR,
86
+ message: `HTTP ${status}`
87
+ });
88
+ } else {
89
+ span.setStatus({ code: SpanStatusCode.OK });
90
+ }
91
+ span.end();
92
+ if (metricsEnabled) {
93
+ endActiveRequest?.();
94
+ recordHttpRequestDuration(
95
+ { method: request.method, route: url.pathname, status },
96
+ performance.now() - startTime
97
+ );
98
+ }
99
+ };
100
+ try {
101
+ const response = await next();
102
+ if (!response.body) {
103
+ finalize(response.status, 0);
104
+ return response;
105
+ }
106
+ const [measureStream, clientStream] = response.body.tee();
107
+ let responseSize = 0;
108
+ (async () => {
109
+ const reader = measureStream.getReader();
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done) break;
114
+ responseSize += value.length;
115
+ }
116
+ } finally {
117
+ finalize(response.status, responseSize);
118
+ }
119
+ })();
120
+ return new Response(clientStream, {
121
+ status: response.status,
122
+ headers: response.headers
123
+ });
124
+ } catch (e) {
125
+ span.setStatus({
126
+ code: SpanStatusCode.ERROR,
127
+ message: e instanceof Error ? e.message : "Unknown error"
128
+ });
129
+ span.end();
130
+ if (metricsEnabled) {
131
+ endActiveRequest?.();
132
+ recordHttpRequestDuration(
133
+ { method: request.method, route: url.pathname, status: 500 },
134
+ performance.now() - startTime
135
+ );
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+ );
141
+ };
142
+ }
143
+
144
+ export {
145
+ createOpenTelemetryMiddleware
146
+ };