@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
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lesto/observability",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Lesto's in-house distributed-tracing core — OpenTelemetry-shaped, with no OpenTelemetry dependency.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"import": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./rum": {
|
|
13
|
+
"types": "./src/rum.ts",
|
|
14
|
+
"import": "./src/rum.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:cov": "vitest run --coverage",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "oxlint src test",
|
|
22
|
+
"format": "oxfmt src test",
|
|
23
|
+
"format:check": "oxfmt --check src test"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/lesto-run/lesto.git",
|
|
34
|
+
"directory": "packages/observability"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://lesto.run",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/lesto-run/lesto/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/exporter.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SpanData, SpanExporter } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The canonical test double: an exporter that simply keeps every span it is
|
|
5
|
+
* handed, in the order they ended. Assert against `spans` after the fact.
|
|
6
|
+
*/
|
|
7
|
+
export class InMemoryExporter implements SpanExporter {
|
|
8
|
+
readonly spans: SpanData[] = [];
|
|
9
|
+
|
|
10
|
+
export(span: SpanData): void {
|
|
11
|
+
this.spans.push(span);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identity, made injectable.
|
|
5
|
+
*
|
|
6
|
+
* The default generator draws 16 random bytes and renders them as hex — wide
|
|
7
|
+
* enough to make collisions a non-event. Tests inject a counting generator
|
|
8
|
+
* instead, so trace and span ids are predictable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const randomHexId = (): string => randomBytes(16).toString("hex");
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lesto/observability — an in-house distributed-tracing core.
|
|
3
|
+
*
|
|
4
|
+
* const exporter = new InMemoryExporter();
|
|
5
|
+
* const tracer = new Tracer({ exporter });
|
|
6
|
+
*
|
|
7
|
+
* const root = tracer.startSpan("handle_request");
|
|
8
|
+
* const child = tracer.startSpan("query_db", { parent: root });
|
|
9
|
+
* child.setAttribute("rows", 12).setStatus("ok").end();
|
|
10
|
+
* root.end();
|
|
11
|
+
*
|
|
12
|
+
* await tracer.withSpan("charge_card", async (span) => {
|
|
13
|
+
* span.setAttribute("amount_cents", 4200);
|
|
14
|
+
* return charge();
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* The shape is OpenTelemetry-flavored, and the OTLP adapter is here: wire an
|
|
18
|
+
* `OtlpHttpExporter` instead of the in-memory one and `flush()` ships every
|
|
19
|
+
* finished span to a real collector over OTLP/HTTP JSON.
|
|
20
|
+
*
|
|
21
|
+
* The WIRING facade (`tracesFromEnv` / `createTraces`) is the env-driven entry
|
|
22
|
+
* point an app constructs once — it reads the two-env-var setup, builds the
|
|
23
|
+
* exporter + tracer, drives the flush lifecycle, and hands back the per-domain
|
|
24
|
+
* seam hooks (`db.onQuery`, `identity.onEvent`, …) every battery terminates in:
|
|
25
|
+
*
|
|
26
|
+
* // The two-env-var setup (LESTO_OTLP_URL is the on switch):
|
|
27
|
+
* // LESTO_OTLP_URL=http://localhost:4318/v1/traces
|
|
28
|
+
* // LESTO_OTLP_SERVICE=my-app (service.name; default "lesto")
|
|
29
|
+
* // LESTO_OTLP_HEADERS=authorization=Bearer t (optional, comma-separated)
|
|
30
|
+
* const traces = tracesFromEnv(process.env, { currentSpan });
|
|
31
|
+
* const db = createDb(sql, { onQuery: traces?.seams.onQuery });
|
|
32
|
+
* const stop = traces?.startInterval(5_000); // flush cadence; stop on drain
|
|
33
|
+
*
|
|
34
|
+
* `traceparent` (parse/format) is the W3C propagation primitive — verbatim, never
|
|
35
|
+
* an invented format — that joins a trace across a process boundary.
|
|
36
|
+
*
|
|
37
|
+
* ── The v1 observability cut: TRACES ONLY (said out loud) ───────────────────
|
|
38
|
+
*
|
|
39
|
+
* This package is distributed TRACING and nothing else. v1 ships NO metrics
|
|
40
|
+
* pipeline (no counters, no latency histograms) and NO logs pipeline (no log
|
|
41
|
+
* aggregation/shipping) from here. That is a deliberate scope line, not an
|
|
42
|
+
* oversight: the operability plan defers metrics and logs post-1.0
|
|
43
|
+
* (`docs/plans/operability-dx.md`), and the launch story is spans + the runtime's
|
|
44
|
+
* structured access log. If you reach for a counter or a histogram here, it does
|
|
45
|
+
* not exist on purpose — the seam to add one is a future increment, not a gap to
|
|
46
|
+
* patch around.
|
|
47
|
+
*
|
|
48
|
+
* ── The `lesto.request_id` → trace join ──────────────────────────────────────
|
|
49
|
+
*
|
|
50
|
+
* Traces and the access log are two records of ONE request, joined by a shared
|
|
51
|
+
* id. The runtime mints a per-request id, puts it on every access-log entry as
|
|
52
|
+
* `requestId`, sets it on the request span as the `lesto.request_id` attribute,
|
|
53
|
+
* and echoes it back on the `X-Request-Id` response header. So a span found in
|
|
54
|
+
* the collector and an access-log line are correlated by an exact-match query on
|
|
55
|
+
* that one value — the trace tells you the shape (parent/child spans, timings),
|
|
56
|
+
* the access log tells you the outcome (method, path, status, ms), and
|
|
57
|
+
* `lesto.request_id` is the key that lines them up. No metrics layer is needed to
|
|
58
|
+
* bridge the two; the id is the join.
|
|
59
|
+
*
|
|
60
|
+
* ── The NIH boundary line: W3C `traceparent`, verbatim ──────────────────────
|
|
61
|
+
*
|
|
62
|
+
* Cross-process propagation is W3C Trace Context `traceparent` EXACTLY (see
|
|
63
|
+
* `traceparent.ts`) — never a Lesto-invented header or format. That is a settled
|
|
64
|
+
* decision: the W3C wire is what every collector, vendor, and sibling service
|
|
65
|
+
* already speaks, so adopting it verbatim is the difference between joining the
|
|
66
|
+
* world's traces and stranding ours. We do not extend it, we do not abbreviate
|
|
67
|
+
* it, and we do not ship an alternative — if a hop needs propagation, it carries
|
|
68
|
+
* `traceparent`.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
export { InMemoryExporter } from "./exporter";
|
|
72
|
+
|
|
73
|
+
export { DEFAULT_MAX_BUFFERED_SPANS, OtlpHttpExporter, otlpTraceRequest } from "./otlp";
|
|
74
|
+
export type { OtlpHttpExporterOptions } from "./otlp";
|
|
75
|
+
|
|
76
|
+
export { randomHexId } from "./ids";
|
|
77
|
+
|
|
78
|
+
export { systemClock } from "./time";
|
|
79
|
+
|
|
80
|
+
export { Tracer } from "./tracer";
|
|
81
|
+
export type { InboundTrace, StartSpanOptions, TracerOptions } from "./tracer";
|
|
82
|
+
|
|
83
|
+
export { formatTraceparent, parseTraceparent, TRACEPARENT_HEADER } from "./traceparent";
|
|
84
|
+
export type { Traceparent } from "./traceparent";
|
|
85
|
+
|
|
86
|
+
export { browserSpanToData, createTraces, parseOtlpHeaders, tracesFromEnv } from "./traces";
|
|
87
|
+
export type { CurrentSpan, Traces, TracesEnv, TracesOptions, TraceSeams } from "./traces";
|
|
88
|
+
|
|
89
|
+
// Browser RUM — the browser half of the UI→API→DB trace (ARCHITECTURE.md §7).
|
|
90
|
+
// `startBrowserRum` observes the page's performance and POSTs spans under the
|
|
91
|
+
// SSR-injected server trace id; `wrapFetch` carries an outbound `traceparent` on
|
|
92
|
+
// same-origin fetches so the server handler joins the same trace.
|
|
93
|
+
export {
|
|
94
|
+
BROWSER_SPANS_PATH,
|
|
95
|
+
BrowserTracer,
|
|
96
|
+
browserRumEnvironment,
|
|
97
|
+
DEFAULT_RUM_SAMPLE_RATE,
|
|
98
|
+
defaultSendBrowserSpans,
|
|
99
|
+
readTraceparentMeta,
|
|
100
|
+
shouldSampleRum,
|
|
101
|
+
startBrowserRum,
|
|
102
|
+
TRACEPARENT_META_NAME,
|
|
103
|
+
wrapFetch,
|
|
104
|
+
} from "./rum";
|
|
105
|
+
export type { BrowserSpan, BrowserSpansPayload, RumEnvironment, RumOptions } from "./rum";
|
|
106
|
+
|
|
107
|
+
export type { Clock, Span, SpanData, SpanExporter, SpanStatus } from "./types";
|
package/src/otlp.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OTLP/HTTP exporter — finished spans, shipped to a real collector.
|
|
3
|
+
*
|
|
4
|
+
* This is the adapter the rest of the package was shaped for: `SpanData`
|
|
5
|
+
* mirrors OpenTelemetry on purpose, so exporting is a mapping, not a redesign.
|
|
6
|
+
* `OtlpHttpExporter` buffers what `Tracer` hands it and `flush()` POSTs the
|
|
7
|
+
* batch as OTLP/HTTP JSON (`/v1/traces`) — the wire format every OTel
|
|
8
|
+
* collector, and the vendors behind one, accept.
|
|
9
|
+
*
|
|
10
|
+
* Telemetry must never take the app down: a failed flush (non-2xx, network
|
|
11
|
+
* throw) is routed to `onError` and the batch is dropped — the deliberate
|
|
12
|
+
* trade for a demo-grade exporter (no retry queue, no backpressure). The
|
|
13
|
+
* mapping itself is exported pure ({@link otlpTraceRequest}) so tests pin the
|
|
14
|
+
* exact bytes a collector would receive.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { SpanData, SpanExporter } from "./types";
|
|
18
|
+
|
|
19
|
+
/** One OTLP attribute value, in the tagged-union shape the protocol wants. */
|
|
20
|
+
interface OtlpValue {
|
|
21
|
+
stringValue?: string;
|
|
22
|
+
boolValue?: boolean;
|
|
23
|
+
intValue?: string;
|
|
24
|
+
doubleValue?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Map a span attribute to OTLP's tagged value union. Unknown shapes stringify. */
|
|
28
|
+
function otlpValue(value: unknown): OtlpValue {
|
|
29
|
+
if (typeof value === "boolean") return { boolValue: value };
|
|
30
|
+
|
|
31
|
+
if (typeof value === "number") {
|
|
32
|
+
// OTLP carries 64-bit ints as strings; anything fractional is a double.
|
|
33
|
+
return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === "string") return { stringValue: value };
|
|
37
|
+
|
|
38
|
+
return { stringValue: String(value) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Epoch milliseconds → the unix-nano string OTLP timestamps are. */
|
|
42
|
+
function unixNano(ms: number): string {
|
|
43
|
+
return `${Math.round(ms)}000000`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** OTLP status codes: 0 unset, 1 ok, 2 error. */
|
|
47
|
+
const STATUS_CODE = { unset: 0, ok: 1, error: 2 } as const;
|
|
48
|
+
|
|
49
|
+
/** OTLP span kind 2 — SERVER, which is what a per-request span is. */
|
|
50
|
+
const SPAN_KIND_SERVER = 2;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the OTLP/HTTP JSON trace-export request body for a batch of spans.
|
|
54
|
+
*
|
|
55
|
+
* One resource (named by `serviceName`), one scope (this package), the spans.
|
|
56
|
+
* Lesto's span ids are 32 hex chars (16 random bytes); OTLP's spanId field is
|
|
57
|
+
* exactly 16 hex chars, so span ids are truncated to spec — still 8 random
|
|
58
|
+
* bytes of identity, while the full-width traceId is carried verbatim.
|
|
59
|
+
*/
|
|
60
|
+
export function otlpTraceRequest(spans: readonly SpanData[], serviceName: string): unknown {
|
|
61
|
+
return {
|
|
62
|
+
resourceSpans: [
|
|
63
|
+
{
|
|
64
|
+
resource: {
|
|
65
|
+
attributes: [{ key: "service.name", value: { stringValue: serviceName } }],
|
|
66
|
+
},
|
|
67
|
+
scopeSpans: [
|
|
68
|
+
{
|
|
69
|
+
scope: { name: "@lesto/observability" },
|
|
70
|
+
spans: spans.map((span) => ({
|
|
71
|
+
traceId: span.traceId,
|
|
72
|
+
spanId: span.spanId.slice(0, 16),
|
|
73
|
+
...(span.parentSpanId === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { parentSpanId: span.parentSpanId.slice(0, 16) }),
|
|
76
|
+
name: span.name,
|
|
77
|
+
kind: SPAN_KIND_SERVER,
|
|
78
|
+
startTimeUnixNano: unixNano(span.startedAt),
|
|
79
|
+
endTimeUnixNano: unixNano(span.endedAt ?? span.startedAt),
|
|
80
|
+
attributes: Object.entries(span.attributes).map(([key, value]) => ({
|
|
81
|
+
key,
|
|
82
|
+
value: otlpValue(value),
|
|
83
|
+
})),
|
|
84
|
+
status: { code: STATUS_CODE[span.status] },
|
|
85
|
+
})),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The default ceiling on the unbounded span buffer.
|
|
95
|
+
*
|
|
96
|
+
* A flush that never ran (the collector is down, no interval was started) used
|
|
97
|
+
* to let the buffer grow without limit — a slow memory leak under load, exactly
|
|
98
|
+
* the failure telemetry must never cause. We cap it at a generous default and
|
|
99
|
+
* drop the OLDEST span when full (the newest signal is the one an operator most
|
|
100
|
+
* wants), tallying every drop so the loss is observable, not silent.
|
|
101
|
+
*/
|
|
102
|
+
export const DEFAULT_MAX_BUFFERED_SPANS = 10_000;
|
|
103
|
+
|
|
104
|
+
/** What the OTLP exporter needs to reach a collector. Everything that varies is injected. */
|
|
105
|
+
export interface OtlpHttpExporterOptions {
|
|
106
|
+
/** The collector's trace endpoint, e.g. `http://localhost:4318/v1/traces`. */
|
|
107
|
+
readonly url: string;
|
|
108
|
+
|
|
109
|
+
/** Extra request headers (an auth token, a tenant id). */
|
|
110
|
+
readonly headers?: Record<string, string>;
|
|
111
|
+
|
|
112
|
+
/** The `service.name` resource attribute. Defaults to `"lesto"`. */
|
|
113
|
+
readonly serviceName?: string;
|
|
114
|
+
|
|
115
|
+
/** The HTTP seam; defaults to the global `fetch`. */
|
|
116
|
+
readonly fetchFn?: typeof fetch;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The most spans the buffer may hold between flushes. When full, the OLDEST is
|
|
120
|
+
* dropped to admit the newest (and counted — see {@link OtlpHttpExporter.dropped}).
|
|
121
|
+
* Defaults to {@link DEFAULT_MAX_BUFFERED_SPANS}.
|
|
122
|
+
*/
|
|
123
|
+
readonly maxBufferedSpans?: number;
|
|
124
|
+
|
|
125
|
+
/** Where a failed flush is reported. Defaults to `console.error`. */
|
|
126
|
+
readonly onError?: (error: unknown) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Buffer finished spans; `flush()` ships the batch to the collector.
|
|
131
|
+
*
|
|
132
|
+
* Batching is the caller's cadence to own — flush at request end (an edge
|
|
133
|
+
* worker's `waitUntil`), on an interval (a node service), or at shutdown. A
|
|
134
|
+
* flush failure reports to `onError` and drops the batch; it never throws.
|
|
135
|
+
*
|
|
136
|
+
* The buffer is BOUNDED ({@link OtlpHttpExporterOptions.maxBufferedSpans}): if
|
|
137
|
+
* spans pile up faster than they flush (a down collector, a missing interval),
|
|
138
|
+
* the oldest are dropped to admit the newest and the loss is counted in
|
|
139
|
+
* {@link dropped} — telemetry sheds load instead of leaking memory.
|
|
140
|
+
*/
|
|
141
|
+
export class OtlpHttpExporter implements SpanExporter {
|
|
142
|
+
private readonly buffer: SpanData[] = [];
|
|
143
|
+
|
|
144
|
+
private readonly url: string;
|
|
145
|
+
|
|
146
|
+
private readonly headers: Record<string, string>;
|
|
147
|
+
|
|
148
|
+
private readonly serviceName: string;
|
|
149
|
+
|
|
150
|
+
private readonly fetchFn: typeof fetch;
|
|
151
|
+
|
|
152
|
+
private readonly maxBufferedSpans: number;
|
|
153
|
+
|
|
154
|
+
private readonly onError: (error: unknown) => void;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How many spans were dropped because the buffer was full when they arrived.
|
|
158
|
+
*
|
|
159
|
+
* A non-zero count means the exporter is shedding telemetry under backpressure
|
|
160
|
+
* — the collector is unreachable, or no flush cadence is draining the buffer.
|
|
161
|
+
* Read it to surface the loss (a periodic log, a metric) so dropped traces are
|
|
162
|
+
* a known fact, not a silent gap.
|
|
163
|
+
*/
|
|
164
|
+
dropped = 0;
|
|
165
|
+
|
|
166
|
+
constructor(options: OtlpHttpExporterOptions) {
|
|
167
|
+
this.url = options.url;
|
|
168
|
+
this.headers = options.headers ?? {};
|
|
169
|
+
this.serviceName = options.serviceName ?? "lesto";
|
|
170
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
171
|
+
this.maxBufferedSpans = options.maxBufferedSpans ?? DEFAULT_MAX_BUFFERED_SPANS;
|
|
172
|
+
this.onError = options.onError ?? ((error) => console.error("[lesto/observability]", error));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export(span: SpanData): void {
|
|
176
|
+
// Drop-oldest at the ceiling: a backed-up buffer (down collector, no flush
|
|
177
|
+
// cadence) sheds its stalest span to admit the freshest, and counts the loss
|
|
178
|
+
// so it is observable rather than an unbounded memory leak.
|
|
179
|
+
if (this.buffer.length >= this.maxBufferedSpans) {
|
|
180
|
+
this.buffer.shift();
|
|
181
|
+
this.dropped += 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.buffer.push(span);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Ship everything buffered so far. An empty buffer is a no-op, not a request. */
|
|
188
|
+
async flush(): Promise<void> {
|
|
189
|
+
if (this.buffer.length === 0) return;
|
|
190
|
+
|
|
191
|
+
const spans = this.buffer.splice(0);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const response = await this.fetchFn(this.url, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json", ...this.headers },
|
|
197
|
+
body: JSON.stringify(otlpTraceRequest(spans, this.serviceName)),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
this.onError(
|
|
202
|
+
new Error(`OTLP export failed: ${response.status} for ${spans.length} span(s)`),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
this.onError(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|