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