@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/src/traces.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * The wiring facade — one place an app turns env + seams into live traces.
3
+ *
4
+ * The pieces below it (`Tracer`, `OtlpHttpExporter`, the W3C `traceparent`
5
+ * primitive) are deliberately small and unopinionated. THIS is where they meet
6
+ * the rest of Lesto: it reads the two-env-var setup, builds the exporter and
7
+ * tracer, drives the flush lifecycle (an interval for a long-lived node service,
8
+ * an explicit `flush()` for an edge worker's `waitUntil` or a drain), and turns
9
+ * every per-domain `on*` seam other packages exposed into a child span under the
10
+ * request currently in flight.
11
+ *
12
+ * The contract this file defines is the one `@lesto/cloudflare` (edge-deploy #3)
13
+ * mirrors: the env-var names + semantics, the exporter/tracer construction, and
14
+ * the `flush()` API `waitUntil` calls. The node tier wires it here; the edge
15
+ * adapter wires the SAME shape with its own `waitUntil` arity.
16
+ */
17
+
18
+ import { OtlpHttpExporter } from "./otlp";
19
+ import type { OtlpHttpExporterOptions } from "./otlp";
20
+ import type { BrowserSpan } from "./rum";
21
+ import { Tracer } from "./tracer";
22
+ import type { InboundTrace } from "./tracer";
23
+ import type { Span, SpanData } from "./types";
24
+
25
+ /**
26
+ * The environment a {@link tracesFromEnv} reads — the two-env-var setup, plus a
27
+ * couple of optional knobs. Just the slice of `process.env` we need, so a test
28
+ * passes a literal and a worker passes its `env` binding.
29
+ *
30
+ * THE CONTRACT (mirrored by the edge adapter):
31
+ *
32
+ * - `LESTO_OTLP_URL` — the collector's trace endpoint, e.g.
33
+ * `http://localhost:4318/v1/traces`. ABSENT disables
34
+ * tracing entirely (no exporter, no spans, zero cost) —
35
+ * the safe default, so an app with no collector pays
36
+ * nothing and an operator opts in by setting one var.
37
+ * - `LESTO_OTLP_SERVICE` — the `service.name` resource attribute. Defaults to
38
+ * `"lesto"`.
39
+ * - `LESTO_OTLP_HEADERS` — extra request headers as a comma-separated
40
+ * `key=value` list (an auth token, a tenant id), e.g.
41
+ * `authorization=Bearer t,x-tenant=acme`. Absent = none.
42
+ */
43
+ export interface TracesEnv {
44
+ readonly LESTO_OTLP_URL?: string | undefined;
45
+ readonly LESTO_OTLP_SERVICE?: string | undefined;
46
+ readonly LESTO_OTLP_HEADERS?: string | undefined;
47
+ }
48
+
49
+ /**
50
+ * Parse the `LESTO_OTLP_HEADERS` comma-separated `key=value` list into a header
51
+ * map. A blank entry is skipped; an entry with no `=` is skipped (we never
52
+ * invent a value); whitespace around the key and around the value is trimmed.
53
+ * Pure and exported so the skip/trim branches are unit-testable.
54
+ */
55
+ export function parseOtlpHeaders(raw: string | undefined): Record<string, string> {
56
+ if (raw === undefined) return {};
57
+
58
+ const headers: Record<string, string> = {};
59
+
60
+ for (const entry of raw.split(",")) {
61
+ const eq = entry.indexOf("=");
62
+
63
+ // No `=` (a bare token) carries no value — we never guess one; skip it.
64
+ if (eq === -1) continue;
65
+
66
+ const key = entry.slice(0, eq).trim();
67
+ const value = entry.slice(eq + 1).trim();
68
+
69
+ // A blank key is meaningless as a header name; drop it.
70
+ if (key === "") continue;
71
+
72
+ headers[key] = value;
73
+ }
74
+
75
+ return headers;
76
+ }
77
+
78
+ /**
79
+ * Reads the request span for the request currently in flight, so a seam hook can
80
+ * parent its child span on it.
81
+ *
82
+ * Injected (rather than importing `@lesto/web`'s `currentContext` here) so this
83
+ * tracing core stays dependency-free and the wiring site supplies the binding.
84
+ * Returns `undefined` outside a request (a background job, startup) — the hook
85
+ * then roots a standalone span instead of crashing.
86
+ */
87
+ export type CurrentSpan = () => Span | undefined;
88
+
89
+ /** What {@link createTraces} needs: a tracer, a flushable exporter, the current-span seam. */
90
+ export interface TracesOptions {
91
+ readonly tracer: Tracer;
92
+
93
+ /** Flushed on the interval, on drain, and on an edge worker's `waitUntil`. */
94
+ readonly flush: () => Promise<void>;
95
+
96
+ /**
97
+ * Reads the in-flight request span so seam hooks parent on it. Absent → seam
98
+ * spans root their own trace (still exported, just unparented). The runtime
99
+ * wires this to `currentContext()?.span`.
100
+ */
101
+ readonly currentSpan?: CurrentSpan;
102
+
103
+ /**
104
+ * Writes a pre-built {@link SpanData} straight to the exporter, bypassing the
105
+ * tracer's id minting.
106
+ *
107
+ * The `onBrowserSpan` seam needs this: a browser span arrives with its OWN ids
108
+ * (the page adopted the server trace id, the browser minted its span id) and its
109
+ * OWN epoch-ms timestamps. Re-minting them through `tracer.startSpan` would
110
+ * discard the join. So the wiring hands the raw `SpanData` to the exporter
111
+ * directly — the same exporter the tracer feeds, so browser and server spans
112
+ * share one collector and one trace id. Absent → `onBrowserSpan` drops the span
113
+ * (no exporter to write to), the honest no-op when tracing was wired without it.
114
+ */
115
+ readonly exportSpan?: (span: SpanData) => void;
116
+ }
117
+
118
+ /**
119
+ * The narrow tracer the runtime mints request spans through (its `RequestTracer`,
120
+ * structurally): `startSpan(name, inbound?)` adopts an inbound `traceparent` join
121
+ * and returns a span whose `data` carries the ids a child span and the outbound
122
+ * `traceparent` read.
123
+ */
124
+ export interface RequestTracer {
125
+ startSpan(name: string, inbound?: InboundTrace): Span;
126
+ }
127
+
128
+ /**
129
+ * The live tracing handle an app holds: the seam hooks to pass each battery, the
130
+ * tracer the runtime mints request spans through, and the flush lifecycle.
131
+ *
132
+ * `seams` is the whole point — every `on*` hook another plan built terminates in
133
+ * one of these, becoming a child span (or, for the fire-and-forget signals, a
134
+ * standalone span) so a query/job/event/delivery shows up under the request that
135
+ * caused it.
136
+ */
137
+ export interface Traces {
138
+ /** The tracer the runtime mints one `http.request` span per request through. */
139
+ readonly tracer: Tracer;
140
+
141
+ /**
142
+ * The runtime-shaped tracer: `startSpan(name, inbound?)` joins an inbound
143
+ * `traceparent` and returns the request span. Hand this to `serve({ tracer })`
144
+ * so a request span continues the caller's trace and is published on the
145
+ * request context for child spans.
146
+ */
147
+ readonly requestTracer: RequestTracer;
148
+
149
+ /** The seam hooks to hand each battery (`db.onQuery`, `identity.onEvent`, …). */
150
+ readonly seams: TraceSeams;
151
+
152
+ /**
153
+ * Ship every buffered span to the collector. Idempotent and safe to call
154
+ * often — an empty buffer is a no-op. This is the API an edge worker's
155
+ * `waitUntil(traces.flush())` and a node drain both call; it never throws.
156
+ */
157
+ flush(): Promise<void>;
158
+
159
+ /**
160
+ * Start flushing on an interval (a long-lived node service's cadence). Returns
161
+ * a stop handle; call it on drain (after a final {@link flush}). A worker has
162
+ * no steady process to run an interval on, so it skips this and flushes per
163
+ * request instead.
164
+ */
165
+ startInterval(everyMs: number): () => void;
166
+ }
167
+
168
+ /**
169
+ * The per-domain seam hooks, each shaped to slot straight into the battery that
170
+ * raises it (see the Phase-1 signatures). Every one turns its event into a span:
171
+ * a child of the in-flight request span when there is one, a root span when
172
+ * there is not (a background worker, a startup task).
173
+ */
174
+ export interface TraceSeams {
175
+ /** `@lesto/db`'s `onQuery` — each executed query becomes a `db.query` child span. */
176
+ onQuery(event: { readonly sql: string; readonly durationMs: number }): void;
177
+
178
+ /** `@lesto/queue`'s `onJob` — each finished job becomes a `queue.job` span. */
179
+ onJob(event: {
180
+ readonly queue: string;
181
+ readonly id: number;
182
+ readonly name: string;
183
+ readonly outcome: "done" | "retry" | "failed";
184
+ readonly attempt: number;
185
+ readonly durationMs: number;
186
+ }): void;
187
+
188
+ /** `@lesto/identity`'s `onEvent` — each lifecycle event becomes an `identity.<type>` span. */
189
+ onEvent(event: { readonly type: string; readonly userId?: string; readonly at: number }): void;
190
+
191
+ /** `@lesto/mail`'s `onDelivered` — a `mail.delivered` span per accepted email. */
192
+ onDelivered(event: {
193
+ readonly mailerName: string;
194
+ readonly jobId: number;
195
+ readonly attempt: number;
196
+ }): void;
197
+
198
+ /** `@lesto/mail`'s `onFailed` — a `mail.failed` (error-status) span per failed attempt. */
199
+ onFailed(event: {
200
+ readonly mailerName: string;
201
+ readonly jobId: number;
202
+ readonly attempt: number;
203
+ readonly code: string;
204
+ }): void;
205
+
206
+ /** The `runWorker` `onError` sink — a `worker.poll_failed` error span per poll fault. */
207
+ onWorkerError(error: { readonly code: string; readonly message: string }): void;
208
+
209
+ /** `@lesto/web`'s `clientErrors` sink — a `client.island_error` span per browser beacon. */
210
+ onClientError(event: {
211
+ readonly failed: readonly string[];
212
+ readonly missing: readonly string[];
213
+ readonly failedCount: number;
214
+ readonly missingCount: number;
215
+ }): void;
216
+
217
+ /**
218
+ * `@lesto/web`'s browser-spans receiver sink — one EXPORTED span per browser RUM
219
+ * span, joined to the server trace.
220
+ *
221
+ * Unlike every other seam (each turns a server-side EVENT into a freshly-minted
222
+ * child span), this hands through a span the BROWSER already authored: its ids
223
+ * (the adopted server trace id + the browser's span id), its epoch-ms
224
+ * timestamps, and a PII-free attribute bag. The wiring writes it straight to the
225
+ * exporter via {@link TracesOptions.exportSpan}, so a navigation/resource/vital
226
+ * span lands in the SAME collector as the server `http.request` span, under one
227
+ * trace id — the UI→API→DB join ARCHITECTURE.md §7 promises. With no
228
+ * `exportSpan` wired, this is a no-op (nothing to export to).
229
+ */
230
+ onBrowserSpan(span: BrowserSpan): void;
231
+ }
232
+
233
+ /** OTLP status codes (0 unset, 1 ok, 2 error), the inverse of the exporter's map. */
234
+ const SPAN_STATUS_FOR_CODE = { 0: "unset", 1: "ok", 2: "error" } as const;
235
+
236
+ /**
237
+ * Map a browser-authored {@link BrowserSpan} onto the internal {@link SpanData}.
238
+ *
239
+ * The browser already chose the ids (the adopted server trace id + its own span
240
+ * id) and the epoch-ms timestamps, so this preserves them verbatim — that
241
+ * preservation IS the cross-tier join. Only the status shape differs: the browser
242
+ * speaks the OTLP code (0/1/2), the internal record speaks the named status, so we
243
+ * translate. The attribute bag is copied into a fresh mutable record, since
244
+ * `SpanData.attributes` is mutable by contract.
245
+ */
246
+ export function browserSpanToData(span: BrowserSpan): SpanData {
247
+ return {
248
+ traceId: span.traceId,
249
+ spanId: span.spanId,
250
+ ...(span.parentSpanId === undefined ? {} : { parentSpanId: span.parentSpanId }),
251
+ name: span.name,
252
+ startedAt: span.startedAt,
253
+ endedAt: span.endedAt,
254
+ attributes: { ...span.attributes },
255
+ status: SPAN_STATUS_FOR_CODE[span.status],
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Build the live tracing handle from a tracer, a flush, and the current-span seam.
261
+ *
262
+ * The seam hooks all funnel through one helper: open a span (parented on the
263
+ * in-flight request span when one exists), stamp the event's attributes, set its
264
+ * status, and end it immediately — a hook receives a finished event, so the span
265
+ * is a point-in-time record, not a window we hold open.
266
+ */
267
+ export function createTraces(options: TracesOptions): Traces {
268
+ const { tracer } = options;
269
+
270
+ const parentOf = (): Span | undefined => options.currentSpan?.();
271
+
272
+ /** Record a finished event as a child span: open, stamp, status, end. */
273
+ const record = (
274
+ name: string,
275
+ attributes: Record<string, unknown>,
276
+ status: "ok" | "error",
277
+ ): void => {
278
+ const parent = parentOf();
279
+
280
+ const span = tracer.startSpan(
281
+ name,
282
+ parent === undefined ? { attributes } : { parent, attributes },
283
+ );
284
+
285
+ span.setStatus(status);
286
+ span.end();
287
+ };
288
+
289
+ const seams: TraceSeams = {
290
+ onQuery: (event) =>
291
+ record("db.query", { "db.statement": event.sql, "db.duration_ms": event.durationMs }, "ok"),
292
+
293
+ onJob: (event) =>
294
+ record(
295
+ "queue.job",
296
+ {
297
+ "queue.name": event.queue,
298
+ "queue.job_id": event.id,
299
+ "queue.job_name": event.name,
300
+ "queue.outcome": event.outcome,
301
+ "queue.attempt": event.attempt,
302
+ "queue.duration_ms": event.durationMs,
303
+ },
304
+ event.outcome === "failed" ? "error" : "ok",
305
+ ),
306
+
307
+ onEvent: (event) =>
308
+ record(
309
+ `identity.${event.type}`,
310
+ {
311
+ "identity.event": event.type,
312
+ "identity.at": event.at,
313
+ ...(event.userId === undefined ? {} : { "identity.user_id": event.userId }),
314
+ },
315
+ "ok",
316
+ ),
317
+
318
+ onDelivered: (event) =>
319
+ record(
320
+ "mail.delivered",
321
+ {
322
+ "mail.mailer": event.mailerName,
323
+ "mail.job_id": event.jobId,
324
+ "mail.attempt": event.attempt,
325
+ },
326
+ "ok",
327
+ ),
328
+
329
+ onFailed: (event) =>
330
+ record(
331
+ "mail.failed",
332
+ {
333
+ "mail.mailer": event.mailerName,
334
+ "mail.job_id": event.jobId,
335
+ "mail.attempt": event.attempt,
336
+ "mail.code": event.code,
337
+ },
338
+ "error",
339
+ ),
340
+
341
+ onWorkerError: (error) =>
342
+ record(
343
+ "worker.poll_failed",
344
+ { "error.code": error.code, "error.message": error.message },
345
+ "error",
346
+ ),
347
+
348
+ onClientError: (event) =>
349
+ record(
350
+ "client.island_error",
351
+ {
352
+ "client.failed": event.failed.join(","),
353
+ "client.missing": event.missing.join(","),
354
+ "client.failed_count": event.failedCount,
355
+ "client.missing_count": event.missingCount,
356
+ },
357
+ "error",
358
+ ),
359
+
360
+ // A browser span keeps its OWN ids and timestamps (the join), so it is written
361
+ // straight to the exporter rather than re-minted through the tracer. With no
362
+ // `exportSpan` wired this is the honest no-op — there is nowhere to export to.
363
+ onBrowserSpan: (span) => {
364
+ if (options.exportSpan === undefined) return;
365
+
366
+ options.exportSpan(browserSpanToData(span));
367
+ },
368
+ };
369
+
370
+ const requestTracer: RequestTracer = {
371
+ // The runtime's `RequestTracer.startSpan(name, inbound?)`: an inbound trace
372
+ // (parsed from `traceparent`) continues the caller's trace; absent, it roots
373
+ // a fresh one. The returned `Span.data` is what the request context publishes
374
+ // for child spans and the outbound `traceparent` reads.
375
+ startSpan: (name, inbound) => tracer.startSpan(name, inbound === undefined ? {} : { inbound }),
376
+ };
377
+
378
+ return {
379
+ tracer,
380
+ requestTracer,
381
+ seams,
382
+ flush: options.flush,
383
+ startInterval: (everyMs) => {
384
+ const timer = setInterval(() => void options.flush(), everyMs);
385
+
386
+ // A flush interval must never keep the process alive on its own — the app's
387
+ // own open socket does that. `unref` is node-only; a runtime without it
388
+ // (a worker, where this path is unused) simply skips the call.
389
+ timer.unref?.();
390
+
391
+ return () => clearInterval(timer);
392
+ },
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Build a {@link Traces} from the environment, or `undefined` when tracing is off.
398
+ *
399
+ * `LESTO_OTLP_URL` is the on switch: absent, we return `undefined` and the app
400
+ * runs with NO tracer (zero spans, zero overhead). Present, we construct an
401
+ * `OtlpHttpExporter` over the parsed headers + service name, a `Tracer` over it,
402
+ * and the live handle. `currentSpan` and `fetchFn` are injected so the runtime
403
+ * wires the request-span seam and a worker passes its own `fetch`.
404
+ *
405
+ * This is the construction the CLI calls for `lesto serve`/`dev`; the edge adapter
406
+ * mirrors it with the worker `env` and `ctx.waitUntil(traces.flush())`.
407
+ */
408
+ export function tracesFromEnv(
409
+ env: TracesEnv,
410
+ options: {
411
+ readonly currentSpan?: CurrentSpan;
412
+ readonly fetchFn?: typeof fetch;
413
+ readonly onError?: (error: unknown) => void;
414
+ } = {},
415
+ ): Traces | undefined {
416
+ const url = env.LESTO_OTLP_URL;
417
+
418
+ // The on switch: no collector configured means no tracing at all.
419
+ if (url === undefined || url === "") return undefined;
420
+
421
+ const exporterOptions: OtlpHttpExporterOptions = {
422
+ url,
423
+ headers: parseOtlpHeaders(env.LESTO_OTLP_HEADERS),
424
+ serviceName: env.LESTO_OTLP_SERVICE ?? "lesto",
425
+ ...(options.fetchFn === undefined ? {} : { fetchFn: options.fetchFn }),
426
+ ...(options.onError === undefined ? {} : { onError: options.onError }),
427
+ };
428
+
429
+ const exporter = new OtlpHttpExporter(exporterOptions);
430
+
431
+ const tracer = new Tracer({ exporter });
432
+
433
+ return createTraces({
434
+ tracer,
435
+ flush: () => exporter.flush(),
436
+ // The browser-spans seam writes pre-built spans straight to this exporter, so
437
+ // a RUM span shares the collector + trace id with the server spans the tracer
438
+ // feeds. Wired here so the env-driven app gets the join for free.
439
+ exportSpan: (span) => exporter.export(span),
440
+ ...(options.currentSpan === undefined ? {} : { currentSpan: options.currentSpan }),
441
+ });
442
+ }
package/src/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The vocabulary of a trace.
3
+ *
4
+ * The shape mirrors OpenTelemetry so an OTel exporter can be a thin future
5
+ * adapter — but we take no dependency on OTel here. Time and identity are
6
+ * injected (a `Clock`, an id-generator) so every span is deterministic in tests.
7
+ */
8
+
9
+ /** Wall-clock time, made injectable. Returns epoch milliseconds. */
10
+ export type Clock = () => number;
11
+
12
+ /** A span's lifecycle verdict: unset until decided, then ok or error. */
13
+ export type SpanStatus = "unset" | "ok" | "error";
14
+
15
+ /** The flat, exportable record behind a live span. */
16
+ export interface SpanData {
17
+ readonly traceId: string;
18
+ readonly spanId: string;
19
+
20
+ /** Absent on a root span; the parent's spanId on a child. */
21
+ parentSpanId?: string;
22
+
23
+ readonly name: string;
24
+
25
+ readonly startedAt: number;
26
+ endedAt?: number;
27
+
28
+ attributes: Record<string, unknown>;
29
+ status: SpanStatus;
30
+ }
31
+
32
+ /** A live span: a fluent handle over its `data`, ended exactly once. */
33
+ export interface Span {
34
+ setAttribute(key: string, value: unknown): this;
35
+ setStatus(status: SpanStatus): this;
36
+ end(): void;
37
+
38
+ readonly data: SpanData;
39
+ }
40
+
41
+ /** A sink for finished spans. The InMemoryExporter is the canonical test double. */
42
+ export interface SpanExporter {
43
+ export(span: SpanData): void;
44
+ }