@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.
- package/README.md +90 -3
- package/dist/cache.d.ts +46 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +78 -0
- package/dist/cache.js.map +1 -0
- package/dist/context-trace.d.ts +8 -0
- package/dist/context-trace.d.ts.map +1 -0
- package/dist/context-trace.js +16 -0
- package/dist/context-trace.js.map +1 -0
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +2 -0
- package/dist/effects.js.map +1 -1
- package/dist/events.d.ts +69 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +106 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +56 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +98 -0
- package/dist/jobs.js.map +1 -0
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +12 -3
- package/dist/lifecycle.js.map +1 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +0 -0
- package/dist/metrics.js.map +1 -1
- package/dist/otlp.d.ts.map +1 -1
- package/dist/otlp.js +17 -1
- package/dist/otlp.js.map +1 -1
- package/dist/route-template.d.ts +14 -0
- package/dist/route-template.d.ts.map +1 -0
- package/dist/route-template.js +38 -0
- package/dist/route-template.js.map +1 -0
- package/dist/sdk-bridge.d.ts +107 -0
- package/dist/sdk-bridge.d.ts.map +1 -0
- package/dist/sdk-bridge.js +243 -0
- package/dist/sdk-bridge.js.map +1 -0
- package/dist/span.d.ts +8 -1
- package/dist/span.d.ts.map +1 -1
- package/dist/span.js +1 -0
- package/dist/span.js.map +1 -1
- package/dist/tracing.d.ts.map +1 -1
- package/dist/tracing.js +11 -2
- package/dist/tracing.js.map +1 -1
- package/package.json +33 -3
- package/src/cache.ts +115 -0
- package/src/context-trace.ts +14 -0
- package/src/effects.ts +2 -0
- package/src/events.ts +170 -0
- package/src/index.ts +2 -0
- package/src/jobs.ts +141 -0
- package/src/lifecycle.ts +26 -3
- package/src/metrics.ts +0 -0
- package/src/otlp.ts +18 -2
- package/src/route-template.ts +43 -0
- package/src/sdk-bridge.ts +375 -0
- package/src/span.ts +9 -1
- 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 =
|
|
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
|
-
|
|
217
|
-
|
|
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:
|
|
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
|
+
}
|