@nifrajs/otel 3.5.0 → 4.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 (62) hide show
  1. package/README.md +90 -3
  2. package/dist/cache.d.ts +46 -0
  3. package/dist/cache.d.ts.map +1 -0
  4. package/dist/cache.js +78 -0
  5. package/dist/cache.js.map +1 -0
  6. package/dist/context-trace.d.ts +8 -0
  7. package/dist/context-trace.d.ts.map +1 -0
  8. package/dist/context-trace.js +16 -0
  9. package/dist/context-trace.js.map +1 -0
  10. package/dist/effects.d.ts.map +1 -1
  11. package/dist/effects.js +2 -0
  12. package/dist/effects.js.map +1 -1
  13. package/dist/events.d.ts +69 -0
  14. package/dist/events.d.ts.map +1 -0
  15. package/dist/events.js +106 -0
  16. package/dist/events.js.map +1 -0
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/jobs.d.ts +56 -0
  21. package/dist/jobs.d.ts.map +1 -0
  22. package/dist/jobs.js +98 -0
  23. package/dist/jobs.js.map +1 -0
  24. package/dist/lifecycle.d.ts +13 -1
  25. package/dist/lifecycle.d.ts.map +1 -1
  26. package/dist/lifecycle.js +12 -3
  27. package/dist/lifecycle.js.map +1 -1
  28. package/dist/metrics.d.ts.map +1 -1
  29. package/dist/metrics.js +0 -0
  30. package/dist/metrics.js.map +1 -1
  31. package/dist/otlp.d.ts.map +1 -1
  32. package/dist/otlp.js +17 -1
  33. package/dist/otlp.js.map +1 -1
  34. package/dist/route-template.d.ts +14 -0
  35. package/dist/route-template.d.ts.map +1 -0
  36. package/dist/route-template.js +38 -0
  37. package/dist/route-template.js.map +1 -0
  38. package/dist/sdk-bridge.d.ts +107 -0
  39. package/dist/sdk-bridge.d.ts.map +1 -0
  40. package/dist/sdk-bridge.js +243 -0
  41. package/dist/sdk-bridge.js.map +1 -0
  42. package/dist/span.d.ts +8 -1
  43. package/dist/span.d.ts.map +1 -1
  44. package/dist/span.js +1 -0
  45. package/dist/span.js.map +1 -1
  46. package/dist/tracing.d.ts.map +1 -1
  47. package/dist/tracing.js +11 -2
  48. package/dist/tracing.js.map +1 -1
  49. package/package.json +33 -3
  50. package/src/cache.ts +115 -0
  51. package/src/context-trace.ts +14 -0
  52. package/src/effects.ts +2 -0
  53. package/src/events.ts +170 -0
  54. package/src/index.ts +2 -0
  55. package/src/jobs.ts +141 -0
  56. package/src/lifecycle.ts +26 -3
  57. package/src/metrics.ts +0 -0
  58. package/src/otlp.ts +18 -2
  59. package/src/route-template.ts +43 -0
  60. package/src/sdk-bridge.ts +375 -0
  61. package/src/span.ts +9 -1
  62. package/src/tracing.ts +11 -2
@@ -0,0 +1,375 @@
1
+ /**
2
+ * A bridge between nifra spans and the OpenTelemetry SDK. The app passes its own `@opentelemetry/api`;
3
+ * nifra takes no dependency on it.
4
+ *
5
+ * import * as api from "@opentelemetry/api"
6
+ * import { otelBridge } from "@nifrajs/otel/sdk-bridge"
7
+ *
8
+ * const bridge = otelBridge({ api })
9
+ * app.use(tracing({ adapters: [bridge.adapter] })).use(bridge.plugin)
10
+ * // a pg / undici span started inside a handler is now a child of the request span
11
+ *
12
+ * Context propagation needs an async context manager registered with the SDK (on Node, Bun and Deno the
13
+ * AsyncLocalStorage one from `@opentelemetry/context-async-hooks`; on Workers, `nodejs_compat`).
14
+ */
15
+
16
+ import { type AnyServer, defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
17
+ import { traceOfContext } from "./context-trace.ts"
18
+ import type { ObservationContext, ObservationScope } from "./lifecycle.ts"
19
+ import type {
20
+ AttributeValue,
21
+ NifraSpan,
22
+ ObservationAdapter,
23
+ ObservationLink,
24
+ SpanKind,
25
+ } from "./span.ts"
26
+ import { generateSpanId, generateTraceId } from "./traceparent.ts"
27
+
28
+ /** The OTel `SpanContext` shape. */
29
+ export interface OtelSpanContext {
30
+ readonly traceId: string
31
+ readonly spanId: string
32
+ readonly traceFlags: number
33
+ readonly isRemote?: boolean
34
+ }
35
+
36
+ /** The part of an OTel `Span` the bridge uses. Every `Span` from `@opentelemetry/api` has it. */
37
+ export interface OtelSpan {
38
+ spanContext(): OtelSpanContext
39
+ setAttributes(attributes: Record<string, AttributeValue>): unknown
40
+ setStatus(status: { code: number }): unknown
41
+ end(endTime?: number): void
42
+ isRecording(): boolean
43
+ }
44
+
45
+ /** The part of an OTel `Tracer` the bridge uses. */
46
+ export interface OtelTracer {
47
+ startSpan(
48
+ name: string,
49
+ options?: {
50
+ kind?: number
51
+ startTime?: number
52
+ attributes?: Record<string, AttributeValue>
53
+ links?: Array<{
54
+ context: OtelSpanContext
55
+ attributes?: Record<string, AttributeValue>
56
+ }>
57
+ },
58
+ context?: unknown,
59
+ ): OtelSpan
60
+ }
61
+
62
+ /** The part of `@opentelemetry/api` the bridge uses: pass `import * as api from "@opentelemetry/api"`. */
63
+ export interface OtelApi {
64
+ readonly ROOT_CONTEXT: unknown
65
+ readonly context: {
66
+ active(): unknown
67
+ with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
68
+ context: unknown,
69
+ fn: F,
70
+ thisArg?: ThisParameterType<F>,
71
+ ...args: A
72
+ ): ReturnType<F>
73
+ }
74
+ readonly trace: {
75
+ setSpan(context: unknown, span: unknown): unknown
76
+ getSpan(context: unknown): { spanContext(): OtelSpanContext } | undefined
77
+ getTracer(name: string, version?: string): OtelTracer
78
+ }
79
+ }
80
+
81
+ /** The SDK's `IdGenerator`. */
82
+ export interface OtelIdGenerator {
83
+ generateTraceId(): string
84
+ generateSpanId(): string
85
+ }
86
+
87
+ export interface OtelBridgeOptions {
88
+ /** The app's own `@opentelemetry/api` module. */
89
+ readonly api: OtelApi
90
+ /** Tracer that mirrored spans start on. Default `api.trace.getTracer("@nifrajs/otel")`, at first use. */
91
+ readonly tracer?: OtelTracer
92
+ /** Maximum mirrored spans in flight at once; the oldest is ended past it. Default 10,000. */
93
+ readonly maxActive?: number
94
+ /** Opportunistically end mirrored spans in flight longer than this many ms. Default 5 min. */
95
+ readonly maxActiveAgeMs?: number
96
+ /** Injectable wall clock for deterministic retention tests. */
97
+ readonly now?: () => number
98
+ /**
99
+ * Called once when the SDK gave a mirrored span different ids from the nifra span, which means
100
+ * `idGenerator` is not wired. Default: one `console.warn`.
101
+ */
102
+ readonly onIdMismatch?: () => void
103
+ }
104
+
105
+ export interface OtelBridge {
106
+ /**
107
+ * Mirrors nifra spans into SDK spans: name, kind, parent, links, attributes, status and timing. Add it
108
+ * to `tracing({ adapters })` and to `cacheTracing` / `jobTracing` / `traceEventConsumer`.
109
+ */
110
+ readonly adapter: ObservationAdapter
111
+ /**
112
+ * `app.use(bridge.plugin)`: each subsequent route runs with its request span active in the OTel
113
+ * context, so the SDK's own instrumentations (pg, mysql2, ioredis, undici, prisma) nest under it.
114
+ * Costs one `around()` frame per request, and only when installed.
115
+ */
116
+ readonly plugin: IdentityPlugin
117
+ /** Runs code with a nifra span active in the OTel context: `jobTracing({ scope: bridge.scope })`. */
118
+ readonly scope: ObservationScope
119
+ /**
120
+ * Pass as the SDK provider's `idGenerator` so mirrored spans keep nifra's exact trace and span ids:
121
+ * the `traceparent` nifra forwards downstream and the trace context a job stores then name real
122
+ * spans. Outside a mirrored start it generates random ids.
123
+ */
124
+ readonly idGenerator: OtelIdGenerator
125
+ }
126
+
127
+ type OtelSpanOptions = NonNullable<Parameters<OtelTracer["startSpan"]>[1]>
128
+ type OtelLink = NonNullable<OtelSpanOptions["links"]>[number]
129
+
130
+ // `SpanKind` of @opentelemetry/api, stable since 1.0. A kind is caller data, so anything unknown
131
+ // starts as server, the kind of the spans that predate the field.
132
+ function otelKind(kind: SpanKind | undefined): number {
133
+ switch (kind) {
134
+ case "internal":
135
+ return 0
136
+ case "client":
137
+ return 2
138
+ case "producer":
139
+ return 3
140
+ case "consumer":
141
+ return 4
142
+ default:
143
+ return 1
144
+ }
145
+ }
146
+
147
+ function otelLink(link: ObservationLink): OtelLink {
148
+ const out: OtelLink = {
149
+ context: { traceId: link.traceId, spanId: link.spanId, traceFlags: 0, isRemote: true },
150
+ }
151
+ if (link.attributes !== undefined) out.attributes = { ...link.attributes }
152
+ return out
153
+ }
154
+
155
+ // `SpanStatusCode` of @opentelemetry/api, stable since 1.0.
156
+ const STATUS_OK = 1
157
+ const STATUS_ERROR = 2
158
+ const SAMPLED = 1
159
+ const INVALID: OtelSpanContext = Object.freeze({
160
+ traceId: "00000000000000000000000000000000",
161
+ spanId: "0000000000000000",
162
+ traceFlags: 0,
163
+ })
164
+
165
+ /**
166
+ * A span that only carries a context. Instrumentations read the active span's `spanContext()` to pick
167
+ * their parent; nifra owns the span's lifecycle, so every other call is a no-op.
168
+ */
169
+ class ContextSpan {
170
+ private readonly resolve: () => OtelSpanContext
171
+ constructor(resolve: () => OtelSpanContext) {
172
+ this.resolve = resolve
173
+ }
174
+ spanContext(): OtelSpanContext {
175
+ return this.resolve()
176
+ }
177
+ setAttribute(): this {
178
+ return this
179
+ }
180
+ setAttributes(): this {
181
+ return this
182
+ }
183
+ addEvent(): this {
184
+ return this
185
+ }
186
+ addLink(): this {
187
+ return this
188
+ }
189
+ addLinks(): this {
190
+ return this
191
+ }
192
+ setStatus(): this {
193
+ return this
194
+ }
195
+ updateName(): this {
196
+ return this
197
+ }
198
+ end(): void {}
199
+ isRecording(): boolean {
200
+ return false
201
+ }
202
+ recordException(): void {}
203
+ }
204
+
205
+ /** Build the bridge. See {@link OtelBridge} for what each part does. */
206
+ export function otelBridge(options: OtelBridgeOptions): OtelBridge {
207
+ const api = options.api
208
+ const maxActive = options.maxActive ?? 10_000
209
+ const maxActiveAgeMs = options.maxActiveAgeMs ?? 5 * 60_000
210
+ if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
211
+ throw new RangeError("otelBridge maxActive must be a positive safe integer")
212
+ }
213
+ if (!Number.isSafeInteger(maxActiveAgeMs) || maxActiveAgeMs < 1) {
214
+ throw new RangeError("otelBridge maxActiveAgeMs must be a positive safe integer")
215
+ }
216
+ const now = options.now ?? Date.now
217
+ let tracer = options.tracer
218
+ const tracerOf = (): OtelTracer => {
219
+ tracer ??= api.trace.getTracer("@nifrajs/otel")
220
+ return tracer
221
+ }
222
+ let warned = false
223
+ const onIdMismatch =
224
+ options.onIdMismatch ??
225
+ (() =>
226
+ console.warn(
227
+ "[nifra/otel] mirrored spans got new ids from the SDK; pass `idGenerator: bridge.idGenerator` to the tracer provider so forwarded traceparents name real spans",
228
+ ))
229
+
230
+ // The ids the next SDK span must take. Set and cleared around one synchronous `startSpan` call.
231
+ let pending: { traceId: string | undefined; spanId: string | undefined } | undefined
232
+ const idGenerator: OtelIdGenerator = {
233
+ generateTraceId() {
234
+ const id = pending?.traceId
235
+ if (pending !== undefined) pending.traceId = undefined
236
+ return id ?? generateTraceId()
237
+ },
238
+ generateSpanId() {
239
+ const id = pending?.spanId
240
+ if (pending !== undefined) pending.spanId = undefined
241
+ return id ?? generateSpanId()
242
+ },
243
+ }
244
+
245
+ const active = new Map<string, { readonly span: OtelSpan; readonly at: number }>()
246
+ let nextSweepAt = Number.POSITIVE_INFINITY
247
+
248
+ const evict = (spanId: string): void => {
249
+ const entry = active.get(spanId)
250
+ if (entry === undefined) return
251
+ active.delete(spanId)
252
+ try {
253
+ entry.span.setAttributes({ "nifra.bridge.evicted": true })
254
+ entry.span.end()
255
+ } catch {
256
+ // A failing SDK cannot break the request that triggered the eviction.
257
+ }
258
+ }
259
+
260
+ // Same retention shape as effectTracing: insertion order is age order, so a sweep stops at the
261
+ // first live entry and costs O(evicted).
262
+ const sweep = (): number => {
263
+ const at = now()
264
+ if (!Number.isFinite(at) || at < nextSweepAt) return at
265
+ nextSweepAt = Number.POSITIVE_INFINITY
266
+ for (const [spanId, entry] of active) {
267
+ const expiresAt = entry.at + maxActiveAgeMs
268
+ if (at >= expiresAt) {
269
+ evict(spanId)
270
+ } else {
271
+ nextSweepAt = expiresAt
272
+ break
273
+ }
274
+ }
275
+ return at
276
+ }
277
+
278
+ const contextOf = (trace: {
279
+ readonly traceId: string
280
+ readonly spanId: string
281
+ readonly sampled: boolean
282
+ }): OtelSpanContext =>
283
+ active.get(trace.spanId)?.span.spanContext() ?? {
284
+ traceId: trace.traceId,
285
+ spanId: trace.spanId,
286
+ traceFlags: trace.sampled ? SAMPLED : 0,
287
+ isRemote: false,
288
+ }
289
+
290
+ const contextWith = (active: unknown, spanContext: () => OtelSpanContext): unknown =>
291
+ api.trace.setSpan(active, new ContextSpan(spanContext))
292
+
293
+ const adapter: ObservationAdapter = {
294
+ onStart(span: NifraSpan) {
295
+ const at = sweep()
296
+ while (active.size >= maxActive) {
297
+ const oldest = active.keys().next().value
298
+ if (oldest === undefined) break
299
+ evict(oldest)
300
+ }
301
+ const parentSpanId = span.parentSpanId
302
+ let parent = api.ROOT_CONTEXT
303
+ if (parentSpanId !== undefined) {
304
+ const mirroredParent = active.get(parentSpanId)?.span
305
+ const parentSpan =
306
+ mirroredParent ??
307
+ new ContextSpan(() => ({
308
+ traceId: span.traceId,
309
+ spanId: parentSpanId,
310
+ traceFlags: span.sampled ? SAMPLED : 0,
311
+ isRemote: true,
312
+ }))
313
+ parent = api.trace.setSpan(parent, parentSpan)
314
+ }
315
+ pending = { traceId: span.traceId, spanId: span.spanId }
316
+ let mirrored: OtelSpan
317
+ try {
318
+ const start: OtelSpanOptions = {
319
+ kind: otelKind(span.kind),
320
+ startTime: span.startTime,
321
+ attributes: { ...span.attributes },
322
+ }
323
+ if (span.links !== undefined) start.links = span.links.map(otelLink)
324
+ mirrored = tracerOf().startSpan(span.name, start, parent)
325
+ } finally {
326
+ pending = undefined
327
+ }
328
+ if (!warned && mirrored.isRecording() && mirrored.spanContext().spanId !== span.spanId) {
329
+ warned = true
330
+ onIdMismatch()
331
+ }
332
+ active.set(span.spanId, Object.freeze({ span: mirrored, at: Number.isFinite(at) ? at : 0 }))
333
+ nextSweepAt = Math.min(nextSweepAt, (Number.isFinite(at) ? at : 0) + maxActiveAgeMs)
334
+ },
335
+ onEnd(span: NifraSpan) {
336
+ const entry = active.get(span.spanId)
337
+ if (entry === undefined) return
338
+ active.delete(span.spanId)
339
+ entry.span.setAttributes(span.attributes)
340
+ if (span.status !== "unset")
341
+ entry.span.setStatus({ code: span.status === "error" ? STATUS_ERROR : STATUS_OK })
342
+ entry.span.end(span.endTime)
343
+ },
344
+ }
345
+
346
+ const scope: ObservationScope = (trace: ObservationContext, run) =>
347
+ api.context.with(
348
+ contextWith(api.context.active(), () => contextOf(trace)),
349
+ run,
350
+ )
351
+
352
+ const apply = <S extends AnyServer>(app: S): S => {
353
+ app.around((c, next) => {
354
+ const active = api.context.active()
355
+ const outer = api.trace.getSpan(active)
356
+ // The request span opens in `tracing()`'s derive, which runs inside this wrapper, so the
357
+ // context is resolved lazily when an instrumentation starts a child.
358
+ return api.context.with(
359
+ contextWith(active, () => {
360
+ const trace = traceOfContext(c)
361
+ return trace === null ? (outer?.spanContext() ?? INVALID) : contextOf(trace)
362
+ }),
363
+ next,
364
+ )
365
+ })
366
+ return app
367
+ }
368
+
369
+ return {
370
+ adapter,
371
+ plugin: defineIdentityPlugin("nifra:otel-sdk-bridge", apply),
372
+ scope,
373
+ idGenerator,
374
+ }
375
+ }
package/src/span.ts CHANGED
@@ -8,6 +8,11 @@
8
8
 
9
9
  export type SpanStatus = "unset" | "ok" | "error"
10
10
  export type AttributeValue = string | number | boolean
11
+ /**
12
+ * The OTel span kind. A span without one is exported as `server`, the kind of the request spans that
13
+ * predate this field.
14
+ */
15
+ export type SpanKind = "server" | "client" | "producer" | "consumer" | "internal"
11
16
 
12
17
  /** A non-parent causal relationship to a span in another trace (the OTel `Link` model). */
13
18
  export interface ObservationLink {
@@ -26,8 +31,10 @@ export interface NifraSpan {
26
31
  readonly parentSpanId?: string
27
32
  /** Whether the trace is sampled (the W3C flag). */
28
33
  readonly sampled: boolean
29
- /** Span name - `"<METHOD> <path>"`. */
34
+ /** Span name - `"<METHOD> <path>"` for a request. */
30
35
  readonly name: string
36
+ /** OTel span kind. Absent means `server`. */
37
+ readonly kind?: SpanKind
31
38
  /** Wall-clock start (epoch ms). */
32
39
  readonly startTime: number
33
40
  /** Wall-clock end (epoch ms) - set on completion. */
@@ -92,6 +99,7 @@ export function consoleSpanExporter(
92
99
  log(
93
100
  JSON.stringify({
94
101
  name: span.name,
102
+ kind: span.kind,
95
103
  traceId: span.traceId,
96
104
  spanId: span.spanId,
97
105
  parentSpanId: span.parentSpanId,
package/src/tracing.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  createObservationLifecycle,
20
20
  type ObservationContext,
21
21
  } from "./lifecycle.ts"
22
+ import { methodLabel, routeTemplateOf } from "./route-template.ts"
22
23
  import { consoleSpanExporter, type ObservationAdapter } from "./span.ts"
23
24
 
24
25
  /** The trace context exposed on the handler `c.trace` (typed, threaded via `derive`). */
@@ -115,14 +116,22 @@ export function tracing(options: TracingOptions = {}): ContextPlugin<TracingCont
115
116
 
116
117
  return defineContextPlugin<TracingContext>("tracing", (app) => {
117
118
  for (const stop of stopHooks) app.onStop(stop)
119
+ const routeOf = routeTemplateOf(app)
118
120
  return app
119
121
  .derive((c) => {
120
122
  const path = pathOf(c.req.url)
123
+ const method = methodLabel(c.req.method)
124
+ // Named `{method} {route template}` per the HTTP conventions: the raw path would make every
125
+ // id its own span name, and carry whatever a path holds into every trace backend's index.
126
+ const route = routeOf(c.req.method, path)
121
127
  const observation = lifecycle.start({
122
- name: `${c.req.method} ${path}`,
128
+ name: `${method === "_OTHER" ? "HTTP" : method}${route === undefined ? "" : ` ${route}`}`,
129
+ kind: "server",
123
130
  traceparent: c.req.headers.get("traceparent"),
124
131
  attributes: {
125
- "http.request.method": c.req.method,
132
+ "http.request.method": method,
133
+ ...(method === "_OTHER" ? { "http.request.method_original": c.req.method } : {}),
134
+ ...(route === undefined ? {} : { "http.route": route }),
126
135
  "url.path": path,
127
136
  ...(serviceName === undefined ? {} : { "service.name": serviceName }),
128
137
  },