@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 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
+ }
@@ -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
+ }