@nifrajs/otel 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -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: `${
|
|
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":
|
|
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
|
},
|