@defjs/opentelemetry-server 0.0.1-beta.0 → 0.0.2

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 (4) hide show
  1. package/README.md +61 -248
  2. package/index.d.ts +103 -18
  3. package/index.js +238 -109
  4. package/package.json +7 -3
package/README.md CHANGED
@@ -1,290 +1,103 @@
1
1
  # @defjs/opentelemetry-server
2
2
 
3
- Server-side outbound OpenTelemetry integration for `@defjs/core` HTTP, SSE, and WebSocket clients.
3
+ Server-side outbound OpenTelemetry for `@defjs/core` HTTP, SSE, and WebSocket clients.
4
4
 
5
- **Core Positioning**:
5
+ It is a middleware plugin — the plugin form of an interceptor chain. Your application keeps its OpenTelemetry SDK, providers, exporters, and shutdown; the imported SDK definitions stay free of telemetry configuration. This package is outbound client instrumentation for server applications, not inbound server instrumentation.
6
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:
7
+ ## Install
15
8
 
16
9
  ```sh
17
10
  bun add @defjs/core @defjs/opentelemetry-server @opentelemetry/api @opentelemetry/core
18
11
  ```
19
12
 
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.
13
+ The package is ESM and requires `@defjs/core` as a peer in the `^0.5.0` range.
21
14
 
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.
15
+ ## Quick start
23
16
 
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:
17
+ It never initializes a provider or looks one up implicitly: pass the `tracer`, `meter`, and `logger` you want, and each signal works independently. Without a `tracer` no spans are created — interceptors still wrap requests, but `trace.getTracer()` returns a no-op until your SDK registers a provider.
27
18
 
28
19
  ```typescript
29
- import { createClient, withEndpoint } from '@defjs/core'
30
- import { withOpenTelemetryServer } from '@defjs/opentelemetry-server'
31
- import { trace } from '@opentelemetry/api'
20
+ import { createClient, withEndpoint } from "@defjs/core";
21
+ import { withOpenTelemetryServer } from "@defjs/opentelemetry-server";
22
+ import { trace } from "@opentelemetry/api";
32
23
 
33
24
  // After your OpenTelemetry SDK has called setGlobalTracerProvider:
34
- const tracer = trace.getTracer('my-service')
25
+ const tracer = trace.getTracer("my-service");
35
26
 
36
27
  const client = createClient(
37
- withEndpoint('https://api.example.com'),
38
- withOpenTelemetryServer({
39
- tracer,
40
- }),
41
- )
28
+ withEndpoint("https://api.example.com"),
29
+ withOpenTelemetryServer({ tracer }),
30
+ );
42
31
  ```
43
32
 
44
- ## Operation Identity
45
-
46
- Set the optional `operation` field statically on `defineRequest(...)`, `defineEventStream(...)`, or `defineWebSocket(...)`:
33
+ Omit `tracer` to record metrics only; the plugin then never calls `trace.getTracer(...)` or the span hooks. `logger` accepts the native `Logger` from `@opentelemetry/api-logs`, not a Winston or Pino business logger.
47
34
 
48
35
  ```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`.
36
+ import { metrics } from "@opentelemetry/api";
58
37
 
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.
38
+ const meter = metrics.getMeter("my-service");
60
39
 
61
- ## Full Configuration
62
-
63
- ```typescript
64
40
  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
- )
41
+ withEndpoint("https://api.example.com"),
42
+ withOpenTelemetryServer({ meter }),
43
+ );
89
44
  ```
90
45
 
91
- ## WebSocket propagation safety
46
+ ## Operation identity
92
47
 
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.
48
+ Set `operation` statically on the definition. It is the low-cardinality transport identity: spans are named `GET orders.read`, `SSE orders.watch`, `WebSocket orders.connect`, and `defjs.operation` is recorded on spans and metrics.
94
49
 
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
- })
50
+ ```typescript
51
+ const readOrder = defineRequest({
52
+ method: "GET",
53
+ operation: "orders.read",
54
+ path: "/orders/:id",
55
+ });
104
56
  ```
105
57
 
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`.
58
+ Without it, HTTP spans use the method alone and SSE/WebSocket use their transport name. Never derive an operation from a resolved URL or a path containing identifiers.
172
59
 
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'
60
+ ## Configuration
177
61
 
62
+ ```typescript
178
63
  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
- })
64
+ tracer, // optional: local spans and span hooks
65
+ meter, // optional: metrics
66
+ logger, // optional: failure diagnostics only
67
+ propagator, // optional: defaults to W3C TraceContext + Baggage
68
+ requireParentSpan: false,
69
+ http: { enabled: true, startSpanHook, requestHook, responseHook },
70
+ sse: { enabled: true, startSpanHook, requestHook, responseHook },
71
+ webSocket: { enabled: true, queryPropagation: false },
72
+ });
228
73
  ```
229
74
 
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:
75
+ | Option | Type | Default | Description |
76
+ | ------------------- | --------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
77
+ | `tracer` | `Tracer` | `undefined` | External tracer; omitting disables local spans and hooks |
78
+ | `meter` | `Meter` | `undefined` | External meter; omitting disables metrics |
79
+ | `logger` | `Logger` from `@opentelemetry/api-logs` | `undefined` | External native Logger; omitting disables diagnostic LogRecords |
80
+ | `propagator` | `TextMapPropagator` | W3C TraceContext + Baggage | Custom context propagator |
81
+ | `requireParentSpan` | `boolean` | `false` | With a tracer, bypass all instrumentation when no active parent span exists |
82
+ | `http` | `OpenTelemetryServerHttpOptions` | `{}` | HTTP transport options |
83
+ | `sse` | `OpenTelemetryServerSSEOptions` | `{}` | SSE transport options |
84
+ | `webSocket` | `OpenTelemetryServerWebSocketOptions` | `{}` | WebSocket transport options |
268
85
 
269
- - `websocket.connected` — Session successfully established
270
- - `websocket.closed` — Connection normal close
271
- - `websocket.error` — Connection error
86
+ Span hooks run only with a `tracer`. `startSpanHook(request)` is synchronous and runs before span creation; a throw leaves the built-in attributes and the operation continues. `requestHook` and `responseHook` are not awaited, and their failures never block or fail the operation. Configure `diag.setLogger(...)` to see hook diagnostics.
272
87
 
273
- ### Custom Metrics
88
+ ## Privacy defaults
274
89
 
275
- When `meter` is provided, the following defjs custom metrics are collected:
90
+ - `webSocket.queryPropagation` defaults to `false`: trace context and baggage do not ride on the WebSocket URL. Enable it only after reviewing how your proxies, browsers, and access logs record query strings.
91
+ - No bodies, full headers, baggage values, or message payloads are added as telemetry fields, and no built-in redactor exists. Prefer allowlists and low-cardinality attributes in hooks.
92
+ - Diagnostics carry a fixed message, phase, operation, server address/port, and exception type — not raw URLs, headers, bodies, or stacks.
93
+ - SSE and WebSocket are traced per connection, not per event or message.
276
94
 
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 |
95
+ ## Documentation
282
96
 
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.
97
+ - [OpenTelemetry guide](https://defjs.lyz.cloud/docs/integrations/opentelemetry-server) — full option tables, log events, span lifecycles, metrics, and third-party logging
98
+ - [OpenTelemetry API](https://defjs.lyz.cloud/api/opentelemetry-server)
99
+ - [Client](https://defjs.lyz.cloud/docs/client) · [SSE](https://defjs.lyz.cloud/docs/sse) · [WebSocket](https://defjs.lyz.cloud/docs/web-socket)
284
100
 
285
- ## What's Next
101
+ ## License
286
102
 
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
103
+ MIT.
package/index.d.ts CHANGED
@@ -1,7 +1,25 @@
1
1
  import { ClientOption, EventStreamHandle, HttpRequest, HttpResponse, WebSocketSessionLike } from "@defjs/core";
2
2
  import { Attributes, Meter, Span, TextMapPropagator, Tracer } from "@opentelemetry/api";
3
- //#region src/option.d.ts
4
- /** Shared per-transport OpenTelemetry client options. */
3
+ import { Logger } from "@opentelemetry/api-logs";
4
+ /**
5
+ * Shared per-transport OpenTelemetry client options: whether the transport is enabled and its span hooks, generic over the response type.
6
+ *
7
+ * `TResponse` is what `responseHook` receives: `HttpResponse<unknown>` for HTTP, `EventStreamHandle<unknown>` for SSE, `WebSocketSessionLike` for WebSocket. See {@link OpenTelemetryServerHttpOptions}, {@link OpenTelemetryServerSSEOptions} and {@link OpenTelemetryServerWebSocketOptions}.
8
+ *
9
+ * `enabled` defaults to `true`; only `enabled: false` turns the transport off. The old boolean switch (`http: false`) and top-level hooks are rejected by the TypeScript types only. There is no runtime check, so at runtime `http: false` leaves the transport enabled and a top-level hook is silently ignored.
10
+ *
11
+ * Span hooks run only when a `tracer` is supplied. `startSpanHook` runs synchronously before span creation for its own transport. Its attributes are applied after built-in attributes, so the application may override `url.full` or another initial value. If it throws, Defjs creates the span with built-in attributes and continues the request. `requestHook` runs after span creation and before the request is sent; `responseHook` runs after the transport result is returned. Both may return a promise, but neither is awaited: a throw or rejection never blocks or fails the operation. `responseHook` receives the original request as its third argument.
12
+ *
13
+ * While `span.isRecording()` is true, hook failures record `defjs.otel.hook.error` and `exception` events. For non-recording spans (ended or unsampled), failures go to the OpenTelemetry diagnostic logger through `diag.error`. Applications must configure `diag.setLogger(...)` from `@opentelemetry/api` to receive them; there is no automatic output by default. The package does not initialize the SDK, and this diagnostic is not a separate SDK `LogRecord`.
14
+ *
15
+ * By default, `url.full` resolves only `request.endpoint` against optional `request.baseEndpoint`; an independent `request.queryString` is not appended. That boundary is not redaction, and the package has no built-in redactor or sensitive-key policy. Construct and, if needed, redact an application-owned URL explicitly.
16
+ *
17
+ * @group opentelemetry-server
18
+ * @see {@link OpenTelemetryServerHttpOptions}
19
+ * @see {@link OpenTelemetryServerSSEOptions}
20
+ * @see {@link OpenTelemetryServerWebSocketOptions}
21
+ * @exampleFile examples/api/opentelemetry-server-transport-options.ts#example
22
+ */
5
23
  interface OpenTelemetryServerTransportOptions<TResponse> {
6
24
  /** Whether this transport's instrumentation is enabled. Defaults to `true`. */
7
25
  enabled?: boolean;
@@ -12,8 +30,54 @@ interface OpenTelemetryServerTransportOptions<TResponse> {
12
30
  /** Customize the client span after the transport result is returned. */
13
31
  responseHook?: (span: Span, res: TResponse, req: HttpRequest) => Promise<void> | void;
14
32
  }
33
+ /**
34
+ * Options for the HTTP transport — the shared options with an HttpResponse in responseHook.
35
+ *
36
+ * Set on `http` in `OpenTelemetryServerOptions`. It has no fields of its own: `enabled`, `startSpanHook`, `requestHook` and `responseHook` come from `OpenTelemetryServerTransportOptions`.
37
+ *
38
+ * - `responseHook(span, res, req)` receives the `HttpResponse` that came back through the interceptor, before the client decodes a declared body, together with the original `HttpRequest`. Read `req.operation` for the request identity; do not rebuild it from the endpoint or a resolved URL.
39
+ *
40
+ * - The HTTP span ends as soon as `responseHook` returns; the hook is not awaited, so only its synchronous part can still change the span. A status of 400 or above ends it `ERROR` with `error.type` set to the status string; 100–399 leaves its status unset. An asynchronous hook rejection after end is reported through `diag.error`; configure `diag.setLogger(...)` from `@opentelemetry/api` to receive it. It cannot append events to an ended span.
41
+ *
42
+ * - A rejection through the interceptor has no response status and does not call `responseHook`. That includes a network failure, caller cancellation, an abort whose reason is `TimeoutError`, any declared-body read failure (`BodyFailure`, including text, blob, and bytes, not only JSON), and a progress observer failure (`EXT_OBSERVER_FAILED`). The span ends `ERROR` with `error.type` set to the thrown class name, such as `TypeError`, `Error` or `BodyFailure`. Caller cancellation is `Error` whatever the abort reason. There is no `timeout` option; a deadline is `abort`.
43
+ *
44
+ * Pass `{ enabled: false }` to skip HTTP instrumentation. Span hooks run only when a `tracer` is supplied.
45
+ *
46
+ * @group opentelemetry-server
47
+ * @see {@link OpenTelemetryServerTransportOptions}
48
+ */
15
49
  type OpenTelemetryServerHttpOptions = OpenTelemetryServerTransportOptions<HttpResponse<unknown>>;
50
+ /**
51
+ * Options for the SSE transport — the shared options with the stream handle in responseHook.
52
+ *
53
+ * Set on `sse` in `OpenTelemetryServerOptions`. It has no fields of its own: `enabled`, `startSpanHook`, `requestHook` and `responseHook` come from `OpenTelemetryServerTransportOptions`.
54
+ *
55
+ * - `responseHook(span, stream, req)` runs once the stream has opened, before the span records `sse.connected`. `stream` is the `EventStreamHandle` the transport returned; `req` is the original `HttpRequest`.
56
+ *
57
+ * - SSE is traced at connection level. After startup the span stays open until `stream.closed` settles, and records `sse.connected`, then one of `sse.closed` (server EOF), `sse.aborted` (`stream.close()` or a caller abort of the open stream whose reason is not `TimeoutError`; the span still ends without error), or `sse.error` (including an abort whose reason is `TimeoutError`, which ends the span `ERROR`). No per-event spans are created.
58
+ *
59
+ * - A non-2xx handshake is observed as a connection failure: the span ends `ERROR` and, with a `logger`, one `WARN` is emitted whatever the response body.
60
+ *
61
+ * Pass `{ enabled: false }` to skip SSE instrumentation. Span hooks run only when a `tracer` is supplied.
62
+ *
63
+ * @group opentelemetry-server
64
+ * @see {@link OpenTelemetryServerTransportOptions}
65
+ */
16
66
  type OpenTelemetryServerSSEOptions = OpenTelemetryServerTransportOptions<EventStreamHandle<unknown>>;
67
+ /**
68
+ * Options for the WebSocket transport — the shared options plus queryPropagation.
69
+ *
70
+ * Set on `webSocket` in `OpenTelemetryServerOptions`. `enabled`, `startSpanHook`, `requestHook` and `responseHook` come from `OpenTelemetryServerTransportOptions`; `responseHook(span, session, req)` receives the opened `WebSocketSessionLike` and the original `HttpRequest`.
71
+ *
72
+ * `queryPropagation` defaults to `false`: the WebSocket query string is left unchanged and no trace context or baggage is written into the URL. Browser WebSocket clients usually cannot set headers, so enabling it writes the propagated context to the end of the query string, replacing any parameter of the same name. Query strings can be recorded by proxies, access logs and APM tools, and baggage values can be sensitive. Turn it on only after you accept that exposure.
73
+ *
74
+ * WebSocket is traced at connection level. The span stays open until `session.closed` settles. It records `websocket.connected`, then `websocket.closed` for a normal close or `websocket.error`. A close frame from the server counts as normal with any code, including 1011. An abrupt drop without a close frame (1006) is normal only where the runtime reports no socket `error` before `close` (Bun); where it does (Node.js), the session ends in error and logs once. A caller abort of an open session is also `websocket.error`: the span ends `ERROR` and no log is emitted. `error.type` follows the abort reason: `AbortError` for a default `abort()`, the `Error.name` of an `Error` reason, `string` for a string reason. An abort while the WebSocket is still connecting ends the span `ERROR` with `error.type` `DefjsFault`. No per-message spans are created.
75
+ *
76
+ * Pass `{ enabled: false }` to skip WebSocket instrumentation. Span hooks run only when a `tracer` is supplied.
77
+ *
78
+ * @group opentelemetry-server
79
+ * @see {@link OpenTelemetryServerTransportOptions}
80
+ */
17
81
  interface OpenTelemetryServerWebSocketOptions extends OpenTelemetryServerTransportOptions<WebSocketSessionLike> {
18
82
  /**
19
83
  * Inject trace context into the WebSocket URL query string.
@@ -22,19 +86,39 @@ interface OpenTelemetryServerWebSocketOptions extends OpenTelemetryServerTranspo
22
86
  queryPropagation?: boolean;
23
87
  }
24
88
  /**
25
- * Options for {@link withOpenTelemetryServer}.
89
+ * Options for {@link withOpenTelemetryServer}: tracer, meter, logger, propagator, and per-transport settings.
26
90
  *
27
- * Pass an application-owned OpenTelemetry `Tracer` (and optional `Meter`).
91
+ * Pass application-owned OpenTelemetry `Tracer`, `Meter`, and/or `Logger` handles.
28
92
  * This package does not initialize the OpenTelemetry SDK.
93
+ *
94
+ * - `tracer` — Optional. Without it there are no local spans and no span hooks.
95
+ *
96
+ * - `meter` — Optional. Without it no package metrics are recorded.
97
+ *
98
+ * - `logger` — Optional. A `Logger` from `@opentelemetry/api-logs`, not a Winston/Pino Logger. Without it no diagnostic LogRecords are emitted. Defjs never obtains a global logger or owns its provider.
99
+ *
100
+ * - `propagator` — Defaults to W3C Trace Context + Baggage.
101
+ *
102
+ * - `requireParentSpan` — Defaults to `false`. With a `tracer`, `true` bypasses all instrumentation, including logs, when no active parent span exists. Without a `tracer` it blocks neither metrics nor logs.
103
+ *
104
+ * - `http`, `sse`, `webSocket` — Per-transport options. Each defaults to `{}`; a transport is disabled only by `{ enabled: false }`.
105
+ *
106
+ * @group opentelemetry-server
107
+ * @see {@link OpenTelemetryServerHttpOptions}
108
+ * @see {@link OpenTelemetryServerSSEOptions}
109
+ * @see {@link OpenTelemetryServerWebSocketOptions}
110
+ * @see {@link OpenTelemetryServerTransportOptions}
29
111
  */
30
112
  interface OpenTelemetryServerOptions {
31
- /** OpenTelemetry tracer used for outbound client spans. */
32
- tracer: Tracer;
113
+ /** Optional tracer; omitting it disables outbound spans and span hooks. */
114
+ tracer?: Tracer;
33
115
  /** Optional meter; when set, per-transport client metrics are recorded. */
34
116
  meter?: Meter;
117
+ /** Optional logger; emits WARN diagnostics for failed outbound requests and connections. */
118
+ logger?: Logger;
35
119
  /** Context propagator. Defaults to W3C Trace Context + Baggage. */
36
120
  propagator?: TextMapPropagator;
37
- /** When `true`, only create an outgoing span if an active parent span exists. */
121
+ /** With a tracer, bypass instrumentation when no active parent span exists. Ignored without a tracer. */
38
122
  requireParentSpan?: boolean;
39
123
  /** HTTP transport instrumentation options. */
40
124
  http?: OpenTelemetryServerHttpOptions;
@@ -46,20 +130,21 @@ interface OpenTelemetryServerOptions {
46
130
  /**
47
131
  * Client option that installs outbound OpenTelemetry interceptors for HTTP, SSE, and WebSocket.
48
132
  *
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`.
133
+ * Appends one interceptor per enabled transport, in the order HTTP, SSE, WebSocket. Apply it at `createClient` time. It does not initialize the OpenTelemetry SDK or acquire global tracer, meter, or logger handles: pass the ones your application owns. Each transport can be toggled or customized via `http`, `sse`, and `webSocket`.
134
+ *
135
+ * `options` is required. It carries `tracer`, `meter`, `logger`, `propagator`, `requireParentSpan`, and the `http` / `sse` / `webSocket` transport options. Pass `{}` to install the interceptors with propagation only.
136
+ *
137
+ * Each of `tracer`, `meter` and `logger` is optional and works without the others. Without `tracer`, the plugin creates no local spans, never calls `trace.getTracer(...)`, and skips `startSpanHook`, `requestHook` and `responseHook`. Propagation still forwards the extracted parent context.
138
+ *
139
+ * HTTP, SSE, and WebSocket are on by default. Pass `{ enabled: false }` on a transport to skip it.
51
140
  *
52
- * @param options - Tracer, optional meter/propagator, and per-transport settings.
141
+ * @param options - Required settings object. Each of `tracer`, `meter`, `logger`, and `propagator` may be omitted; pass `{}` for propagation only.
53
142
  * @returns A `ClientOption` for `createClient(...)`.
54
143
  *
55
- * @example
56
- * ```ts
57
- * const client = createClient(
58
- * withEndpoint('https://api.example.com'),
59
- * withOpenTelemetryServer({ tracer }),
60
- * )
61
- * ```
144
+ * @group opentelemetry-server
145
+ * @see {@link ClientOption}
146
+ * @see {@link OpenTelemetryServerOptions}
147
+ * @exampleFile examples/api/with-opentelemetry-server.ts#example
62
148
  */
63
149
  export declare function withOpenTelemetryServer(options: OpenTelemetryServerOptions): ClientOption;
64
- //#endregion
65
150
  export type { OpenTelemetryServerHttpOptions, OpenTelemetryServerOptions, OpenTelemetryServerSSEOptions, OpenTelemetryServerTransportOptions, OpenTelemetryServerWebSocketOptions };
package/index.js CHANGED
@@ -1,7 +1,8 @@
1
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
2
+ import { ERR_ABORTED, createHttpInterceptor, createResolvedRequestUrl, createSSEInterceptor, createWebSocketInterceptor, isAbortCause, isTimeoutCause } from "@defjs/core";
3
+ import { SpanKind, SpanStatusCode, context, diag, trace } from "@opentelemetry/api";
4
+ import { SeverityNumber } from "@opentelemetry/api-logs";
5
+ // src/propagation/carrier.ts
5
6
  const headersSetter = { set(carrier, key, value) {
6
7
  if (carrier && key && value !== void 0) carrier.set(key, String(value));
7
8
  } };
@@ -16,10 +17,9 @@ const headersGetter = {
16
17
  }
17
18
  };
18
19
  const queryStringSetter = { set(carrier, key, value) {
19
- if (carrier && key && value !== void 0) carrier.params.append(key, String(value));
20
+ if (carrier && key && value !== void 0) carrier.params.set(key, String(value));
20
21
  } };
21
- //#endregion
22
- //#region src/telemetry/trace.ts
22
+ // src/telemetry/trace.ts
23
23
  const spanStatusSet = /* @__PURE__ */ new WeakMap();
24
24
  function markSpanStatusSet(span) {
25
25
  spanStatusSet.set(span, true);
@@ -69,9 +69,9 @@ function createWebSocketSpan(tracer, url, parentCtx, operation, startAttributes)
69
69
  }
70
70
  }, parentCtx);
71
71
  }
72
- function setSpanHttpResponse(span, status, error) {
72
+ function setSpanHttpResponse(span, status) {
73
73
  if (status !== 0) span.setAttribute("http.response.status_code", status);
74
- const errorType = getHttpResponseErrorType(status, error);
74
+ const errorType = getHttpResponseErrorType(status);
75
75
  if (errorType) {
76
76
  span.setAttribute("error.type", errorType);
77
77
  span.setStatus({ code: SpanStatusCode.ERROR });
@@ -100,6 +100,18 @@ function runSpanHook(span, hookName, hook) {
100
100
  }
101
101
  }
102
102
  function recordSpanHookError(span, hookName, error) {
103
+ if (!span.isRecording()) {
104
+ try {
105
+ const { traceId, spanId } = span.spanContext();
106
+ diag.error("@defjs/opentelemetry-server: span hook failed on a non-recording span", {
107
+ "hook.name": hookName,
108
+ "error.type": getErrorType(error),
109
+ traceId,
110
+ spanId
111
+ });
112
+ } catch {}
113
+ return;
114
+ }
103
115
  try {
104
116
  span.addEvent("defjs.otel.hook.error", {
105
117
  "hook.name": hookName,
@@ -120,11 +132,7 @@ function addSpanEvent(span, eventName, attributes) {
120
132
  function toError(error) {
121
133
  return error instanceof Error ? error : new Error(String(error));
122
134
  }
123
- function getHttpResponseErrorType(status, error) {
124
- if (status === 0) {
125
- const errorCode = createTransportError(error).code;
126
- return errorCode === "ABORTED" ? void 0 : errorCode;
127
- }
135
+ function getHttpResponseErrorType(status) {
128
136
  if (status >= 400) return String(status);
129
137
  }
130
138
  function getErrorType(error) {
@@ -132,8 +140,7 @@ function getErrorType(error) {
132
140
  if (error === void 0 || error === null) return "_OTHER";
133
141
  return typeof error;
134
142
  }
135
- //#endregion
136
- //#region src/telemetry/url.ts
143
+ // src/telemetry/url.ts
137
144
  function resolveUrl(endpoint, baseEndpoint) {
138
145
  if (!baseEndpoint) return endpoint;
139
146
  try {
@@ -155,8 +162,7 @@ function resolveHttpUrl(endpoint, baseEndpoint) {
155
162
  return { url: endpoint };
156
163
  }
157
164
  }
158
- //#endregion
159
- //#region src/telemetry/metrics.ts
165
+ // src/telemetry/metrics.ts
160
166
  const DURATION_BUCKETS = [
161
167
  .005,
162
168
  .01,
@@ -224,7 +230,7 @@ function createHttpMetricAttributes(req, response, error) {
224
230
  ...createServerMetricAttributes(req),
225
231
  "http.request.method": req.method
226
232
  };
227
- const errorType = response ? getHttpResponseErrorType(response.status, response.error) : createErrorMetricAttributes(error)["error.type"];
233
+ const errorType = response ? getHttpResponseErrorType(response.status) : createErrorMetricAttributes(error)["error.type"];
228
234
  if (errorType) attributes["error.type"] = errorType;
229
235
  if (response && response.status !== 0) attributes["http.response.status_code"] = response.status;
230
236
  return attributes;
@@ -248,26 +254,85 @@ function createErrorMetricAttributes(error) {
248
254
  if (error === void 0 || error === null) return {};
249
255
  return { "error.type": getErrorType(error) };
250
256
  }
251
- //#endregion
252
- //#region src/interceptor/http.ts
257
+ // src/telemetry/logs.ts
258
+ const CLIENT_EXCEPTIONS = {
259
+ http: {
260
+ eventName: "http.client.request.exception",
261
+ body: "HTTP client request failed"
262
+ },
263
+ sse: {
264
+ eventName: "defjs.client.sse.exception",
265
+ body: "SSE client connection failed"
266
+ },
267
+ websocket: {
268
+ eventName: "defjs.client.websocket.exception",
269
+ body: "WebSocket client connection failed"
270
+ }
271
+ };
272
+ /**
273
+ * Caller cancellation produces no log. An abort whose reason is `TimeoutError` or `ERR_TIMEOUT` still logs.
274
+ *
275
+ * `request.abort` alone is not cancellation: WebSocket `finish()` aborts that same signal
276
+ * when a connect fails. Cancellation is an abort sentinel from any `@defjs/core` copy, or an
277
+ * `AbortError`, on the rejection or on `request.abort.reason`. There is no `timeout` option.
278
+ */
279
+ function isCallerCancellation(request, error) {
280
+ const reason = request.abort?.reason;
281
+ if (isTimeoutCause(error) || isTimeoutCause(reason)) return false;
282
+ return isAbortCause(error) || isAbortCause(reason);
283
+ }
284
+ function emitClientException(logger, details) {
285
+ if (!logger) return;
286
+ try {
287
+ const { error, transport, request } = details;
288
+ if (isCallerCancellation(request, error)) return;
289
+ if (error instanceof Error && "code" in error) {
290
+ if (error.code === "NET_ABORTED" || transport === "http" && error.code === "HTTP_STATUS") return;
291
+ }
292
+ const attributes = {
293
+ ...createServerMetricAttributes(request),
294
+ "defjs.phase": details.phase,
295
+ "exception.type": getErrorType(error)
296
+ };
297
+ if (transport === "http") attributes["http.request.method"] = request.method;
298
+ const closeCode = details.closeInfo?.code;
299
+ if (transport === "sse" && typeof closeCode === "string") attributes["defjs.sse.fault.code"] = closeCode;
300
+ if (transport === "websocket" && typeof closeCode === "number") attributes["websocket.close.code"] = closeCode;
301
+ logger.emit({
302
+ ...CLIENT_EXCEPTIONS[transport],
303
+ context: details.context,
304
+ severityNumber: SeverityNumber.WARN,
305
+ severityText: "WARN",
306
+ attributes
307
+ });
308
+ } catch {
309
+ try {
310
+ diag.error("@defjs/opentelemetry-server: failed to emit client diagnostic log");
311
+ } catch {}
312
+ }
313
+ }
314
+ // src/interceptor/http.ts
253
315
  function createOpenTelemetryHttpInterceptor(options) {
254
316
  return createHttpInterceptor(async (req, next) => {
255
- const { tracer, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
256
- if (requireParentSpan && !trace.getActiveSpan()) return next(req);
317
+ const { tracer, logger, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
318
+ if (tracer && requireParentSpan && !trace.getActiveSpan()) return next(req);
257
319
  const parentCtx = propagator.extract(context.active(), req.headers ?? new Headers(), headersGetter);
258
320
  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);
321
+ let span;
322
+ if (tracer) {
323
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
324
+ span = createHttpSpan(tracer, req.method, url, parentCtx, req.operation, {
325
+ ...serverAddress ? { "server.address": serverAddress } : {},
326
+ ...serverPort ? { "server.port": serverPort } : {},
327
+ ...startSpanHookResult.ok ? startSpanHookResult.attributes : {}
328
+ });
329
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
330
+ throw startSpanHookResult.error;
331
+ });
332
+ }
333
+ if (span) runSpanHook(span, "requestHook", () => requestHook?.(span, req));
334
+ const spanCtx = span ? trace.setSpan(parentCtx, span) : parentCtx;
335
+ const headers = new Headers(req.headers);
271
336
  propagator.inject(spanCtx, headers, headersSetter);
272
337
  const startTime = performance.now();
273
338
  try {
@@ -276,35 +341,46 @@ function createOpenTelemetryHttpInterceptor(options) {
276
341
  headers
277
342
  });
278
343
  const durationS = durationSeconds(startTime);
279
- runSpanHook(span, "responseHook", () => responseHook?.(span, response, req));
280
- setSpanHttpResponse(span, response.status, response.error);
344
+ if (span) {
345
+ runSpanHook(span, "responseHook", () => responseHook?.(span, response, req));
346
+ setSpanHttpResponse(span, response.status);
347
+ }
281
348
  metrics?.requestDuration.record(durationS, createHttpMetricAttributes(req, response));
282
349
  return response;
283
350
  } catch (error) {
284
351
  const durationS = durationSeconds(startTime);
285
- setSpanError(span, error);
352
+ emitClientException(logger, {
353
+ request: req,
354
+ context: spanCtx,
355
+ error,
356
+ transport: "http",
357
+ phase: "request"
358
+ });
359
+ if (span) setSpanError(span, error);
286
360
  metrics?.requestDuration.record(durationS, createHttpMetricAttributes(req, void 0, error));
287
361
  throw error;
288
362
  }
289
363
  });
290
364
  }
291
- //#endregion
292
- //#region src/interceptor/sse.ts
365
+ // src/interceptor/sse.ts
293
366
  function createOpenTelemetrySSEInterceptor(options) {
294
367
  return createSSEInterceptor(async (req, next) => {
295
- const { tracer, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
296
- if (requireParentSpan && !trace.getActiveSpan()) return next(req);
368
+ const { tracer, logger, propagator, metrics, requireParentSpan, startSpanHook, requestHook, responseHook } = options;
369
+ if (tracer && requireParentSpan && !trace.getActiveSpan()) return next(req);
297
370
  const parentCtx = propagator.extract(context.active(), req.headers ?? new Headers(), headersGetter);
298
371
  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);
372
+ let span;
373
+ if (tracer) {
374
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
375
+ span = createSSESpan(tracer, url, parentCtx, req.operation, startSpanHookResult.ok ? startSpanHookResult.attributes : void 0);
376
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
377
+ throw startSpanHookResult.error;
378
+ });
379
+ }
380
+ const spanCtx = span ? trace.setSpan(parentCtx, span) : parentCtx;
381
+ if (span) runSpanHook(span, "requestHook", () => requestHook?.(span, req));
382
+ const headers = new Headers(req.headers);
306
383
  propagator.inject(spanCtx, headers, headersSetter);
307
- runSpanHook(span, "requestHook", () => requestHook?.(span, req));
308
384
  const connectStartMs = performance.now();
309
385
  try {
310
386
  const stream = await next({
@@ -313,8 +389,10 @@ function createOpenTelemetrySSEInterceptor(options) {
313
389
  });
314
390
  const connectedAtMs = performance.now();
315
391
  const activeAttributes = createServerMetricAttributes(req);
316
- runSpanHook(span, "responseHook", () => responseHook?.(span, stream, req));
317
- addSpanEvent(span, "sse.connected");
392
+ if (span) {
393
+ runSpanHook(span, "responseHook", () => responseHook?.(span, stream, req));
394
+ addSpanEvent(span, "sse.connected");
395
+ }
318
396
  metrics?.connectDuration.record(durationSeconds(connectStartMs, connectedAtMs), createConnectionMetricAttributes(req, "success"));
319
397
  metrics?.activeStreams.add(1, activeAttributes);
320
398
  const closeActiveStream = () => {
@@ -323,59 +401,82 @@ function createOpenTelemetrySSEInterceptor(options) {
323
401
  stream.closed.then((closeInfo) => {
324
402
  closeActiveStream();
325
403
  const closeAttributes = {
326
- "sse.close.code": closeInfo.code,
327
- ...closeInfo.code === "error" ? { "defjs.sse.error.code": closeInfo.errorCode } : {}
404
+ "sse.close.kind": closeInfo.kind,
405
+ ...closeInfo.kind === "error" ? { "defjs.sse.fault.code": closeInfo.code } : {}
328
406
  };
329
- if (closeInfo.code === "error") {
330
- addSpanEvent(span, "sse.error", closeAttributes);
407
+ if (closeInfo.kind === "error") {
408
+ emitClientException(logger, {
409
+ request: req,
410
+ context: spanCtx,
411
+ error: closeInfo.cause,
412
+ transport: "sse",
413
+ phase: "connection",
414
+ closeInfo
415
+ });
416
+ if (span) addSpanEvent(span, "sse.error", closeAttributes);
331
417
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", {
332
418
  ...closeAttributes,
333
419
  ...createErrorMetricAttributes(closeInfo.cause)
334
420
  }));
335
- setSpanError(span, closeInfo.cause);
421
+ if (span) setSpanError(span, closeInfo.cause);
336
422
  return;
337
423
  }
338
- addSpanEvent(span, closeInfo.code === "aborted" ? "sse.aborted" : "sse.closed", closeAttributes);
424
+ if (span) addSpanEvent(span, closeInfo.kind === "aborted" ? "sse.aborted" : "sse.closed", closeAttributes);
339
425
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "success", closeAttributes));
340
- endSpan(span);
426
+ if (span) endSpan(span);
341
427
  }, (error) => {
342
428
  closeActiveStream();
343
- addSpanEvent(span, "sse.error", createErrorMetricAttributes(error));
429
+ emitClientException(logger, {
430
+ request: req,
431
+ context: spanCtx,
432
+ error,
433
+ transport: "sse",
434
+ phase: "connection"
435
+ });
436
+ if (span) addSpanEvent(span, "sse.error", createErrorMetricAttributes(error));
344
437
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
345
- setSpanError(span, error);
438
+ if (span) setSpanError(span, error);
346
439
  });
347
440
  return stream;
348
441
  } catch (error) {
442
+ emitClientException(logger, {
443
+ request: req,
444
+ context: spanCtx,
445
+ error,
446
+ transport: "sse",
447
+ phase: "connect"
448
+ });
349
449
  metrics?.connectDuration.record(durationSeconds(connectStartMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
350
- setSpanError(span, error);
450
+ if (span) setSpanError(span, error);
351
451
  throw error;
352
452
  }
353
453
  });
354
454
  }
355
- //#endregion
356
- //#region src/interceptor/web_socket.ts
455
+ // src/interceptor/web_socket.ts
357
456
  function createOpenTelemetryWebSocketInterceptor(options) {
358
457
  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);
458
+ const { tracer, logger, propagator, metrics, requireParentSpan, queryPropagation = false, startSpanHook, requestHook, responseHook } = options;
459
+ if (tracer && requireParentSpan && !trace.getActiveSpan()) return next(req);
361
460
  const url = resolveUrl(req.endpoint, req.baseEndpoint);
362
461
  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);
462
+ let span;
463
+ if (tracer) {
464
+ const startSpanHookResult = resolveStartSpanHook(startSpanHook, req);
465
+ span = createWebSocketSpan(tracer, url, parentCtx, req.operation, startSpanHookResult.ok ? startSpanHookResult.attributes : void 0);
466
+ if (!startSpanHookResult.ok) runSpanHook(span, "startSpanHook", () => {
467
+ throw startSpanHookResult.error;
468
+ });
469
+ }
470
+ const spanCtx = span ? trace.setSpan(parentCtx, span) : parentCtx;
369
471
  let queryParams = req.queryParams;
370
472
  let queryString = req.queryString;
371
473
  if (queryPropagation) {
372
- const originalParams = new URLSearchParams(queryParams);
373
- const carrier = { params: new URLSearchParams(queryParams) };
474
+ const carrier = { params: new URLSearchParams() };
374
475
  propagator.inject(spanCtx, carrier, queryStringSetter);
375
- queryParams = carrier.params;
376
- queryString = appendInjectedQueryString(queryString, originalParams, carrier.params);
476
+ queryString = replaceInjectedPairs(queryString ?? new URLSearchParams(queryParams).toString(), carrier.params);
477
+ queryParams = new URLSearchParams(queryString);
377
478
  }
378
- runSpanHook(span, "requestHook", () => requestHook?.(span, req));
479
+ if (span) runSpanHook(span, "requestHook", () => requestHook?.(span, req));
379
480
  const connectStartMs = performance.now();
380
481
  try {
381
482
  const session = await next({
@@ -385,8 +486,10 @@ function createOpenTelemetryWebSocketInterceptor(options) {
385
486
  });
386
487
  const connectedAtMs = performance.now();
387
488
  const activeAttributes = createServerMetricAttributes(req);
388
- runSpanHook(span, "responseHook", () => responseHook?.(span, session, req));
389
- addSpanEvent(span, "websocket.connected");
489
+ if (span) {
490
+ runSpanHook(span, "responseHook", () => responseHook?.(span, session, req));
491
+ addSpanEvent(span, "websocket.connected");
492
+ }
390
493
  metrics?.connectDuration.record(durationSeconds(connectStartMs, connectedAtMs), createConnectionMetricAttributes(req, "success"));
391
494
  metrics?.activeConnections.add(1, activeAttributes);
392
495
  const closeActiveConnection = () => {
@@ -399,63 +502,88 @@ function createOpenTelemetryWebSocketInterceptor(options) {
399
502
  const closeCause = runtimeCloseInfo.cause;
400
503
  if (typeof closeKind === "string" ? closeKind !== "closed" : typeof closeCause !== "undefined") {
401
504
  const error = typeof closeKind === "string" ? closeKind === "error" ? closeCause : closeCause ?? ERR_ABORTED : closeCause;
402
- addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
505
+ if (closeKind !== "aborted") emitClientException(logger, {
506
+ request: req,
507
+ context: spanCtx,
508
+ error,
509
+ transport: "websocket",
510
+ phase: "connection",
511
+ closeInfo: runtimeCloseInfo
512
+ });
513
+ if (span) addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
403
514
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
404
- setSpanError(span, error);
515
+ if (span) setSpanError(span, error);
405
516
  return;
406
517
  }
407
- addSpanEvent(span, "websocket.closed");
518
+ if (span) addSpanEvent(span, "websocket.closed");
408
519
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "success"));
409
- endSpan(span);
520
+ if (span) endSpan(span);
410
521
  }, (error) => {
411
522
  closeActiveConnection();
412
- addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
523
+ emitClientException(logger, {
524
+ request: req,
525
+ context: spanCtx,
526
+ error,
527
+ transport: "websocket",
528
+ phase: "connection"
529
+ });
530
+ if (span) addSpanEvent(span, "websocket.error", createErrorMetricAttributes(error));
413
531
  metrics?.connectionDuration.record(durationSeconds(connectedAtMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
414
- setSpanError(span, error);
532
+ if (span) setSpanError(span, error);
415
533
  });
416
534
  return session;
417
535
  } catch (error) {
536
+ emitClientException(logger, {
537
+ request: req,
538
+ context: spanCtx,
539
+ error,
540
+ transport: "websocket",
541
+ phase: "connect"
542
+ });
418
543
  metrics?.connectDuration.record(durationSeconds(connectStartMs), createConnectionMetricAttributes(req, "error", createErrorMetricAttributes(error)));
419
- setSpanError(span, error);
544
+ if (span) setSpanError(span, error);
420
545
  throw error;
421
546
  }
422
547
  });
423
548
  }
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}`;
549
+ /**
550
+ * Put the injected pairs last and drop earlier pairs of the same name, so a receiver reading the first value gets the injected one.
551
+ * Every other pair keeps the bytes it was serialized with.
552
+ */
553
+ function replaceInjectedPairs(queryString, injected) {
554
+ const added = injected.toString();
555
+ if (!added) return queryString;
556
+ const prefixed = queryString.startsWith("?");
557
+ const query = prefixed ? queryString.slice(1) : queryString;
558
+ const combined = [...query ? query.split("&").filter((pair) => !injected.has(new URLSearchParams(`&${pair}`).keys().next().value ?? "")) : [], added].join("&");
559
+ return prefixed || combined.startsWith("?") ? `?${combined}` : combined;
434
560
  }
435
- //#endregion
436
- //#region src/option.ts
561
+ // src/option.ts
437
562
  /**
438
563
  * Client option that installs outbound OpenTelemetry interceptors for HTTP, SSE, and WebSocket.
439
564
  *
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`.
565
+ * Appends one interceptor per enabled transport, in the order HTTP, SSE, WebSocket. Apply it at `createClient` time. It does not initialize the OpenTelemetry SDK or acquire global tracer, meter, or logger handles: pass the ones your application owns. Each transport can be toggled or customized via `http`, `sse`, and `webSocket`.
566
+ *
567
+ * `options` is required. It carries `tracer`, `meter`, `logger`, `propagator`, `requireParentSpan`, and the `http` / `sse` / `webSocket` transport options. Pass `{}` to install the interceptors with propagation only.
568
+ *
569
+ * Each of `tracer`, `meter` and `logger` is optional and works without the others. Without `tracer`, the plugin creates no local spans, never calls `trace.getTracer(...)`, and skips `startSpanHook`, `requestHook` and `responseHook`. Propagation still forwards the extracted parent context.
570
+ *
571
+ * HTTP, SSE, and WebSocket are on by default. Pass `{ enabled: false }` on a transport to skip it.
442
572
  *
443
- * @param options - Tracer, optional meter/propagator, and per-transport settings.
573
+ * @param options - Required settings object. Each of `tracer`, `meter`, `logger`, and `propagator` may be omitted; pass `{}` for propagation only.
444
574
  * @returns A `ClientOption` for `createClient(...)`.
445
575
  *
446
- * @example
447
- * ```ts
448
- * const client = createClient(
449
- * withEndpoint('https://api.example.com'),
450
- * withOpenTelemetryServer({ tracer }),
451
- * )
452
- * ```
576
+ * @group opentelemetry-server
577
+ * @see {@link ClientOption}
578
+ * @see {@link OpenTelemetryServerOptions}
579
+ * @exampleFile examples/api/with-opentelemetry-server.ts#example
453
580
  */
454
581
  function withOpenTelemetryServer(options) {
455
- const { tracer, meter, propagator = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }), requireParentSpan = false } = options;
582
+ const { tracer, meter, logger, propagator = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }), requireParentSpan = false } = options;
456
583
  return (config) => {
457
584
  if (isTransportEnabled(options.http)) config.interceptors.push(createOpenTelemetryHttpInterceptor({
458
585
  tracer,
586
+ logger,
459
587
  propagator,
460
588
  metrics: meter ? createHttpClientMetrics(meter) : void 0,
461
589
  requireParentSpan,
@@ -465,6 +593,7 @@ function withOpenTelemetryServer(options) {
465
593
  }));
466
594
  if (isTransportEnabled(options.sse)) config.interceptors.push(createOpenTelemetrySSEInterceptor({
467
595
  tracer,
596
+ logger,
468
597
  propagator,
469
598
  metrics: meter ? createSSEClientMetrics(meter) : void 0,
470
599
  requireParentSpan,
@@ -474,6 +603,7 @@ function withOpenTelemetryServer(options) {
474
603
  }));
475
604
  if (isTransportEnabled(options.webSocket)) config.interceptors.push(createOpenTelemetryWebSocketInterceptor({
476
605
  tracer,
606
+ logger,
477
607
  propagator,
478
608
  metrics: meter ? createWebSocketClientMetrics(meter) : void 0,
479
609
  requireParentSpan,
@@ -487,5 +617,4 @@ function withOpenTelemetryServer(options) {
487
617
  function isTransportEnabled(option) {
488
618
  return option?.enabled !== false;
489
619
  }
490
- //#endregion
491
620
  export { withOpenTelemetryServer };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@defjs/opentelemetry-server",
3
- "version": "0.0.1-beta.0",
3
+ "version": "0.0.2",
4
+ "description": "Append OpenTelemetry interceptors to an application-owned Defjs client.",
4
5
  "bugs": {
5
6
  "url": "https://github.com/defjs/defjs/issues"
6
7
  },
@@ -12,10 +13,13 @@
12
13
  "type": "module",
13
14
  "main": "./index.js",
14
15
  "types": "./index.d.ts",
16
+ "dependencies": {
17
+ "@opentelemetry/api-logs": "^0.223.0"
18
+ },
15
19
  "peerDependencies": {
16
- "@defjs/core": "0.4.0-beta.0",
20
+ "@defjs/core": "^0.5.0",
17
21
  "@opentelemetry/api": "^1.0.0",
18
- "@opentelemetry/core": "^2.0.0"
22
+ "@opentelemetry/core": "^2.0.0 || ^3.0.0"
19
23
  },
20
24
  "module": "./index.js",
21
25
  "typings": "./index.d.ts",