@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
package/src/events.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Consumer spans for `@nifrajs/events` envelopes. nifra does not deliver events, so it cannot trace them
3
+ * on its own: wrap the consumer once and every envelope it receives gets a `process <type>` span, linked
4
+ * to the producer through the envelope's durable causality.
5
+ *
6
+ * import { traceEventConsumer } from "@nifrajs/otel/events"
7
+ * const consume = traceEventConsumer(orderPaid, async (event, ctx) => fulfil(event.payload), { exporter })
8
+ * await consume(message.body) // from a queue, an SSE frame, a webhook body
9
+ */
10
+
11
+ import type { CausalityContext } from "@nifrajs/core/causality"
12
+ import { causalitySpanLink } from "./causality.ts"
13
+ import {
14
+ createObservationLifecycle,
15
+ type ObservationContext,
16
+ type ObservationScope,
17
+ type StartObservation,
18
+ } from "./lifecycle.ts"
19
+ import type { AttributeValue, ObservationAdapter } from "./span.ts"
20
+ import { parseTraceparent } from "./traceparent.ts"
21
+
22
+ /** The envelope fields a consumer span reads. `EventEnvelope` from `@nifrajs/events` has them. */
23
+ export interface TracedEventEnvelope {
24
+ readonly id: string
25
+ readonly type: string
26
+ readonly version: number
27
+ readonly causality?: CausalityContext
28
+ }
29
+
30
+ /**
31
+ * An event contract or a registry from `@nifrajs/events`, declared structurally: anything whose
32
+ * `parse(input)` never throws and reports success with an envelope, or failure with `issues` (a contract)
33
+ * or a `reason` (a registry).
34
+ */
35
+ export interface TracedEventSource<Envelope extends TracedEventEnvelope> {
36
+ /** A contract's own type, used to name the span of an envelope that failed to parse. */
37
+ readonly type?: string
38
+ parse(input: unknown):
39
+ | { readonly success: true; readonly envelope: Envelope }
40
+ | {
41
+ readonly success: false
42
+ readonly issues?: readonly unknown[]
43
+ readonly reason?: string
44
+ }
45
+ }
46
+
47
+ /** What the wrapped handler receives next to the envelope: the consumer span's trace context. */
48
+ export interface EventConsumerContext {
49
+ readonly trace: ObservationContext
50
+ }
51
+
52
+ export type TracedEventResult<Value> =
53
+ | { readonly success: true; readonly value: Value }
54
+ | { readonly success: false; readonly issueCount: number }
55
+
56
+ /** The traced consumer. `parent` is an ambient trace (`c.trace` in a webhook route), if there is one. */
57
+ export type TracedEventConsumer<Value> = (
58
+ input: unknown,
59
+ parent?: ObservationContext,
60
+ ) => Promise<TracedEventResult<Value>>
61
+
62
+ export interface EventTracingOptions {
63
+ readonly exporter?: ObservationAdapter
64
+ readonly adapters?: readonly ObservationAdapter[]
65
+ /** `messaging.system`: the transport the envelopes arrive on. Default `"nifra.events"`. */
66
+ readonly system?: string
67
+ /** Runs the handler with the consumer span active in an ambient context (see `ObservationScope`). */
68
+ readonly scope?: ObservationScope
69
+ }
70
+
71
+ const REGISTRY_REASONS: ReadonlySet<string> = new Set([
72
+ "not-an-object",
73
+ "unknown-contract",
74
+ "invalid-payload",
75
+ ])
76
+ const TYPE_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/
77
+
78
+ /**
79
+ * Wrap an event consumer so each envelope is one `process <type>` span (kind consumer), linked to the
80
+ * producer's span through `causalitySpanLink(envelope.causality)`. With a `parent`, the span is that
81
+ * span's child; otherwise it starts its own trace. Attributes: `nifra.event.type`, `nifra.event.version`,
82
+ * `messaging.message.id` (the envelope id), and the messaging operation.
83
+ *
84
+ * Input that fails to parse never reaches the handler: it becomes an error span carrying only an issue
85
+ * count (and a registry's bounded reason), resolves `{ success: false }`, and exports no payload. A
86
+ * throwing handler ends its span as an error and rejects.
87
+ */
88
+ export function traceEventConsumer<Envelope extends TracedEventEnvelope, Result>(
89
+ source: TracedEventSource<Envelope>,
90
+ handler: (envelope: Envelope, context: EventConsumerContext) => Result,
91
+ options: EventTracingOptions = {},
92
+ ): TracedEventConsumer<Awaited<Result>> {
93
+ const adapters = [
94
+ ...(options.exporter === undefined ? [] : [options.exporter]),
95
+ ...(options.adapters ?? []),
96
+ ]
97
+ const lifecycle = createObservationLifecycle({ adapters })
98
+ const system = options.system ?? "nifra.events"
99
+ const scope = options.scope
100
+ const sourceType =
101
+ typeof source.type === "string" && TYPE_NAME.test(source.type) ? source.type : undefined
102
+ const operation = {
103
+ "messaging.system": system,
104
+ "messaging.operation.type": "process",
105
+ "messaging.operation.name": "process",
106
+ }
107
+
108
+ return async (input, parent) => {
109
+ const ambient = parent === undefined ? null : parseTraceparent(parent.traceparent)
110
+ const name = sourceType === undefined ? "process" : `process ${sourceType}`
111
+ let parsed: ReturnType<TracedEventSource<Envelope>["parse"]>
112
+ try {
113
+ parsed = source.parse(input)
114
+ } catch (error) {
115
+ // A contract with an async payload schema throws here; that is a bug, not bad input.
116
+ lifecycle
117
+ .start({ name, kind: "consumer", parent: ambient, attributes: operation })
118
+ .end({ status: "error", attributes: { "error.type": "_OTHER" } })
119
+ throw error
120
+ }
121
+ if (!parsed.success) {
122
+ const attributes: Record<string, AttributeValue> = {
123
+ ...operation,
124
+ "nifra.event.issue_count": parsed.issues?.length ?? 1,
125
+ "error.type": "invalid_event",
126
+ }
127
+ if (parsed.reason !== undefined && REGISTRY_REASONS.has(parsed.reason))
128
+ attributes["nifra.event.parse_failure"] = parsed.reason
129
+ lifecycle
130
+ .start({
131
+ name,
132
+ kind: "consumer",
133
+ parent: ambient,
134
+ attributes,
135
+ })
136
+ .end({ status: "error" })
137
+ return { success: false, issueCount: parsed.issues?.length ?? 1 }
138
+ }
139
+
140
+ const envelope = parsed.envelope
141
+ const start: { -readonly [K in keyof StartObservation]: StartObservation[K] } = {
142
+ name: `process ${envelope.type}`,
143
+ kind: "consumer",
144
+ parent: ambient,
145
+ attributes: {
146
+ ...operation,
147
+ "messaging.message.id": envelope.id,
148
+ "nifra.event.type": envelope.type,
149
+ "nifra.event.version": envelope.version,
150
+ },
151
+ }
152
+ const link =
153
+ envelope.causality === undefined ? undefined : causalitySpanLink(envelope.causality)
154
+ if (link !== undefined) start.links = [link]
155
+ const span = lifecycle.start(start)
156
+ const context: EventConsumerContext = { trace: span.context }
157
+ try {
158
+ const value =
159
+ scope === undefined
160
+ ? await handler(envelope, context)
161
+ : await scope(span.context, () => handler(envelope, context))
162
+ span.end({ status: "ok" })
163
+ return { success: true, value }
164
+ } catch (error) {
165
+ span.recordError(error)
166
+ span.end({ status: "error", attributes: { "error.type": "_OTHER" } })
167
+ throw error
168
+ }
169
+ }
170
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  type ObservationLifecycle,
25
25
  type ObservationLifecycleOptions,
26
26
  type ObservationParent,
27
+ type ObservationScope,
27
28
  type StartObservation,
28
29
  } from "./lifecycle.ts"
29
30
  export { type OtlpExporter, type OtlpExporterOptions, otlpExporter } from "./otlp.ts"
@@ -34,6 +35,7 @@ export {
34
35
  type NifraSpan,
35
36
  type ObservationAdapter,
36
37
  type ObservationLink,
38
+ type SpanKind,
37
39
  type SpanStatus,
38
40
  } from "./span.ts"
39
41
  export {
package/src/jobs.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Spans for `@nifrajs/jobs`, following the OpenTelemetry messaging conventions. `jobTracing()` is the
3
+ * queue's `instrument`: each enqueue is a `send <job>` producer span and each attempt a `process <job>`
4
+ * consumer span. The send span's context is stored with the job, and the process span is its CHILD
5
+ * (plus a link to it), so one trace runs from the request that enqueued the job through every attempt.
6
+ *
7
+ * import { jobTracing } from "@nifrajs/otel/jobs"
8
+ * const queue = createQueue({ store, instrument: jobTracing({ exporter }) })
9
+ * await sendEmail.for(c).enqueue({ to }) // "send send-email", child of the request span
10
+ */
11
+
12
+ import {
13
+ createObservationLifecycle,
14
+ type ObservationContext,
15
+ type ObservationScope,
16
+ type StartObservation,
17
+ } from "./lifecycle.ts"
18
+ import type { AttributeValue, ObservationAdapter } from "./span.ts"
19
+ import { parseTraceparent } from "./traceparent.ts"
20
+
21
+ /** `JobEnqueueInfo` from `@nifrajs/jobs`, declared structurally so this package does not depend on it. */
22
+ export interface JobTracingEnqueueInfo {
23
+ readonly name: string
24
+ readonly traceparent: string | undefined
25
+ }
26
+
27
+ /** `JobRunInfo` from `@nifrajs/jobs`, declared structurally. */
28
+ export interface JobTracingRunInfo {
29
+ readonly id: string
30
+ readonly name: string
31
+ readonly attempt: number
32
+ readonly maxAttempts: number
33
+ readonly traceparent: string | undefined
34
+ }
35
+
36
+ /** Structurally a `QueueInstrument` from `@nifrajs/jobs`: pass it as `createQueue({ instrument })`. */
37
+ export interface JobTracingInstrument {
38
+ enqueue(
39
+ info: JobTracingEnqueueInfo,
40
+ next: (scope?: { readonly traceparent?: string }) => Promise<string>,
41
+ ): Promise<string>
42
+ run(
43
+ info: JobTracingRunInfo,
44
+ next: (scope?: { readonly trace?: ObservationContext }) => Promise<string>,
45
+ ): Promise<string>
46
+ }
47
+
48
+ export interface JobTracingOptions {
49
+ readonly exporter?: ObservationAdapter
50
+ readonly adapters?: readonly ObservationAdapter[]
51
+ /**
52
+ * Runs each attempt with its process span active in an ambient context. Pass `otelBridge().scope`
53
+ * from `@nifrajs/otel/sdk-bridge` so OpenTelemetry instrumentations (pg, undici) inside the handler
54
+ * nest under the process span.
55
+ */
56
+ readonly scope?: ObservationScope
57
+ }
58
+
59
+ const SYSTEM = "nifra.jobs"
60
+
61
+ /**
62
+ * The queue instrument. Span names are `send <job name>` (kind producer) and `process <job name>` (kind
63
+ * consumer), with `messaging.system = "nifra.jobs"`, `messaging.operation.type`/`.name`,
64
+ * `messaging.destination.name` (the job name) and `messaging.message.id`. A process span also carries
65
+ * `nifra.job.attempt` and `nifra.job.outcome`; an attempt that failed is an error span, and one that
66
+ * dead-lettered the job adds `nifra.job.dead_lettered = true`. Error text is never exported.
67
+ *
68
+ * The stored traceparent is untrusted: a malformed one starts the process span as a new trace. The
69
+ * sampled flag travels with it.
70
+ */
71
+ export function jobTracing(options: JobTracingOptions = {}): JobTracingInstrument {
72
+ const adapters = [
73
+ ...(options.exporter === undefined ? [] : [options.exporter]),
74
+ ...(options.adapters ?? []),
75
+ ]
76
+ const lifecycle = createObservationLifecycle({ adapters })
77
+ const scope = options.scope
78
+
79
+ return {
80
+ async enqueue(info, next) {
81
+ const span = lifecycle.start({
82
+ name: `send ${info.name}`,
83
+ kind: "producer",
84
+ parent: parseTraceparent(info.traceparent),
85
+ attributes: {
86
+ "messaging.system": SYSTEM,
87
+ "messaging.operation.type": "send",
88
+ "messaging.operation.name": "send",
89
+ "messaging.destination.name": info.name,
90
+ },
91
+ })
92
+ try {
93
+ const id = await next({ traceparent: span.context.traceparent })
94
+ span.end({ status: "ok", attributes: { "messaging.message.id": id } })
95
+ return id
96
+ } catch (error) {
97
+ span.recordError(error)
98
+ span.end({ status: "error", attributes: { "error.type": "_OTHER" } })
99
+ throw error
100
+ }
101
+ },
102
+ async run(info, next) {
103
+ const creation = parseTraceparent(info.traceparent)
104
+ const start: { -readonly [K in keyof StartObservation]: StartObservation[K] } = {
105
+ name: `process ${info.name}`,
106
+ kind: "consumer",
107
+ parent: creation,
108
+ attributes: {
109
+ "messaging.system": SYSTEM,
110
+ "messaging.operation.type": "process",
111
+ "messaging.operation.name": "process",
112
+ "messaging.destination.name": info.name,
113
+ "messaging.message.id": info.id,
114
+ "nifra.job.attempt": info.attempt,
115
+ },
116
+ }
117
+ if (creation !== null) start.links = [{ traceId: creation.traceId, spanId: creation.spanId }]
118
+ const span = lifecycle.start(start)
119
+ const trace = span.context
120
+ try {
121
+ const outcome =
122
+ scope === undefined ? await next({ trace }) : await scope(trace, () => next({ trace }))
123
+ if (outcome === "completed") {
124
+ span.end({ status: "ok", attributes: { "nifra.job.outcome": outcome } })
125
+ } else {
126
+ const attributes: Record<string, AttributeValue> = {
127
+ "nifra.job.outcome": outcome,
128
+ "error.type": "_OTHER",
129
+ }
130
+ if (outcome === "dead-lettered") attributes["nifra.job.dead_lettered"] = true
131
+ span.end({ status: "error", attributes })
132
+ }
133
+ return outcome
134
+ } catch (error) {
135
+ span.recordError(error)
136
+ span.end({ status: "error", attributes: { "error.type": "_OTHER" } })
137
+ throw error
138
+ }
139
+ },
140
+ }
141
+ }
package/src/lifecycle.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  NifraSpan,
10
10
  ObservationAdapter,
11
11
  ObservationLink,
12
+ SpanKind,
12
13
  SpanStatus,
13
14
  } from "./span.ts"
14
15
  import {
@@ -27,6 +28,13 @@ export interface ObservationContext {
27
28
  readonly traceparent: string
28
29
  }
29
30
 
31
+ /**
32
+ * Runs `run` with a span active in an ambient context (OpenTelemetry's, for one), so code that only
33
+ * sees that context - a pg or undici instrumentation - nests under the span. `otelBridge().scope` from
34
+ * `@nifrajs/otel/sdk-bridge` is one.
35
+ */
36
+ export type ObservationScope = <T>(trace: ObservationContext, run: () => T) => T
37
+
30
38
  export interface ObservationParent {
31
39
  readonly traceId: string
32
40
  readonly spanId: string
@@ -35,6 +43,8 @@ export interface ObservationParent {
35
43
 
36
44
  export interface StartObservation {
37
45
  readonly name: string
46
+ /** OTel span kind. Omitted, the span exports as `server`. */
47
+ readonly kind?: SpanKind
38
48
  /** Explicit parent. `null` forces a root span; `undefined` falls back to `traceparent`. */
39
49
  readonly parent?: ObservationParent | null
40
50
  /** Inbound W3C header used when `parent` is undefined. */
@@ -42,6 +52,8 @@ export interface StartObservation {
42
52
  readonly attributes?: Readonly<Record<string, AttributeValue>>
43
53
  /** Non-parent causal relationships, for example an outbox event that resumed this workflow. */
44
54
  readonly links?: readonly ObservationLink[]
55
+ /** Epoch ms the work began, for work recorded after it settled. Default: now. */
56
+ readonly startTime?: number
45
57
  }
46
58
 
47
59
  export interface EndObservation {
@@ -49,6 +61,8 @@ export interface EndObservation {
49
61
  /** Explicit status wins over status-code and recorded-error classification. */
50
62
  readonly status?: Exclude<SpanStatus, "unset">
51
63
  readonly attributes?: Readonly<Record<string, AttributeValue>>
64
+ /** Measured duration in ms, for work recorded after it settled. The end time becomes start + duration. */
65
+ readonly durationMs?: number
52
66
  }
53
67
 
54
68
  export interface ActiveObservation {
@@ -139,7 +153,10 @@ export function createObservationLifecycle(
139
153
  const traceId = parent?.traceId ?? newTraceId()
140
154
  const spanId = newSpanId()
141
155
  const sampled = parent?.sampled ?? true
142
- const startTime = clock.wallTime()
156
+ const startTime =
157
+ input.startTime !== undefined && Number.isFinite(input.startTime)
158
+ ? input.startTime
159
+ : clock.wallTime()
143
160
  const monotonicStart = clock.monotonicTime()
144
161
  const span: NifraSpan = {
145
162
  traceId,
@@ -147,6 +164,7 @@ export function createObservationLifecycle(
147
164
  ...(parent === null ? {} : { parentSpanId: parent.spanId }),
148
165
  sampled,
149
166
  name: input.name,
167
+ ...(input.kind === undefined ? {} : { kind: input.kind }),
150
168
  startTime,
151
169
  status: "unset",
152
170
  attributes: { ...input.attributes },
@@ -213,8 +231,13 @@ export function createObservationLifecycle(
213
231
  if (ended) return span
214
232
  ended = true
215
233
  Object.assign(span.attributes, endInput.attributes)
216
- span.endTime = clock.wallTime()
217
- span.durationMs = Math.max(0, clock.monotonicTime() - monotonicStart)
234
+ if (endInput.durationMs !== undefined && Number.isFinite(endInput.durationMs)) {
235
+ span.durationMs = Math.max(0, endInput.durationMs)
236
+ span.endTime = span.startTime + span.durationMs
237
+ } else {
238
+ span.endTime = clock.wallTime()
239
+ span.durationMs = Math.max(0, clock.monotonicTime() - monotonicStart)
240
+ }
218
241
  span.status =
219
242
  endInput.status ??
220
243
  (endInput.statusCode === undefined
package/src/metrics.ts CHANGED
Binary file
package/src/otlp.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * request); delivery failures reach `onError`.
9
9
  */
10
10
 
11
- import type { AttributeValue, NifraSpan, ObservationAdapter, SpanStatus } from "./span.ts"
11
+ import type { AttributeValue, NifraSpan, ObservationAdapter, SpanKind, SpanStatus } from "./span.ts"
12
12
 
13
13
  export interface OtlpExporterOptions {
14
14
  /** The collector's traces endpoint, e.g. `http://localhost:4318/v1/traces`. */
@@ -40,6 +40,22 @@ export interface OtlpExporter extends ObservationAdapter {
40
40
  }
41
41
 
42
42
  const STATUS_CODE: Readonly<Record<SpanStatus, number>> = { unset: 0, ok: 1, error: 2 }
43
+ // OTLP `Span.SpanKind`; 0 (UNSPECIFIED) is never sent. A kind is caller data, so anything unknown
44
+ // exports as server, the kind of the spans that predate the field.
45
+ function kindCode(kind: SpanKind | undefined): number {
46
+ switch (kind) {
47
+ case "internal":
48
+ return 1
49
+ case "client":
50
+ return 3
51
+ case "producer":
52
+ return 4
53
+ case "consumer":
54
+ return 5
55
+ default:
56
+ return 2
57
+ }
58
+ }
43
59
 
44
60
  function assertPositiveSafeInteger(value: number, option: string): void {
45
61
  if (!Number.isSafeInteger(value) || value <= 0) {
@@ -69,7 +85,7 @@ function toOtlpSpan(span: NifraSpan): Record<string, unknown> {
69
85
  spanId: span.spanId,
70
86
  ...(span.parentSpanId !== undefined ? { parentSpanId: span.parentSpanId } : {}),
71
87
  name: span.name,
72
- kind: 2, // SPAN_KIND_SERVER
88
+ kind: kindCode(span.kind),
73
89
  startTimeUnixNano: String(span.startTime * MS_TO_NANOS),
74
90
  endTimeUnixNano: String(end * MS_TO_NANOS),
75
91
  attributes: keyValues(span.attributes),
@@ -0,0 +1,43 @@
1
+ import { type Method, Router } from "@nifrajs/core/router"
2
+ import type { RouteDescriptor } from "@nifrajs/core/server"
3
+
4
+ const KNOWN_METHODS: ReadonlySet<string> = new Set([
5
+ "GET",
6
+ "HEAD",
7
+ "POST",
8
+ "PUT",
9
+ "DELETE",
10
+ "PATCH",
11
+ "OPTIONS",
12
+ "CONNECT",
13
+ "TRACE",
14
+ ])
15
+
16
+ /** A method as a label or span name may carry it. Any other method a runtime accepts (Deno takes
17
+ * extension tokens) is `_OTHER`, as OpenTelemetry's HTTP conventions name it, so a client cannot
18
+ * mint a new series or span name per request. */
19
+ export function methodLabel(method: string): string {
20
+ return KNOWN_METHODS.has(method) ? method : "_OTHER"
21
+ }
22
+
23
+ /**
24
+ * A request's matched route template (`/users/:id`, never `/users/42`), or `undefined` when no route
25
+ * serves it. The matcher is built on first use: by serving time every route is registered, so
26
+ * `app.routes()` is complete, where building it when the plugin is applied would miss later routes.
27
+ */
28
+ export function routeTemplateOf(app: {
29
+ routes(): ReadonlyArray<RouteDescriptor>
30
+ }): (method: string, path: string) => string | undefined {
31
+ let matcher: Router<string> | undefined
32
+ return (method, path) => {
33
+ if (matcher === undefined) {
34
+ matcher = new Router<string>()
35
+ for (const route of app.routes()) {
36
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every registered route's method is one this router already accepted when the app registered it.
37
+ matcher.add(route.method as Method, route.path, route.path)
38
+ }
39
+ }
40
+ const found = matcher.find(method, path)
41
+ return found.found ? found.payload : undefined
42
+ }
43
+ }