@defjs/opentelemetry-server 0.0.1-beta.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/index.d.ts +65 -0
  4. package/index.js +491 -0
  5. package/package.json +29 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MunMunMiao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,290 @@
1
+ # @defjs/opentelemetry-server
2
+
3
+ Server-side outbound OpenTelemetry integration for `@defjs/core` HTTP, SSE, and WebSocket clients.
4
+
5
+ **Core Positioning**:
6
+
7
+ - **Outbound-only client instrumentation** — This package wraps defjs clients that make outbound requests. It is not inbound server instrumentation.
8
+ - **Server environment** — Designed for server-side applications with an OpenTelemetry SDK, not browser-only telemetry setup.
9
+ - **Does not initialize the SDK** — This package does not initialize an OpenTelemetry SDK. Register a `TracerProvider` (`setGlobalTracerProvider`, or your SDK's `start()`) then pass that tracer (and optional meter) into `withOpenTelemetryServer(...)`. `trace.getTracer(...)` with no provider is a silent no-op.
10
+ - **Per-transport separation** — HTTP, SSE, and WebSocket each have independent interceptors, span lifecycles, and metric dimensions.
11
+
12
+ ## Installation
13
+
14
+ Install `@defjs/opentelemetry-server` with the matching `@defjs/core` release line and its OpenTelemetry peers:
15
+
16
+ ```sh
17
+ bun add @defjs/core @defjs/opentelemetry-server @opentelemetry/api @opentelemetry/core
18
+ ```
19
+
20
+ The package is ESM and requires `@defjs/core` as a peer in the `^0.4.0` range. Its tarball retains this `README.md` and the repository `LICENSE`; repository-wide guides and examples remain outside the package.
21
+
22
+ Initialize your application's OpenTelemetry SDK separately so a `TracerProvider` is registered. The package provides interceptors and consumes the tracer you pass; it does not emit spans on its own.
23
+
24
+ ## Basic Usage
25
+
26
+ This package does not install a `TracerProvider`. `trace.getTracer('my-service')` without `trace.setGlobalTracerProvider(...)` (your SDK's `start()` usually does this) returns a no-op tracer: interceptors still wrap requests, but spans are silently discarded. Pass a recording tracer from your SDK, or register the provider first:
27
+
28
+ ```typescript
29
+ import { createClient, withEndpoint } from '@defjs/core'
30
+ import { withOpenTelemetryServer } from '@defjs/opentelemetry-server'
31
+ import { trace } from '@opentelemetry/api'
32
+
33
+ // After your OpenTelemetry SDK has called setGlobalTracerProvider:
34
+ const tracer = trace.getTracer('my-service')
35
+
36
+ const client = createClient(
37
+ withEndpoint('https://api.example.com'),
38
+ withOpenTelemetryServer({
39
+ tracer,
40
+ }),
41
+ )
42
+ ```
43
+
44
+ ## Operation Identity
45
+
46
+ Set the optional `operation` field statically on `defineRequest(...)`, `defineEventStream(...)`, or `defineWebSocket(...)`:
47
+
48
+ ```typescript
49
+ const readOrder = defineRequest({
50
+ method: 'GET',
51
+ operation: 'orders.read',
52
+ path: '/orders/:id',
53
+ // input and output omitted
54
+ })
55
+ ```
56
+
57
+ An explicit operation is the low-cardinality transport identity. HTTP spans use names such as `GET orders.read`; SSE and WebSocket spans use `SSE orders.watch` and `WebSocket orders.connect`. Spans and metrics also receive `defjs.operation`.
58
+
59
+ Without `operation`, the previous fallback remains unchanged: HTTP uses the request method as its span name, SSE uses `SSE`, WebSocket uses `WebSocket`, and `defjs.operation` is omitted. Never infer an operation from `req.endpoint`, a resolved URL, or a path containing identifiers. Do not copy resolved URLs into custom attributes or logs.
60
+
61
+ ## Full Configuration
62
+
63
+ ```typescript
64
+ const client = createClient(
65
+ withEndpoint('https://api.example.com'),
66
+ withOpenTelemetryServer({
67
+ tracer, // Required
68
+ meter, // Optional, metrics only collected when provided
69
+ propagator, // Optional, default W3C TraceContext + Baggage
70
+ requireParentSpan: false,
71
+ http: {
72
+ enabled: true,
73
+ startSpanHook(req) {
74
+ return { 'app.operation': req.operation ?? 'unclassified' }
75
+ },
76
+ responseHook(span, res, req) {
77
+ span.setAttribute('defjs.response.status_text', res.statusText)
78
+ },
79
+ },
80
+ sse: {
81
+ enabled: true,
82
+ },
83
+ webSocket: {
84
+ enabled: true,
85
+ queryPropagation: false, // Safe default; set true only after reviewing URL propagation
86
+ },
87
+ }),
88
+ )
89
+ ```
90
+
91
+ ## WebSocket propagation safety
92
+
93
+ `webSocket.queryPropagation` defaults to `false`. Omitting it leaves the WebSocket query string unchanged and does not inject trace context or baggage into the URL.
94
+
95
+ Browser WebSocket clients usually cannot set arbitrary headers. If you have reviewed URL-based propagation for your environment and accept its exposure boundary, enable it explicitly:
96
+
97
+ ```ts
98
+ withOpenTelemetryServer({
99
+ tracer,
100
+ webSocket: {
101
+ queryPropagation: true,
102
+ },
103
+ })
104
+ ```
105
+
106
+ Query strings can be recorded by proxies, browsers, APM tooling, access logs, and network debugging tools. They may also contain tokens or other high-cardinality user input. If your propagator includes `baggage`, baggage values can also be written into the URL and may contain sensitive data.
107
+
108
+ With the safe default, trace context does not ride on the WebSocket URL. Use another reviewed correlation mechanism at the application layer, such as a protocol-reviewed first frame or a short-lived connection ticket, if your server still needs to link the connection to a trace.
109
+
110
+ ### Configuration Options
111
+
112
+ | Option | Type | Default | Description |
113
+ | ------------------- | ------------------------------------- | -------------------------- | ------------------------------------------------------------ |
114
+ | `tracer` | `Tracer` | **Required** | External OpenTelemetry tracer |
115
+ | `meter` | `Meter` | `undefined` | External OpenTelemetry meter, omitting disables metrics |
116
+ | `propagator` | `TextMapPropagator` | W3C TraceContext + Baggage | Custom context propagator |
117
+ | `requireParentSpan` | `boolean` | `false` | Only create outbound spans when an active parent span exists |
118
+ | `http` | `OpenTelemetryServerHttpOptions` | `{}` | HTTP transport trace/metric options |
119
+ | `sse` | `OpenTelemetryServerSSEOptions` | `{}` | SSE transport trace/metric options |
120
+ | `webSocket` | `OpenTelemetryServerWebSocketOptions` | `{}` | WebSocket transport trace/metric options |
121
+
122
+ ### HTTP Options
123
+
124
+ | Option | Type | Default | Description |
125
+ | --------------- | ------------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
126
+ | `enabled` | `boolean` | `true` | Enable HTTP tracing |
127
+ | `startSpanHook` | `(req) => Attributes` | `undefined` | Add or override synchronous initial attributes before the HTTP span is created |
128
+ | `requestHook` | `(span, req) => void \| Promise<void>` | `undefined` | Customize HTTP span before request, `req` is `HttpRequest` |
129
+ | `responseHook` | `(span, res, req) => void \| Promise<void>` | `undefined` | Customize HTTP span after response; `req` is the original `HttpRequest` |
130
+
131
+ ### SSE Options
132
+
133
+ | Option | Type | Default | Description |
134
+ | --------------- | ---------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ |
135
+ | `enabled` | `boolean` | `true` | Enable SSE tracing |
136
+ | `startSpanHook` | `(req) => Attributes` | `undefined` | Add or override synchronous initial attributes before the SSE span is created |
137
+ | `requestHook` | `(span, req) => void \| Promise<void>` | `undefined` | Customize SSE span before stream request |
138
+ | `responseHook` | `(span, stream, req) => void \| Promise<void>` | `undefined` | Customize SSE span after stream handle returned; `req` is the original `HttpRequest` |
139
+
140
+ ### WebSocket Options
141
+
142
+ | Option | Type | Default | Description |
143
+ | ------------------ | ----------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `enabled` | `boolean` | `true` | Enable WebSocket tracing |
145
+ | `queryPropagation` | `boolean` | `false` | Inject trace context and baggage into the WebSocket URL query string. Enable only after reviewing URL logging and disclosure risks. |
146
+ | `startSpanHook` | `(req) => Attributes` | `undefined` | Add or override synchronous initial attributes before the WebSocket span is created |
147
+ | `requestHook` | `(span, req) => void \| Promise<void>` | `undefined` | Customize WebSocket span before connection request |
148
+ | `responseHook` | `(span, session, req) => void \| Promise<void>` | `undefined` | Customize WebSocket span after session returned; `req` is the original `HttpRequest` |
149
+
150
+ > **Hook timing and failures**: `startSpanHook(request)` is synchronous and runs before span creation. Its attributes are applied after built-in attributes. If it throws, Defjs creates the span with built-in attributes, records `defjs.otel.hook.error`, and the client request/stream/session continues normally. `requestHook` and `responseHook` run after creation and may return `void` or `Promise<void>`; their throws/rejections are recorded without blocking the operation.
151
+ >
152
+ > Each `responseHook` receives the original transport `HttpRequest` as its third argument. Read its explicit `operation`; do not reconstruct identity from its endpoint or a resolved URL.
153
+ >
154
+ > **Attribute hygiene**: This package has no built-in redactor or sensitive-key policy. Prefer explicit allowlists, application-owned redaction, and stable low-cardinality attributes in hooks. Do not attach resolved URLs, raw query strings, request or response bodies, full headers, baggage values, or message payloads unless your application has already reviewed privacy, cardinality, retention, and redaction requirements.
155
+
156
+ ## HTTP Semantic Conventions and Metrics
157
+
158
+ HTTP tracing uses stable OpenTelemetry HTTP client attribute and metric names. This is not a claim of complete semantic-convention compliance. With an explicit operation, the interceptor creates a `SpanKind.CLIENT` span named `${method} ${operation}` and records `defjs.operation`. Without one, it preserves the previous method-only span name and omits that attribute. It also records:
159
+
160
+ - `http.request.method`
161
+ - `url.full`
162
+ - `server.address`
163
+ - `server.port`
164
+ - `http.response.status_code` only when a response status was received
165
+
166
+ HTTP span status and `error.type` follow these rules:
167
+
168
+ - status `100` through `399` leaves span status unset and does not set `error.type`;
169
+ - status `400` and above marks the client span `ERROR` and sets `error.type` to the status code string;
170
+ - a Defjs status-0 transport result does not set `http.response.status_code`; caller cancellation leaves status unset, timeout uses `ERROR` / `TIMEOUT`, and other transport failures use `ERROR` / `NETWORK_ERROR`;
171
+ - an error thrown through the interceptor marks the span `ERROR`, records the exception, and uses its `Error.name` or another low-cardinality type fallback as `error.type`.
172
+
173
+ In the current implementation, `url.full` is resolved from `req.endpoint` and the optional `req.baseEndpoint`; it does not append `req.queryString`. This is an implementation boundary, not sanitization, an identity source, or a guarantee that URLs are safe. Applications that need a complete or redacted URL can override the initial value explicitly:
174
+
175
+ ```ts
176
+ import { createResolvedRequestUrl } from '@defjs/core'
177
+
178
+ withOpenTelemetryServer({
179
+ tracer,
180
+ http: {
181
+ startSpanHook(req) {
182
+ if (!req.baseEndpoint) return {}
183
+ const url = createResolvedRequestUrl(req.baseEndpoint, req.endpoint)
184
+ if (req.queryString) url.search = req.queryString
185
+ url.searchParams.delete('access_token')
186
+ return { 'url.full': url.href }
187
+ },
188
+ },
189
+ })
190
+ ```
191
+
192
+ Resolved paths can contain sensitive or high-cardinality identifiers. Keep operation names static and apply application or SDK/exporter redaction before exporting URL attributes.
193
+
194
+ When `meter` is provided, the following stable metrics are collected:
195
+
196
+ | Metric | Unit | Attributes |
197
+ | ------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `http.client.request.duration` | `s` | `http.request.method`, optional `defjs.operation`, `server.address`, `server.port`, optional `http.response.status_code`, optional `error.type` |
199
+
200
+ The metric applies the same response-status and `error.type` classification as the HTTP span.
201
+
202
+ ### Transport span boundary
203
+
204
+ The HTTP span ends when the interceptor receives the Defjs `HttpResponse`. Exact output-status dispatch and Struct decoding happen afterward, so a later `RESPONSE_VALIDATION_FAILED` or `UNDECLARED_STATUS` cannot update that transport span.
205
+
206
+ When telemetry must represent the final command outcome, create an application span around `client.execute(...)` and classify the returned tuple:
207
+
208
+ ```ts
209
+ import { SpanStatusCode } from '@opentelemetry/api'
210
+
211
+ const outcome = await tracer.startActiveSpan('defjs.command', async (span) => {
212
+ try {
213
+ const outcome = await client.execute(command)
214
+ const [error] = outcome
215
+ if (error) {
216
+ span.setAttribute('error.type', error.code)
217
+ span.setStatus({ code: SpanStatusCode.ERROR })
218
+ }
219
+ return outcome
220
+ } catch (error) {
221
+ span.setAttribute('error.type', error instanceof Error ? error.name : typeof error)
222
+ span.setStatus({ code: SpanStatusCode.ERROR })
223
+ throw error
224
+ } finally {
225
+ span.end()
226
+ }
227
+ })
228
+ ```
229
+
230
+ This outer span is application-owned command telemetry; the plugin continues to emit only its lower-level transport span.
231
+
232
+ This package does not add request or response bodies, full headers, baggage values, payload sizes, or message payloads as default custom telemetry fields. It also does not create separate span attributes or metrics for raw query strings.
233
+
234
+ Do not add raw query strings, request or response bodies, full headers, baggage values, or message payloads to spans or metrics unless the application has reviewed privacy, cardinality, retention, and redaction requirements. Prefer explicit allowlists, redaction, and stable low-cardinality attributes when extending telemetry with hooks.
235
+
236
+ ## SSE Connection-Level Tracing and Custom Metrics
237
+
238
+ SSE is a long-lived HTTP response. Normal HTTP request duration ends at stream establishment, which does not reflect whether the stream is still running, interrupted, or errored. Therefore, this package treats SSE as **connection-level** telemetry.
239
+
240
+ ### Span Lifecycle
241
+
242
+ After startup succeeds, the SSE interceptor attaches to `stream.closed` and keeps the span open until the stream reaches its normal, fatal, or aborted terminal state. It records:
243
+
244
+ - `sse.connected` — Stream successfully established
245
+ - `sse.closed` — Stream normal end (server EOF)
246
+ - `sse.aborted` — Active close via `stream.close()`
247
+ - `sse.error` — Connection error or reconnect exhaustion
248
+
249
+ ### Custom Metrics
250
+
251
+ When `meter` is provided, the following defjs custom metrics are collected (non-official OpenTelemetry stable semantic conventions):
252
+
253
+ | Metric | Unit | Meaning |
254
+ | -------------------------------------- | ---------- | ------------------------------------------------------- |
255
+ | `defjs.client.sse.connect.duration` | `s` | Time to establish stream connection |
256
+ | `defjs.client.sse.connection.duration` | `s` | Total duration from stream establishment to close/error |
257
+ | `defjs.client.sse.active_streams` | `{stream}` | Current active SSE stream count |
258
+
259
+ By default, **per-event spans are not created**, and **event payloads, event IDs, `Last-Event-ID`, delivery latency, lost events, or reconnect queues are not collected**. These are application-level semantics that may produce high-cardinality or sensitive telemetry. Implement them at the application layer if needed.
260
+
261
+ ## WebSocket Connection-Level Tracing and Custom Metrics
262
+
263
+ WebSocket starts with an HTTP Upgrade handshake, but production environments care more about the post-handshake connection lifecycle: active connections, connection duration, close/error behavior, and connection failure rate. Since OpenTelemetry WebSocket semantic conventions are not yet stable, this package uses connection-level custom metrics.
264
+
265
+ ### Span Lifecycle
266
+
267
+ The WebSocket span stays open until `session.closed` resolves, recording the following lifecycle events:
268
+
269
+ - `websocket.connected` — Session successfully established
270
+ - `websocket.closed` — Connection normal close
271
+ - `websocket.error` — Connection error
272
+
273
+ ### Custom Metrics
274
+
275
+ When `meter` is provided, the following defjs custom metrics are collected:
276
+
277
+ | Metric | Unit | Meaning |
278
+ | -------------------------------------------- | -------------- | -------------------------------------------------------- |
279
+ | `defjs.client.websocket.connect.duration` | `s` | Time to establish WebSocket session |
280
+ | `defjs.client.websocket.connection.duration` | `s` | Total duration from session establishment to close/error |
281
+ | `defjs.client.websocket.active_connections` | `{connection}` | Current active WebSocket connection count |
282
+
283
+ By default, **per-message spans are not created**, and **message payloads, message sizes, backpressure, buffered amount, subprotocols, or reconnect queues are not collected**. Keep any application-layer message telemetry opt-in, sampled, redacted, and limited to stable low-cardinality attributes whenever possible.
284
+
285
+ ## What's Next
286
+
287
+ - [OpenTelemetry guide](https://defjs.org/plugins/opentelemetry-server) — complete plugin boundaries and examples
288
+ - [Client](https://defjs.org/core/client) — `createClient` and full transport configuration
289
+ - [SSE](https://defjs.org/core/sse) — `defineEventStream` and streaming event consumption
290
+ - [WebSocket](https://defjs.org/core/web-socket) — `defineWebSocket` and real-time communication
package/index.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { ClientOption, EventStreamHandle, HttpRequest, HttpResponse, WebSocketSessionLike } from "@defjs/core";
2
+ import { Attributes, Meter, Span, TextMapPropagator, Tracer } from "@opentelemetry/api";
3
+ //#region src/option.d.ts
4
+ /** Shared per-transport OpenTelemetry client options. */
5
+ interface OpenTelemetryServerTransportOptions<TResponse> {
6
+ /** Whether this transport's instrumentation is enabled. Defaults to `true`. */
7
+ enabled?: boolean;
8
+ /** Add application attributes when the client span is created. */
9
+ startSpanHook?: (request: HttpRequest) => Attributes;
10
+ /** Customize the client span before the request is sent. */
11
+ requestHook?: (span: Span, req: HttpRequest) => Promise<void> | void;
12
+ /** Customize the client span after the transport result is returned. */
13
+ responseHook?: (span: Span, res: TResponse, req: HttpRequest) => Promise<void> | void;
14
+ }
15
+ type OpenTelemetryServerHttpOptions = OpenTelemetryServerTransportOptions<HttpResponse<unknown>>;
16
+ type OpenTelemetryServerSSEOptions = OpenTelemetryServerTransportOptions<EventStreamHandle<unknown>>;
17
+ interface OpenTelemetryServerWebSocketOptions extends OpenTelemetryServerTransportOptions<WebSocketSessionLike> {
18
+ /**
19
+ * Inject trace context into the WebSocket URL query string.
20
+ * Defaults to `false`; enable only after reviewing URL propagation exposure.
21
+ */
22
+ queryPropagation?: boolean;
23
+ }
24
+ /**
25
+ * Options for {@link withOpenTelemetryServer}.
26
+ *
27
+ * Pass an application-owned OpenTelemetry `Tracer` (and optional `Meter`).
28
+ * This package does not initialize the OpenTelemetry SDK.
29
+ */
30
+ interface OpenTelemetryServerOptions {
31
+ /** OpenTelemetry tracer used for outbound client spans. */
32
+ tracer: Tracer;
33
+ /** Optional meter; when set, per-transport client metrics are recorded. */
34
+ meter?: Meter;
35
+ /** Context propagator. Defaults to W3C Trace Context + Baggage. */
36
+ propagator?: TextMapPropagator;
37
+ /** When `true`, only create an outgoing span if an active parent span exists. */
38
+ requireParentSpan?: boolean;
39
+ /** HTTP transport instrumentation options. */
40
+ http?: OpenTelemetryServerHttpOptions;
41
+ /** SSE transport instrumentation options. */
42
+ sse?: OpenTelemetryServerSSEOptions;
43
+ /** WebSocket transport instrumentation options. */
44
+ webSocket?: OpenTelemetryServerWebSocketOptions;
45
+ }
46
+ /**
47
+ * Client option that installs outbound OpenTelemetry interceptors for HTTP, SSE, and WebSocket.
48
+ *
49
+ * Does not initialize the OpenTelemetry SDK — supply a `tracer` (and optional `meter`) from your app.
50
+ * Each transport can be toggled or customized via `http`, `sse`, and `webSocket`.
51
+ *
52
+ * @param options - Tracer, optional meter/propagator, and per-transport settings.
53
+ * @returns A `ClientOption` for `createClient(...)`.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const client = createClient(
58
+ * withEndpoint('https://api.example.com'),
59
+ * withOpenTelemetryServer({ tracer }),
60
+ * )
61
+ * ```
62
+ */
63
+ export declare function withOpenTelemetryServer(options: OpenTelemetryServerOptions): ClientOption;
64
+ //#endregion
65
+ export type { OpenTelemetryServerHttpOptions, OpenTelemetryServerOptions, OpenTelemetryServerSSEOptions, OpenTelemetryServerTransportOptions, OpenTelemetryServerWebSocketOptions };
package/index.js ADDED
@@ -0,0 +1,491 @@
1
+ import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from "@opentelemetry/core";
2
+ import { ERR_ABORTED, createHttpInterceptor, createResolvedRequestUrl, createSSEInterceptor, createTransportError, createWebSocketInterceptor } from "@defjs/core";
3
+ import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
4
+ //#region src/propagation/carrier.ts
5
+ const headersSetter = { set(carrier, key, value) {
6
+ if (carrier && key && value !== void 0) carrier.set(key, String(value));
7
+ } };
8
+ const headersGetter = {
9
+ keys(carrier) {
10
+ if (!carrier) return [];
11
+ return Array.from(carrier.keys());
12
+ },
13
+ get(carrier, key) {
14
+ if (!carrier || !key) return;
15
+ return carrier.get(key) ?? void 0;
16
+ }
17
+ };
18
+ const queryStringSetter = { set(carrier, key, value) {
19
+ if (carrier && key && value !== void 0) carrier.params.append(key, String(value));
20
+ } };
21
+ //#endregion
22
+ //#region src/telemetry/trace.ts
23
+ const spanStatusSet = /* @__PURE__ */ new WeakMap();
24
+ function markSpanStatusSet(span) {
25
+ spanStatusSet.set(span, true);
26
+ }
27
+ function resolveStartSpanHook(hook, request) {
28
+ if (!hook) return { ok: true };
29
+ try {
30
+ return {
31
+ ok: true,
32
+ attributes: hook(request)
33
+ };
34
+ } catch (error) {
35
+ return {
36
+ ok: false,
37
+ error
38
+ };
39
+ }
40
+ }
41
+ function createHttpSpan(tracer, method, url, parentCtx, operation, startAttributes) {
42
+ return tracer.startSpan(operation ? `${method} ${operation}` : method, {
43
+ kind: SpanKind.CLIENT,
44
+ attributes: {
45
+ ...operation ? { "defjs.operation": operation } : {},
46
+ "http.request.method": method,
47
+ "url.full": url,
48
+ ...startAttributes
49
+ }
50
+ }, parentCtx);
51
+ }
52
+ function createSSESpan(tracer, url, parentCtx, operation, startAttributes) {
53
+ return tracer.startSpan(operation ? `SSE ${operation}` : "SSE", {
54
+ kind: SpanKind.CLIENT,
55
+ attributes: {
56
+ ...operation ? { "defjs.operation": operation } : {},
57
+ "url.full": url,
58
+ ...startAttributes
59
+ }
60
+ }, parentCtx);
61
+ }
62
+ function createWebSocketSpan(tracer, url, parentCtx, operation, startAttributes) {
63
+ return tracer.startSpan(operation ? `WebSocket ${operation}` : "WebSocket", {
64
+ kind: SpanKind.CLIENT,
65
+ attributes: {
66
+ ...operation ? { "defjs.operation": operation } : {},
67
+ "url.full": url,
68
+ ...startAttributes
69
+ }
70
+ }, parentCtx);
71
+ }
72
+ function setSpanHttpResponse(span, status, error) {
73
+ if (status !== 0) span.setAttribute("http.response.status_code", status);
74
+ const errorType = getHttpResponseErrorType(status, error);
75
+ if (errorType) {
76
+ span.setAttribute("error.type", errorType);
77
+ span.setStatus({ code: SpanStatusCode.ERROR });
78
+ }
79
+ markSpanStatusSet(span);
80
+ span.end();
81
+ }
82
+ function setSpanError(span, error) {
83
+ span.recordException(toError(error));
84
+ span.setAttribute("error.type", getErrorType(error));
85
+ span.setStatus({ code: SpanStatusCode.ERROR });
86
+ markSpanStatusSet(span);
87
+ span.end();
88
+ }
89
+ function endSpan(span) {
90
+ if (!spanStatusSet.has(span)) span.setStatus({ code: SpanStatusCode.OK });
91
+ span.end();
92
+ }
93
+ function runSpanHook(span, hookName, hook) {
94
+ if (!hook) return;
95
+ try {
96
+ const result = hook();
97
+ Promise.resolve(result).catch((error) => recordSpanHookError(span, hookName, error));
98
+ } catch (error) {
99
+ recordSpanHookError(span, hookName, error);
100
+ }
101
+ }
102
+ function recordSpanHookError(span, hookName, error) {
103
+ try {
104
+ span.addEvent("defjs.otel.hook.error", {
105
+ "hook.name": hookName,
106
+ "error.type": getErrorType(error)
107
+ });
108
+ } catch {}
109
+ try {
110
+ span.recordException(toError(error));
111
+ } catch {}
112
+ }
113
+ function addSpanEvent(span, eventName, attributes) {
114
+ if (attributes) {
115
+ span.addEvent(eventName, attributes);
116
+ return;
117
+ }
118
+ span.addEvent(eventName);
119
+ }
120
+ function toError(error) {
121
+ return error instanceof Error ? error : new Error(String(error));
122
+ }
123
+ function getHttpResponseErrorType(status, error) {
124
+ if (status === 0) {
125
+ const errorCode = createTransportError(error).code;
126
+ return errorCode === "ABORTED" ? void 0 : errorCode;
127
+ }
128
+ if (status >= 400) return String(status);
129
+ }
130
+ function getErrorType(error) {
131
+ if (error instanceof Error) return error.name || "Error";
132
+ if (error === void 0 || error === null) return "_OTHER";
133
+ return typeof error;
134
+ }
135
+ //#endregion
136
+ //#region src/telemetry/url.ts
137
+ function resolveUrl(endpoint, baseEndpoint) {
138
+ if (!baseEndpoint) return endpoint;
139
+ try {
140
+ return createResolvedRequestUrl(baseEndpoint, endpoint).toString();
141
+ } catch {
142
+ return endpoint;
143
+ }
144
+ }
145
+ function resolveHttpUrl(endpoint, baseEndpoint) {
146
+ if (!baseEndpoint) return { url: endpoint };
147
+ try {
148
+ const parsed = createResolvedRequestUrl(baseEndpoint, endpoint);
149
+ return {
150
+ url: parsed.toString(),
151
+ serverAddress: parsed.hostname,
152
+ serverPort: Number.parseInt(parsed.port) || void 0
153
+ };
154
+ } catch {
155
+ return { url: endpoint };
156
+ }
157
+ }
158
+ //#endregion
159
+ //#region src/telemetry/metrics.ts
160
+ const DURATION_BUCKETS = [
161
+ .005,
162
+ .01,
163
+ .025,
164
+ .05,
165
+ .075,
166
+ .1,
167
+ .25,
168
+ .5,
169
+ .75,
170
+ 1,
171
+ 2.5,
172
+ 5,
173
+ 7.5,
174
+ 10
175
+ ];
176
+ function createHttpClientMetrics(meter) {
177
+ return { requestDuration: meter.createHistogram("http.client.request.duration", {
178
+ description: "Duration of HTTP client requests",
179
+ unit: "s",
180
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
181
+ }) };
182
+ }
183
+ function createSSEClientMetrics(meter) {
184
+ return {
185
+ connectDuration: meter.createHistogram("defjs.client.sse.connect.duration", {
186
+ description: "Duration of SSE connection attempts",
187
+ unit: "s",
188
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
189
+ }),
190
+ connectionDuration: meter.createHistogram("defjs.client.sse.connection.duration", {
191
+ description: "Duration of SSE stream connections",
192
+ unit: "s",
193
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
194
+ }),
195
+ activeStreams: meter.createUpDownCounter("defjs.client.sse.active_streams", {
196
+ description: "Number of active SSE streams",
197
+ unit: "{stream}"
198
+ })
199
+ };
200
+ }
201
+ function createWebSocketClientMetrics(meter) {
202
+ return {
203
+ connectDuration: meter.createHistogram("defjs.client.websocket.connect.duration", {
204
+ description: "Duration of WebSocket connection attempts",
205
+ unit: "s",
206
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
207
+ }),
208
+ connectionDuration: meter.createHistogram("defjs.client.websocket.connection.duration", {
209
+ description: "Duration of WebSocket connections",
210
+ unit: "s",
211
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
212
+ }),
213
+ activeConnections: meter.createUpDownCounter("defjs.client.websocket.active_connections", {
214
+ description: "Number of active WebSocket connections",
215
+ unit: "{connection}"
216
+ })
217
+ };
218
+ }
219
+ function durationSeconds(startMs, endMs = performance.now()) {
220
+ return (endMs - startMs) / 1e3;
221
+ }
222
+ function createHttpMetricAttributes(req, response, error) {
223
+ const attributes = {
224
+ ...createServerMetricAttributes(req),
225
+ "http.request.method": req.method
226
+ };
227
+ const errorType = response ? getHttpResponseErrorType(response.status, response.error) : createErrorMetricAttributes(error)["error.type"];
228
+ if (errorType) attributes["error.type"] = errorType;
229
+ if (response && response.status !== 0) attributes["http.response.status_code"] = response.status;
230
+ return attributes;
231
+ }
232
+ function createConnectionMetricAttributes(req, result, extras) {
233
+ return {
234
+ ...createServerMetricAttributes(req),
235
+ "defjs.result": result,
236
+ ...extras
237
+ };
238
+ }
239
+ function createServerMetricAttributes(req) {
240
+ const { serverAddress, serverPort } = resolveHttpUrl(req.endpoint, req.baseEndpoint);
241
+ const attributes = {};
242
+ if (req.operation) attributes["defjs.operation"] = req.operation;
243
+ if (serverAddress) attributes["server.address"] = serverAddress;
244
+ if (serverPort) attributes["server.port"] = serverPort;
245
+ return attributes;
246
+ }
247
+ function createErrorMetricAttributes(error) {
248
+ if (error === void 0 || error === null) return {};
249
+ return { "error.type": getErrorType(error) };
250
+ }
251
+ //#endregion
252
+ //#region src/interceptor/http.ts
253
+ function createOpenTelemetryHttpInterceptor(options) {
254
+ return createHttpInterceptor(async (req, next) => {
255
+ const { tracer, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
256
+ if (requireParentSpan && !trace.getActiveSpan()) return next(req);
257
+ const parentCtx = propagator.extract(context.active(), req.headers ?? new Headers(), headersGetter);
258
+ const { url, serverAddress, serverPort } = resolveHttpUrl(req.endpoint, req.baseEndpoint);
259
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
260
+ const span = createHttpSpan(tracer, req.method, url, parentCtx, req.operation, {
261
+ ...serverAddress ? { "server.address": serverAddress } : {},
262
+ ...serverPort ? { "server.port": serverPort } : {},
263
+ ...startSpanHookResult.ok ? startSpanHookResult.attributes : {}
264
+ });
265
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
266
+ throw startSpanHookResult.error;
267
+ });
268
+ runSpanHook(span, "requestHook", () => requestHook?.(span, req));
269
+ const spanCtx = trace.setSpan(parentCtx, span);
270
+ const headers = req.headers instanceof Headers ? req.headers : new Headers(req.headers);
271
+ propagator.inject(spanCtx, headers, headersSetter);
272
+ const startTime = performance.now();
273
+ try {
274
+ const response = await next({
275
+ ...req,
276
+ headers
277
+ });
278
+ const durationS = durationSeconds(startTime);
279
+ runSpanHook(span, "responseHook", () => responseHook?.(span, response, req));
280
+ setSpanHttpResponse(span, response.status, response.error);
281
+ metrics?.requestDuration.record(durationS, createHttpMetricAttributes(req, response));
282
+ return response;
283
+ } catch (error) {
284
+ const durationS = durationSeconds(startTime);
285
+ setSpanError(span, error);
286
+ metrics?.requestDuration.record(durationS, createHttpMetricAttributes(req, void 0, error));
287
+ throw error;
288
+ }
289
+ });
290
+ }
291
+ //#endregion
292
+ //#region src/interceptor/sse.ts
293
+ function createOpenTelemetrySSEInterceptor(options) {
294
+ return createSSEInterceptor(async (req, next) => {
295
+ const { tracer, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
296
+ if (requireParentSpan && !trace.getActiveSpan()) return next(req);
297
+ const parentCtx = propagator.extract(context.active(), req.headers ?? new Headers(), headersGetter);
298
+ const url = resolveUrl(req.endpoint, req.baseEndpoint);
299
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
300
+ const span = createSSESpan(tracer, url, parentCtx, req.operation, startSpanHookResult.ok ? startSpanHookResult.attributes : void 0);
301
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
302
+ throw startSpanHookResult.error;
303
+ });
304
+ const spanCtx = trace.setSpan(parentCtx, span);
305
+ const headers = req.headers instanceof Headers ? req.headers : new Headers(req.headers);
306
+ propagator.inject(spanCtx, headers, headersSetter);
307
+ runSpanHook(span, "requestHook", () => requestHook?.(span, req));
308
+ const connectStartMs = performance.now();
309
+ try {
310
+ const stream = await next({
311
+ ...req,
312
+ headers
313
+ });
314
+ const connectedAtMs = performance.now();
315
+ const activeAttributes = createServerMetricAttributes(req);
316
+ runSpanHook(span, "responseHook", () => responseHook?.(span, stream, req));
317
+ addSpanEvent(span, "sse.connected");
318
+ metrics?.connectDuration.record(durationSeconds(connectStartMs, connectedAtMs), createConnectionMetricAttributes(req, "success"));
319
+ metrics?.activeStreams.add(1, activeAttributes);
320
+ const closeActiveStream = () => {
321
+ metrics?.activeStreams.add(-1, activeAttributes);
322
+ };
323
+ stream.closed.then((closeInfo) => {
324
+ closeActiveStream();
325
+ const closeAttributes = {
326
+ "sse.close.code": closeInfo.code,
327
+ ...closeInfo.code === "error" ? { "defjs.sse.error.code": closeInfo.errorCode } : {}
328
+ };
329
+ if (closeInfo.code === "error") {
330
+ addSpanEvent(span, "sse.error", closeAttributes);
331
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", {
332
+ ...closeAttributes,
333
+ ...createErrorMetricAttributes(closeInfo.cause)
334
+ }));
335
+ setSpanError(span, closeInfo.cause);
336
+ return;
337
+ }
338
+ addSpanEvent(span, closeInfo.code === "aborted" ? "sse.aborted" : "sse.closed", closeAttributes);
339
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "success", closeAttributes));
340
+ endSpan(span);
341
+ }, (error) => {
342
+ closeActiveStream();
343
+ addSpanEvent(span, "sse.error", createErrorMetricAttributes(error));
344
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
345
+ setSpanError(span, error);
346
+ });
347
+ return stream;
348
+ } catch (error) {
349
+ metrics?.connectDuration.record(durationSeconds(connectStartMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
350
+ setSpanError(span, error);
351
+ throw error;
352
+ }
353
+ });
354
+ }
355
+ //#endregion
356
+ //#region src/interceptor/web_socket.ts
357
+ function createOpenTelemetryWebSocketInterceptor(options) {
358
+ return createWebSocketInterceptor(async (req, next) => {
359
+ const { tracer, propagator, metrics, requireParentSpan, queryPropagation = false, startSpanHook, requestHook, responseHook } = options;
360
+ if (requireParentSpan && !trace.getActiveSpan()) return next(req);
361
+ const url = resolveUrl(req.endpoint, req.baseEndpoint);
362
+ const parentCtx = propagator.extract(context.active(), req.headers ?? new Headers(), headersGetter);
363
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
364
+ const span = createWebSocketSpan(tracer, url, parentCtx, req.operation, startSpanHookResult.ok ? startSpanHookResult.attributes : void 0);
365
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
366
+ throw startSpanHookResult.error;
367
+ });
368
+ const spanCtx = trace.setSpan(parentCtx, span);
369
+ let queryParams = req.queryParams;
370
+ let queryString = req.queryString;
371
+ if (queryPropagation) {
372
+ const originalParams = new URLSearchParams(queryParams);
373
+ const carrier = { params: new URLSearchParams(queryParams) };
374
+ propagator.inject(spanCtx, carrier, queryStringSetter);
375
+ queryParams = carrier.params;
376
+ queryString = appendInjectedQueryString(queryString, originalParams, carrier.params);
377
+ }
378
+ runSpanHook(span, "requestHook", () => requestHook?.(span, req));
379
+ const connectStartMs = performance.now();
380
+ try {
381
+ const session = await next({
382
+ ...req,
383
+ queryParams,
384
+ queryString
385
+ });
386
+ const connectedAtMs = performance.now();
387
+ const activeAttributes = createServerMetricAttributes(req);
388
+ runSpanHook(span, "responseHook", () => responseHook?.(span, session, req));
389
+ addSpanEvent(span, "websocket.connected");
390
+ metrics?.connectDuration.record(durationSeconds(connectStartMs, connectedAtMs), createConnectionMetricAttributes(req, "success"));
391
+ metrics?.activeConnections.add(1, activeAttributes);
392
+ const closeActiveConnection = () => {
393
+ metrics?.activeConnections.add(-1, activeAttributes);
394
+ };
395
+ session.closed.then((closeInfo) => {
396
+ closeActiveConnection();
397
+ const runtimeCloseInfo = typeof closeInfo === "object" && closeInfo !== null ? closeInfo : {};
398
+ const closeKind = runtimeCloseInfo.kind;
399
+ const closeCause = runtimeCloseInfo.cause;
400
+ if (typeof closeKind === "string" ? closeKind !== "closed" : typeof closeCause !== "undefined") {
401
+ const error = typeof closeKind === "string" ? closeKind === "error" ? closeCause : closeCause ?? ERR_ABORTED : closeCause;
402
+ addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
403
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
404
+ setSpanError(span, error);
405
+ return;
406
+ }
407
+ addSpanEvent(span, "websocket.closed");
408
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "success"));
409
+ endSpan(span);
410
+ }, (error) => {
411
+ closeActiveConnection();
412
+ addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
413
+ metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
414
+ setSpanError(span, error);
415
+ });
416
+ return session;
417
+ } catch (error) {
418
+ metrics?.connectDuration.record(durationSeconds(connectStartMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
419
+ setSpanError(span, error);
420
+ throw error;
421
+ }
422
+ });
423
+ }
424
+ function appendInjectedQueryString(queryString, originalParams, nextParams) {
425
+ const injectedParams = new URLSearchParams();
426
+ nextParams.forEach((value, key) => {
427
+ if (originalParams.getAll(key).includes(value)) return;
428
+ injectedParams.append(key, value);
429
+ });
430
+ const injectedQueryString = injectedParams.toString();
431
+ if (!injectedQueryString) return queryString ?? "";
432
+ if (!queryString) return injectedQueryString;
433
+ return `${queryString}&${injectedQueryString}`;
434
+ }
435
+ //#endregion
436
+ //#region src/option.ts
437
+ /**
438
+ * Client option that installs outbound OpenTelemetry interceptors for HTTP, SSE, and WebSocket.
439
+ *
440
+ * Does not initialize the OpenTelemetry SDK — supply a `tracer` (and optional `meter`) from your app.
441
+ * Each transport can be toggled or customized via `http`, `sse`, and `webSocket`.
442
+ *
443
+ * @param options - Tracer, optional meter/propagator, and per-transport settings.
444
+ * @returns A `ClientOption` for `createClient(...)`.
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * const client = createClient(
449
+ * withEndpoint('https://api.example.com'),
450
+ * withOpenTelemetryServer({ tracer }),
451
+ * )
452
+ * ```
453
+ */
454
+ function withOpenTelemetryServer(options) {
455
+ const { tracer, meter, propagator = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }), requireParentSpan = false } = options;
456
+ return (config) => {
457
+ if (isTransportEnabled(options.http)) config.interceptors.push(createOpenTelemetryHttpInterceptor({
458
+ tracer,
459
+ propagator,
460
+ metrics: meter ? createHttpClientMetrics(meter) : void 0,
461
+ requireParentSpan,
462
+ startSpanHook: options.http?.startSpanHook,
463
+ requestHook: options.http?.requestHook,
464
+ responseHook: options.http?.responseHook
465
+ }));
466
+ if (isTransportEnabled(options.sse)) config.interceptors.push(createOpenTelemetrySSEInterceptor({
467
+ tracer,
468
+ propagator,
469
+ metrics: meter ? createSSEClientMetrics(meter) : void 0,
470
+ requireParentSpan,
471
+ startSpanHook: options.sse?.startSpanHook,
472
+ requestHook: options.sse?.requestHook,
473
+ responseHook: options.sse?.responseHook
474
+ }));
475
+ if (isTransportEnabled(options.webSocket)) config.interceptors.push(createOpenTelemetryWebSocketInterceptor({
476
+ tracer,
477
+ propagator,
478
+ metrics: meter ? createWebSocketClientMetrics(meter) : void 0,
479
+ requireParentSpan,
480
+ queryPropagation: options.webSocket?.queryPropagation,
481
+ startSpanHook: options.webSocket?.startSpanHook,
482
+ requestHook: options.webSocket?.requestHook,
483
+ responseHook: options.webSocket?.responseHook
484
+ }));
485
+ };
486
+ }
487
+ function isTransportEnabled(option) {
488
+ return option?.enabled !== false;
489
+ }
490
+ //#endregion
491
+ export { withOpenTelemetryServer };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@defjs/opentelemetry-server",
3
+ "version": "0.0.1-beta.0",
4
+ "bugs": {
5
+ "url": "https://github.com/defjs/defjs/issues"
6
+ },
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/defjs/defjs.git"
11
+ },
12
+ "type": "module",
13
+ "main": "./index.js",
14
+ "types": "./index.d.ts",
15
+ "peerDependencies": {
16
+ "@defjs/core": "0.4.0-beta.0",
17
+ "@opentelemetry/api": "^1.0.0",
18
+ "@opentelemetry/core": "^2.0.0"
19
+ },
20
+ "module": "./index.js",
21
+ "typings": "./index.d.ts",
22
+ "exports": {
23
+ "./package.json": "./package.json",
24
+ ".": {
25
+ "types": "./index.d.ts",
26
+ "default": "./index.js"
27
+ }
28
+ }
29
+ }