@lesto/observability 0.1.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/package.json +40 -0
- package/src/exporter.ts +13 -0
- package/src/ids.ts +11 -0
- package/src/index.ts +107 -0
- package/src/otlp.ts +209 -0
- package/src/rum.ts +615 -0
- package/src/time.ts +10 -0
- package/src/traceparent.ts +111 -0
- package/src/tracer.ts +171 -0
- package/src/traces.ts +442 -0
- package/src/types.ts +44 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C Trace Context `traceparent` — the propagation header, verbatim.
|
|
3
|
+
*
|
|
4
|
+
* One header carries a trace across a process boundary: an inbound request
|
|
5
|
+
* adopts the upstream's trace id so the server span joins the same trace, and an
|
|
6
|
+
* outbound call (a webhook, an edge sub-request) emits its own so the next hop
|
|
7
|
+
* continues it. We implement the W3C format EXACTLY — never an invented one —
|
|
8
|
+
* because that is the wire every collector, vendor, and sibling service already
|
|
9
|
+
* speaks (the NIH boundary line operability-dx draws):
|
|
10
|
+
*
|
|
11
|
+
* version "-" trace-id "-" parent-id "-" trace-flags
|
|
12
|
+
* 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
|
|
13
|
+
*
|
|
14
|
+
* - version : 2 hex, the format version. We read only "00"; any other
|
|
15
|
+
* version we DROP (a future format we cannot trust to parse).
|
|
16
|
+
* - trace-id : 32 hex, the trace. All-zero is invalid (the spec's sentinel).
|
|
17
|
+
* - parent-id : 16 hex, the caller's span — our span's PARENT. All-zero is
|
|
18
|
+
* invalid.
|
|
19
|
+
* - trace-flags: 2 hex; bit 0 is "sampled". We carry it through unread (we
|
|
20
|
+
* always export) but format it honestly.
|
|
21
|
+
*
|
|
22
|
+
* Lesto's own ids are 32-hex traceIds and 32-hex spanIds; OTLP already truncates
|
|
23
|
+
* a spanId to 16 hex on export (see `otlp.ts`), and traceparent's parent-id is
|
|
24
|
+
* the same 16-hex span field — so {@link formatTraceparent} truncates a 32-hex
|
|
25
|
+
* spanId to its first 16 hex, matching what the collector sees.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The only `version` field we parse — a future version is dropped, not guessed. */
|
|
29
|
+
const SUPPORTED_VERSION = "00";
|
|
30
|
+
|
|
31
|
+
/** Default `trace-flags`: `01` = sampled. We always export, so we always set it. */
|
|
32
|
+
const SAMPLED_FLAGS = "01";
|
|
33
|
+
|
|
34
|
+
/** A 32-hex trace id (lowercase). */
|
|
35
|
+
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
36
|
+
|
|
37
|
+
/** A 16-hex parent (span) id (lowercase). */
|
|
38
|
+
const PARENT_ID = /^[0-9a-f]{16}$/;
|
|
39
|
+
|
|
40
|
+
/** A 2-hex flags byte (lowercase). */
|
|
41
|
+
const FLAGS = /^[0-9a-f]{2}$/;
|
|
42
|
+
|
|
43
|
+
/** The all-zero sentinel both id fields treat as invalid, per the spec. */
|
|
44
|
+
const isAllZero = (hex: string): boolean => /^0+$/.test(hex);
|
|
45
|
+
|
|
46
|
+
/** The parsed pieces of a valid `traceparent` an inbound request carried. */
|
|
47
|
+
export interface Traceparent {
|
|
48
|
+
/** The 32-hex trace id this request belongs to — our root span adopts it. */
|
|
49
|
+
readonly traceId: string;
|
|
50
|
+
|
|
51
|
+
/** The 16-hex caller span id — the PARENT of the span we are about to mint. */
|
|
52
|
+
readonly parentId: string;
|
|
53
|
+
|
|
54
|
+
/** The 2-hex trace-flags byte, carried through verbatim (bit 0 = sampled). */
|
|
55
|
+
readonly flags: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a `traceparent` header value, or `undefined` if it is absent/malformed.
|
|
60
|
+
*
|
|
61
|
+
* We are STRICT on inbound (the spec's posture for a received header): a header
|
|
62
|
+
* we cannot fully trust is treated as no header at all, and the request roots a
|
|
63
|
+
* fresh trace instead of joining a forged or garbled one. Rejected: an absent
|
|
64
|
+
* value, the wrong field count, a non-"00" version, a non-hex or wrong-width id,
|
|
65
|
+
* an all-zero (sentinel) trace or parent id, or a malformed flags byte. Lowercase
|
|
66
|
+
* is required — the spec mandates lowercase hex, so an uppercase value is not a
|
|
67
|
+
* valid traceparent.
|
|
68
|
+
*
|
|
69
|
+
* Pure and total so every reject branch is unit-testable without a socket.
|
|
70
|
+
*/
|
|
71
|
+
export function parseTraceparent(header: string | undefined): Traceparent | undefined {
|
|
72
|
+
if (header === undefined) return undefined;
|
|
73
|
+
|
|
74
|
+
const parts = header.split("-");
|
|
75
|
+
|
|
76
|
+
// Exactly four fields: version, trace-id, parent-id, flags.
|
|
77
|
+
if (parts.length !== 4) return undefined;
|
|
78
|
+
|
|
79
|
+
const [version, traceId, parentId, flags] = parts as [string, string, string, string];
|
|
80
|
+
|
|
81
|
+
// Only the version we know how to read; a future format we cannot trust.
|
|
82
|
+
if (version !== SUPPORTED_VERSION) return undefined;
|
|
83
|
+
|
|
84
|
+
if (!TRACE_ID.test(traceId) || isAllZero(traceId)) return undefined;
|
|
85
|
+
|
|
86
|
+
if (!PARENT_ID.test(parentId) || isAllZero(parentId)) return undefined;
|
|
87
|
+
|
|
88
|
+
if (!FLAGS.test(flags)) return undefined;
|
|
89
|
+
|
|
90
|
+
return { traceId, parentId, flags };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format a `traceparent` header for an outbound hop from a trace + span id.
|
|
95
|
+
*
|
|
96
|
+
* `spanId` is the CURRENT span — it becomes the next hop's parent-id. A Lesto
|
|
97
|
+
* spanId is 32 hex; the traceparent parent-id field is 16 hex (the same field
|
|
98
|
+
* OTLP truncates a spanId to), so we take its first 16 hex to match what the
|
|
99
|
+
* collector records. `flags` defaults to `01` (sampled) — we always export. The
|
|
100
|
+
* output is a spec-valid `00-…` header verbatim.
|
|
101
|
+
*
|
|
102
|
+
* The caller guarantees `traceId`/`spanId` are hex of at least the needed width
|
|
103
|
+
* (they come from the tracer's id generator); we slice to the field widths the
|
|
104
|
+
* format demands.
|
|
105
|
+
*/
|
|
106
|
+
export function formatTraceparent(traceId: string, spanId: string, flags = SAMPLED_FLAGS): string {
|
|
107
|
+
return `${SUPPORTED_VERSION}-${traceId.slice(0, 32)}-${spanId.slice(0, 16)}-${flags}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The canonical header name, lowercase (the form node/fetch deliver and want). */
|
|
111
|
+
export const TRACEPARENT_HEADER = "traceparent";
|
package/src/tracer.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomHexId } from "./ids";
|
|
2
|
+
import { systemClock } from "./time";
|
|
3
|
+
|
|
4
|
+
import type { Clock, Span, SpanData, SpanExporter, SpanStatus } from "./types";
|
|
5
|
+
|
|
6
|
+
/** What a Tracer needs from the outside world. Everything that varies is injected. */
|
|
7
|
+
export interface TracerOptions {
|
|
8
|
+
readonly exporter: SpanExporter;
|
|
9
|
+
|
|
10
|
+
/** Defaults to the system wall clock (epoch ms). */
|
|
11
|
+
readonly clock?: Clock;
|
|
12
|
+
|
|
13
|
+
/** Defaults to a random hex id. */
|
|
14
|
+
readonly idGenerator?: () => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An inbound trace adopted from a W3C `traceparent` — the cross-process join.
|
|
19
|
+
*
|
|
20
|
+
* Unlike {@link StartSpanOptions.parent} (a live `Span` we hold), this carries
|
|
21
|
+
* ONLY the ids another system sent: the `traceId` to continue and the `parentId`
|
|
22
|
+
* (the caller's 16-hex span) to point back at. The runtime parses an inbound
|
|
23
|
+
* `traceparent` into this shape so the request's root span joins the upstream
|
|
24
|
+
* trace instead of starting a fresh one.
|
|
25
|
+
*/
|
|
26
|
+
export interface InboundTrace {
|
|
27
|
+
readonly traceId: string;
|
|
28
|
+
|
|
29
|
+
readonly parentId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where a new span sits in the tree, and what it starts knowing. */
|
|
33
|
+
export interface StartSpanOptions {
|
|
34
|
+
/** Given a parent, the new span joins its trace as a child; absent, it roots a fresh trace. */
|
|
35
|
+
readonly parent?: Span;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Given an inbound trace (parsed from a W3C `traceparent`), the new span
|
|
39
|
+
* continues THAT trace: it adopts the inbound `traceId` and points back at the
|
|
40
|
+
* inbound `parentId`. Used for the cross-process join — a request's root span
|
|
41
|
+
* joining the caller's trace. Ignored when {@link parent} is also set (a live
|
|
42
|
+
* parent wins, since it carries a real span we own).
|
|
43
|
+
*/
|
|
44
|
+
readonly inbound?: InboundTrace;
|
|
45
|
+
|
|
46
|
+
readonly attributes?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A live span. The fluent setters return `this`, so attributes and status read
|
|
51
|
+
* as a chain; `end()` stamps the end time from the clock and hands the data to
|
|
52
|
+
* the exporter — exactly once per span.
|
|
53
|
+
*/
|
|
54
|
+
class LiveSpan implements Span {
|
|
55
|
+
readonly data: SpanData;
|
|
56
|
+
|
|
57
|
+
/** A span ends exactly once; a second `end()` is a no-op, never a re-export. */
|
|
58
|
+
private ended = false;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
data: SpanData,
|
|
62
|
+
private readonly clock: Clock,
|
|
63
|
+
private readonly exporter: SpanExporter,
|
|
64
|
+
) {
|
|
65
|
+
this.data = data;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setAttribute(key: string, value: unknown): this {
|
|
69
|
+
this.data.attributes[key] = value;
|
|
70
|
+
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setStatus(status: SpanStatus): this {
|
|
75
|
+
this.data.status = status;
|
|
76
|
+
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
end(): void {
|
|
81
|
+
// Idempotent: the first call stamps the end time and exports; any later call
|
|
82
|
+
// returns silently so a span is never double-counted (e.g. an explicit
|
|
83
|
+
// `end()` inside a `withSpan` body plus the `finally`'s `end()`).
|
|
84
|
+
if (this.ended) return;
|
|
85
|
+
|
|
86
|
+
this.ended = true;
|
|
87
|
+
|
|
88
|
+
this.data.endedAt = this.clock();
|
|
89
|
+
|
|
90
|
+
this.exporter.export(this.data);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The Tracer mints spans and wires them to the exporter.
|
|
96
|
+
*
|
|
97
|
+
* A root span gets a fresh traceId and no parent; a child inherits its parent's
|
|
98
|
+
* traceId and points back at the parent spanId. Every span — root or child —
|
|
99
|
+
* gets its own fresh spanId.
|
|
100
|
+
*/
|
|
101
|
+
export class Tracer {
|
|
102
|
+
private readonly exporter: SpanExporter;
|
|
103
|
+
|
|
104
|
+
private readonly clock: Clock;
|
|
105
|
+
|
|
106
|
+
private readonly idGenerator: () => string;
|
|
107
|
+
|
|
108
|
+
constructor(options: TracerOptions) {
|
|
109
|
+
this.exporter = options.exporter;
|
|
110
|
+
this.clock = options.clock ?? systemClock;
|
|
111
|
+
this.idGenerator = options.idGenerator ?? randomHexId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
startSpan(name: string, options: StartSpanOptions = {}): Span {
|
|
115
|
+
const parent = options.parent;
|
|
116
|
+
const inbound = options.inbound;
|
|
117
|
+
|
|
118
|
+
// The trace this span belongs to, and the span it points back at, in order of
|
|
119
|
+
// precedence: a live parent we own (its trace, its span) > an inbound trace
|
|
120
|
+
// adopted from a `traceparent` (its trace id, the caller's span) > a fresh
|
|
121
|
+
// root (a new trace, no parent).
|
|
122
|
+
const traceId =
|
|
123
|
+
parent !== undefined
|
|
124
|
+
? parent.data.traceId
|
|
125
|
+
: inbound !== undefined
|
|
126
|
+
? inbound.traceId
|
|
127
|
+
: this.idGenerator();
|
|
128
|
+
|
|
129
|
+
const parentSpanId =
|
|
130
|
+
parent !== undefined
|
|
131
|
+
? parent.data.spanId
|
|
132
|
+
: inbound !== undefined
|
|
133
|
+
? inbound.parentId
|
|
134
|
+
: undefined;
|
|
135
|
+
|
|
136
|
+
const data: SpanData = {
|
|
137
|
+
traceId,
|
|
138
|
+
spanId: this.idGenerator(),
|
|
139
|
+
...(parentSpanId === undefined ? {} : { parentSpanId }),
|
|
140
|
+
name,
|
|
141
|
+
startedAt: this.clock(),
|
|
142
|
+
attributes: { ...options.attributes },
|
|
143
|
+
status: "unset",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return new LiveSpan(data, this.clock, this.exporter);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run `fn` inside a span: start it, await the result, end the span, return the
|
|
151
|
+
* value. If `fn` throws, mark the span "error", end it, and rethrow — the
|
|
152
|
+
* caller's failure is recorded but never swallowed.
|
|
153
|
+
*/
|
|
154
|
+
async withSpan<T>(
|
|
155
|
+
name: string,
|
|
156
|
+
fn: (span: Span) => T | Promise<T>,
|
|
157
|
+
options: StartSpanOptions = {},
|
|
158
|
+
): Promise<T> {
|
|
159
|
+
const span = this.startSpan(name, options);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
return await fn(span);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
span.setStatus("error");
|
|
165
|
+
|
|
166
|
+
throw error;
|
|
167
|
+
} finally {
|
|
168
|
+
span.end();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|