@ambarltd/core 0.1.17

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.
Files changed (46) hide show
  1. package/README.md +10 -0
  2. package/dist/callable.d.ts +1 -0
  3. package/dist/callable.js +6 -0
  4. package/dist/future.d.ts +102 -0
  5. package/dist/future.js +164 -0
  6. package/dist/helpers/object.d.ts +9 -0
  7. package/dist/helpers/object.js +31 -0
  8. package/dist/json/decoder.d.ts +99 -0
  9. package/dist/json/decoder.js +213 -0
  10. package/dist/json/encoder.d.ts +50 -0
  11. package/dist/json/encoder.js +115 -0
  12. package/dist/json/schema.d.ts +78 -0
  13. package/dist/json/schema.js +168 -0
  14. package/dist/json/types.d.ts +6 -0
  15. package/dist/json/types.js +1 -0
  16. package/dist/list.d.ts +35 -0
  17. package/dist/list.js +130 -0
  18. package/dist/maybe.d.ts +104 -0
  19. package/dist/maybe.js +106 -0
  20. package/dist/remote-data.d.ts +117 -0
  21. package/dist/remote-data.js +148 -0
  22. package/dist/result.d.ts +75 -0
  23. package/dist/result.js +126 -0
  24. package/dist/router.d.ts +117 -0
  25. package/dist/router.js +106 -0
  26. package/dist/test.d.ts +63 -0
  27. package/dist/test.js +470 -0
  28. package/dist/time.d.ts +118 -0
  29. package/dist/time.js +387 -0
  30. package/dist/tracing/opentelemetry.d.ts +27 -0
  31. package/dist/tracing/opentelemetry.js +215 -0
  32. package/dist/tracing/proxy.d.ts +20 -0
  33. package/dist/tracing/proxy.js +103 -0
  34. package/dist/tracing/simple.d.ts +10 -0
  35. package/dist/tracing/simple.js +224 -0
  36. package/dist/tracing.d.ts +29 -0
  37. package/dist/tracing.js +55 -0
  38. package/dist/trampoline.d.ts +24 -0
  39. package/dist/trampoline.js +46 -0
  40. package/dist/tree-map.d.ts +73 -0
  41. package/dist/tree-map.js +169 -0
  42. package/dist/tree-set.d.ts +63 -0
  43. package/dist/tree-set.js +114 -0
  44. package/dist/types.d.ts +21 -0
  45. package/dist/types.js +1 -0
  46. package/package.json +49 -0
package/dist/time.js ADDED
@@ -0,0 +1,387 @@
1
+ import * as s from "./json/schema";
2
+ import { fail, always } from "./json/decoder";
3
+ import { Just, Nothing } from "./maybe";
4
+ import { DateTime } from "luxon";
5
+ /** Date + time stored as milliseconds passed since 00:00:00 UTC on January 1, 1970. */
6
+ class POSIX {
7
+ value;
8
+ static fromDate(d) {
9
+ return new POSIX(d.valueOf());
10
+ }
11
+ static fromDuration(d) {
12
+ return new POSIX(d.asMilliseconds());
13
+ }
14
+ static now() {
15
+ return new POSIX(Date.now());
16
+ }
17
+ /** Takes number of milliseconds since epoch. */
18
+ constructor(value) {
19
+ this.value = value;
20
+ }
21
+ /** Time since Unix epoch (00:00:00 UTC on January 1, 1970). */
22
+ sinceEpoch() {
23
+ return Duration.milliseconds(this.value);
24
+ }
25
+ toDate() {
26
+ return new Date(this.value);
27
+ }
28
+ isAfter(other) {
29
+ return this.value > other.value;
30
+ }
31
+ greaterThan(other) {
32
+ return this.value > other.value;
33
+ }
34
+ compare(other) {
35
+ return (this.value > other.value ? 1
36
+ : this.value < other.value ? -1
37
+ : 0);
38
+ }
39
+ addDuration(d) {
40
+ return new POSIX(this.value + d.asMilliseconds());
41
+ }
42
+ subtractDuration(d) {
43
+ return new POSIX(this.value - d.asMilliseconds());
44
+ }
45
+ difference(other) {
46
+ return Duration.milliseconds(this.value - other.value);
47
+ }
48
+ static fromLocalDateAndTime(date, time, timezone) {
49
+ const s = `${date.pretty()}T${time.pretty()}`;
50
+ const luxonDate = DateTime.fromISO(s, { zone: timezone });
51
+ return new POSIX(luxonDate.toMillis());
52
+ }
53
+ toUTCDateAndTime() {
54
+ const dt = DateTime.fromMillis(this.value, { zone: "UTC" });
55
+ const date = new DateOnly(dt.year, dt.month, dt.day);
56
+ const time = TimeOfDay.fromParts({
57
+ hours: dt.hour,
58
+ minutes: dt.minute,
59
+ seconds: dt.second,
60
+ });
61
+ return { date, time };
62
+ }
63
+ toLocalDateAndTime(timezone) {
64
+ const dt = DateTime.fromMillis(this.value, { zone: "UTC" }).setZone(timezone);
65
+ const date = new DateOnly(dt.year, dt.month, dt.day);
66
+ const time = TimeOfDay.fromParts({
67
+ hours: dt.hour,
68
+ minutes: dt.minute,
69
+ seconds: dt.second,
70
+ });
71
+ return { date, time };
72
+ }
73
+ /** Parse PostgreSQL TIMESTAMPTZ string (e.g., "2026-03-17 10:30:00+00"). */
74
+ static fromSQLTimestamp(str) {
75
+ const date = DateTime.fromSQL(str, { zone: "UTC" });
76
+ return date.isValid ? new POSIX(date.toMillis()) : null;
77
+ }
78
+ /** Convert to PostgreSQL TIMESTAMPTZ string. */
79
+ toSQLTimestamp() {
80
+ return DateTime.fromMillis(this.value, { zone: "UTC" }).toSQL();
81
+ }
82
+ static schema = s.number.dimap(n => new POSIX(n), p => p.value);
83
+ }
84
+ const padded = (v) => v.toString().padStart(2, "0");
85
+ class DateOnly {
86
+ year;
87
+ month; // 1-12
88
+ day; // 1-30ish
89
+ constructor(year, month, day) {
90
+ this.year = year;
91
+ this.month = month;
92
+ this.day = day;
93
+ }
94
+ static todayUTC() {
95
+ const utcTime = POSIX.now();
96
+ return utcTime.toUTCDateAndTime().date;
97
+ }
98
+ static todayLocal(timezone) {
99
+ const utcTime = POSIX.now();
100
+ return utcTime.toLocalDateAndTime(timezone).date;
101
+ }
102
+ static fromDate(date) {
103
+ return new DateOnly(date.getFullYear(), date.getMonth() + 1, date.getDate());
104
+ }
105
+ pretty() {
106
+ return `${this.year}-${padded(this.month)}-${padded(this.day)}`;
107
+ }
108
+ static schema = s.string.chain(str => {
109
+ const parts = str.split("-");
110
+ if (parts.length !== 3) {
111
+ return fail("Invalid Date");
112
+ }
113
+ const year = parseInt(parts[0], 10);
114
+ const month = parseInt(parts[1], 10);
115
+ const day = parseInt(parts[2], 10);
116
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
117
+ return fail("Invalid Date");
118
+ }
119
+ return always(new DateOnly(year, month, day));
120
+ }, date => date.pretty());
121
+ greaterThan(other) {
122
+ return this.compare(other) === 1;
123
+ }
124
+ compare(other) {
125
+ return (this.year > other.year ? 1
126
+ : this.year < other.year ? -1
127
+ : this.month > other.month ? 1
128
+ : this.month < other.month ? -1
129
+ : this.day > other.day ? 1
130
+ : this.day < other.day ? -1
131
+ : 0);
132
+ }
133
+ addMonths(months) {
134
+ const luxonDate = DateTime.fromObject({
135
+ year: this.year,
136
+ month: this.month,
137
+ day: this.day,
138
+ });
139
+ const newLuxonDate = luxonDate.plus({ months });
140
+ return new DateOnly(newLuxonDate.year, newLuxonDate.month, newLuxonDate.day);
141
+ }
142
+ }
143
+ class TimeOfDay {
144
+ seconds;
145
+ constructor(seconds) {
146
+ this.seconds = seconds;
147
+ }
148
+ static fromParts({ hours, minutes, seconds }) {
149
+ return new TimeOfDay(hours * 60 * 60 + minutes * 60 + seconds);
150
+ }
151
+ pretty() {
152
+ const hours = padded(Math.floor(this.seconds / (60 * 60)));
153
+ const minutes = padded(Math.floor(this.seconds / 60) % 60);
154
+ const wholeSeconds = padded(Math.floor(this.seconds) % 60);
155
+ return `${hours}:${minutes}:${wholeSeconds}`;
156
+ }
157
+ getSubSecondPrecision() {
158
+ return this.seconds - Math.floor(this.seconds);
159
+ }
160
+ }
161
+ /**
162
+ * Configuration strings for duration formatting.
163
+ */
164
+ const DURATION_FORMAT_CONFIG = {
165
+ long: {
166
+ suffixes: {
167
+ weeks: { singular: " week", plural: " weeks" },
168
+ days: { singular: " day", plural: " days" },
169
+ hours: { singular: " hour", plural: " hours" },
170
+ minutes: { singular: " minute", plural: " minutes" },
171
+ seconds: { singular: " second", plural: " seconds" },
172
+ milliseconds: { singular: " millisecond", plural: " milliseconds" },
173
+ },
174
+ less_than: "less than ",
175
+ separator: ", ",
176
+ },
177
+ short: {
178
+ suffixes: {
179
+ weeks: { singular: "w", plural: "w" },
180
+ days: { singular: "d", plural: "d" },
181
+ hours: { singular: "h", plural: "h" },
182
+ minutes: { singular: "m", plural: "m" },
183
+ seconds: { singular: "s", plural: "s" },
184
+ milliseconds: { singular: "ms", plural: "ms" },
185
+ },
186
+ less_than: "< ",
187
+ separator: " ",
188
+ },
189
+ };
190
+ /** A length of time. */
191
+ class Duration {
192
+ millis;
193
+ constructor(millis) {
194
+ this.millis = millis;
195
+ }
196
+ static milliseconds(n) {
197
+ return new Duration(n);
198
+ }
199
+ static seconds(n) {
200
+ return Duration.milliseconds(n * 1_000);
201
+ }
202
+ static minutes(n) {
203
+ return Duration.seconds(n * 60);
204
+ }
205
+ static hours(n) {
206
+ return Duration.minutes(n * 60);
207
+ }
208
+ static days(n) {
209
+ return Duration.hours(n * 24);
210
+ }
211
+ static weeks(n) {
212
+ return Duration.days(n * 7);
213
+ }
214
+ asMilliseconds() {
215
+ return this.millis;
216
+ }
217
+ asSeconds() {
218
+ return this.millis / Duration.seconds(1).millis;
219
+ }
220
+ asMinutes() {
221
+ return this.millis / Duration.minutes(1).millis;
222
+ }
223
+ asHours() {
224
+ return this.millis / Duration.hours(1).millis;
225
+ }
226
+ asDays() {
227
+ return this.millis / Duration.days(1).millis;
228
+ }
229
+ asWeeks() {
230
+ return this.millis / Duration.weeks(1).millis;
231
+ }
232
+ add(other) {
233
+ return new Duration(this.millis + other.millis);
234
+ }
235
+ subtract(other) {
236
+ return new Duration(this.millis - other.millis);
237
+ }
238
+ multiplyBy(n) {
239
+ return new Duration(n * this.millis);
240
+ }
241
+ divideBy(n) {
242
+ return new Duration(this.millis / n);
243
+ }
244
+ greaterThan(other) {
245
+ return this.millis > other.millis;
246
+ }
247
+ compare(other) {
248
+ return (this.millis > other.millis ? 1
249
+ : this.millis < other.millis ? -1
250
+ : 0);
251
+ }
252
+ /** Quantisation. Divide a duration into buckets of a fixed length. */
253
+ bucketsOf(length) {
254
+ const sign = Math.sign(this.millis);
255
+ const abs = this.absolute();
256
+ const count = Math.floor(abs.millis / length.millis);
257
+ const remainderStart = length.multiplyBy(count).multiplyBy(sign);
258
+ const remainder = Duration.milliseconds(abs.millis % length.millis).multiplyBy(sign);
259
+ return {
260
+ count: count * sign,
261
+ remainderStart,
262
+ remainder,
263
+ };
264
+ }
265
+ /** Returns a non-negative duration. */
266
+ absolute() {
267
+ return new Duration(Math.abs(this.millis));
268
+ }
269
+ parts() {
270
+ const d = new Duration(Math.abs(this.millis));
271
+ return {
272
+ days: Math.floor(d.asDays()),
273
+ hours: Math.floor(d.asHours()) % 24,
274
+ minutes: Math.floor(d.asMinutes()) % 60,
275
+ seconds: Math.floor(d.asSeconds()) % 60,
276
+ milliseconds: Math.floor(d.asMilliseconds() % 1_000),
277
+ };
278
+ }
279
+ /** Formats duration as ISO-8601 string (e.g., "P1DT2H30M45.123S"). */
280
+ toISO8601() {
281
+ const { days, hours, minutes } = this.parts();
282
+ const seconds = Math.abs(this.asSeconds()) % 60;
283
+ const formatSeconds = (s) => (s % 1 === 0 ? s.toString() : s.toFixed(3).replace(/\.?0+$/, ""));
284
+ const prefix = this.millis < 0 ? "-P" : "P";
285
+ const dayPart = days > 0 ? `${days}D` : "";
286
+ const hasTimePart = hours > 0 || minutes > 0 || seconds > 0 || days === 0;
287
+ const timeParts = [
288
+ hours > 0 ? `${hours}H` : "",
289
+ minutes > 0 ? `${minutes}M` : "",
290
+ seconds > 0 || (days === 0 && hours === 0 && minutes === 0) ? `${formatSeconds(seconds)}S` : "",
291
+ ].join("");
292
+ return prefix + dayPart + (hasTimePart ? "T" + timeParts : "");
293
+ }
294
+ /**
295
+ * Formats a duration to a friendly, human readable string. First argument (`verbosity`) can be
296
+ * either "short" (e.g. 1w 3d 5h 2m 0s 20ms) or long (e.g. 1 week, 3 days, 5 hours, 2 minutes, 20 milliseconds).
297
+ *
298
+ * Options allow for truncation after minutes or seconds. Additionally, if the truncated duration would
299
+ * evaluate to zero but not _exactly_ zero (e.g. a duration of 20 seconds when truncated to minutes will
300
+ * result in 0m), there is an option `show_less_than_when_close_to_zero` which will prefix either "<" (short)
301
+ * or "less than" long in this case, which is recommended when users are actively "watching the clock".
302
+ */
303
+ toFormatted(verbosity, options = {}) {
304
+ if (this.millis < 0) {
305
+ return this.absolute().toFormatted(verbosity, options);
306
+ }
307
+ const { days: _days, hours, minutes, seconds, milliseconds } = this.parts();
308
+ const weeks = Math.floor(this.asWeeks());
309
+ const days = _days % 7;
310
+ const { suffixes, less_than, separator } = DURATION_FORMAT_CONFIG[verbosity];
311
+ // Zero
312
+ if (this.millis === 0) {
313
+ return "0" + suffixes[options.truncateAfter ?? "milliseconds"].plural;
314
+ }
315
+ // Close to zero
316
+ if (options.onTruncation === "show_less_than_when_close_to_zero") {
317
+ // Less than one minute
318
+ if (options.truncateAfter === "minutes" && this.asMinutes() < 1) {
319
+ return `${less_than}1${suffixes["minutes"].singular}`;
320
+ }
321
+ // Less than one second
322
+ if (options.truncateAfter === "seconds" && this.asSeconds() < 1) {
323
+ return `${less_than}1${suffixes["seconds"].singular}`;
324
+ }
325
+ // Less than one millisecond
326
+ if (this.asMilliseconds() < 1) {
327
+ return `${less_than}1${suffixes["milliseconds"].singular}`;
328
+ }
329
+ }
330
+ const parts = [
331
+ [weeks, "weeks"],
332
+ [days, "days"],
333
+ [hours, "hours"],
334
+ [minutes, "minutes"],
335
+ [seconds, "seconds"],
336
+ [milliseconds, "milliseconds"],
337
+ ].slice(0, options.truncateAfter === "minutes" ? 4
338
+ : options.truncateAfter === "seconds" ? 5
339
+ : 6);
340
+ while (parts[0]?.[0] === 0)
341
+ parts.shift(); // remove leading zeroes
342
+ while (parts[parts.length - 1]?.[0] === 0)
343
+ parts.pop(); // remove trailing zeroes
344
+ const pluralised = (amount, forms) => amount.toString() + (amount == 1 ? forms.singular : forms.plural);
345
+ return parts.map(([amount, unit]) => pluralised(amount, suffixes[unit])).join(separator);
346
+ }
347
+ /** Parses ISO-8601 duration string (e.g., "P1DT2H30M45.123S"). */
348
+ static fromISO8601(str) {
349
+ // Supports: P[nD]T[nH][nM][nS] with optional decimals on any component
350
+ const regex = /^(-)?P(?:(\d+(?:\.\d+)?)D)?(T)?(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/;
351
+ const match = str.match(regex);
352
+ if (!match) {
353
+ return Nothing();
354
+ }
355
+ const hasDays = !!match[2];
356
+ const hasT = !!match[3];
357
+ const hasHours = !!match[4];
358
+ const hasMinutes = !!match[5];
359
+ const hasSeconds = !!match[6];
360
+ const hasTimeComponents = hasHours || hasMinutes || hasSeconds;
361
+ // Must have at least one component
362
+ if (!hasDays && !hasTimeComponents) {
363
+ return Nothing();
364
+ }
365
+ // If T is present, must have at least one time component
366
+ if (hasT && !hasTimeComponents) {
367
+ return Nothing();
368
+ }
369
+ const negative = match[1] === "-";
370
+ const days = match[2] ? parseFloat(match[2]) : 0;
371
+ const hours = match[4] ? parseFloat(match[4]) : 0;
372
+ const minutes = match[5] ? parseFloat(match[5]) : 0;
373
+ const seconds = match[6] ? parseFloat(match[6]) : 0;
374
+ // Chained factory methods (seconds → milliseconds) accumulate
375
+ // IEEE-754 floating point errors (~10⁻¹⁵). Rounding to milliseconds is safe
376
+ // as it's our internal precision and the error is far below this threshold.
377
+ const total = Duration.days(days)
378
+ .add(Duration.hours(hours))
379
+ .add(Duration.minutes(minutes))
380
+ .add(Duration.seconds(seconds));
381
+ const millis = Math.round(total.asMilliseconds());
382
+ return Just(Duration.milliseconds(negative ? -millis : millis));
383
+ }
384
+ /** Default: ISO-8601 string (standard, human-readable, interoperable). */
385
+ static schema = s.string.chain(str => Duration.fromISO8601(str).unwrap(() => fail("Invalid ISO-8601 duration format"), d => always(d)), d => d.toISO8601());
386
+ }
387
+ export { DateOnly, TimeOfDay, POSIX, Duration };
@@ -0,0 +1,27 @@
1
+ export { OpenTelemetryTracer, OtlpJsonStdoutProcessor };
2
+ import { type Tracer, type Attributes } from "../tracing";
3
+ import { Future } from "../future";
4
+ import type { SpanProcessor, ReadableSpan, Sampler } from "@opentelemetry/sdk-trace-base";
5
+ declare class OtlpJsonStdoutProcessor implements SpanProcessor {
6
+ private readonly byTrace;
7
+ onStart(): void;
8
+ onEnd(span: ReadableSpan): void;
9
+ forceFlush(): Promise<void>;
10
+ shutdown(): Promise<void>;
11
+ }
12
+ interface OpenTelemetryTracerOptions {
13
+ serviceName: string;
14
+ spanProcessor: SpanProcessor;
15
+ sampler?: Sampler;
16
+ }
17
+ declare class OpenTelemetryTracer implements Tracer {
18
+ private readonly tracer;
19
+ private readonly provider;
20
+ constructor(options: OpenTelemetryTracerOptions);
21
+ private begin;
22
+ trace<A>(name: string, attributes: Attributes, f: () => A): A;
23
+ traceP<A>(name: string, attributes: Attributes, f: () => Promise<A>): Promise<A>;
24
+ traceF<E, A>(name: string, attributes: Attributes, f: Future<E, A>): Future<E, A>;
25
+ event(name: string, attributes?: Attributes): void;
26
+ shutdown(): Promise<void>;
27
+ }
@@ -0,0 +1,215 @@
1
+ // OpenTelemetry tracing: a `Tracer` (see ./index.ts) exposing the same API as
2
+ // `./simple.ts` — `trace`, `traceP`, `traceF`.
3
+ //
4
+ // Nesting is automatic and needs no token threaded through the code — it rides on
5
+ // OpenTelemetry's AsyncLocalStorage context manager, which propagates the active
6
+ // span across `await`s and Fluture's `.chain`/`.map` continuations. A span
7
+ // created while another is active becomes its child.
8
+ //
9
+ // Construct it with a service name and a span processor:
10
+ //
11
+ // new OpenTelemetryTracer({ serviceName: "backend", spanProcessor: new OtlpJsonStdoutProcessor() })
12
+ //
13
+ // `OtlpJsonStdoutProcessor` (also exported here) prints each completed trace to
14
+ // stdout as an OTLP/JSON document (`{ resourceSpans: [...] }`) — paste it into an
15
+ // OTLP trace viewer such as https://tracekit.dev/tools/trace-visualizer.
16
+ export { OpenTelemetryTracer, OtlpJsonStdoutProcessor };
17
+ import { Future } from "../future";
18
+ import { context, trace as otel, SpanStatusCode, } from "@opentelemetry/api";
19
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
20
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
21
+ import { resourceFromAttributes } from "@opentelemetry/resources";
22
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
23
+ // --- OTLP/JSON stdout span processor ---------------------------------------
24
+ //
25
+ // Prints completed traces to stdout as OTLP/JSON (`{ resourceSpans: [...] }`),
26
+ // one self-contained document per trace, flushed when the trace's ROOT span ends
27
+ // (which is after all of its children). Hand-rolled to avoid depending on
28
+ // @opentelemetry/otlp-transformer. trace/span ids are emitted as hex and int
29
+ // attributes as strings, per the OTLP/JSON encoding the OTel SDKs and Collector
30
+ // use.
31
+ class OtlpJsonStdoutProcessor {
32
+ // Spans accumulated per in-flight trace, flushed when the trace's root ends.
33
+ byTrace = new Map();
34
+ onStart() { }
35
+ onEnd(span) {
36
+ const traceId = span.spanContext().traceId;
37
+ const spans = this.byTrace.get(traceId) ?? [];
38
+ spans.push(span);
39
+ this.byTrace.set(traceId, spans);
40
+ if (!parentSpanId(span)) {
41
+ // Root span ended → the trace is complete.
42
+ this.byTrace.delete(traceId);
43
+ console.log(JSON.stringify(toOtlp(spans)));
44
+ }
45
+ }
46
+ forceFlush() {
47
+ return Promise.resolve();
48
+ }
49
+ shutdown() {
50
+ return Promise.resolve();
51
+ }
52
+ }
53
+ // ReadableSpan exposes the parent either as `parentSpanContext` (newer SDKs) or
54
+ // `parentSpanId` (older ones). A missing/empty value means this is a root span.
55
+ function parentSpanId(span) {
56
+ const s = span;
57
+ return s.parentSpanContext?.spanId ?? s.parentSpanId ?? undefined;
58
+ }
59
+ function toOtlp(spans) {
60
+ const first = spans[0];
61
+ return {
62
+ resourceSpans: [
63
+ {
64
+ resource: { attributes: toAttributes(first?.resource.attributes ?? {}) },
65
+ scopeSpans: [
66
+ {
67
+ scope: {
68
+ name: first?.instrumentationScope.name ?? "",
69
+ version: first?.instrumentationScope.version,
70
+ },
71
+ spans: spans.map(toOtlpSpan),
72
+ },
73
+ ],
74
+ },
75
+ ],
76
+ };
77
+ }
78
+ function toOtlpSpan(span) {
79
+ const ctx = span.spanContext();
80
+ return {
81
+ traceId: ctx.traceId,
82
+ spanId: ctx.spanId,
83
+ parentSpanId: parentSpanId(span),
84
+ name: span.name,
85
+ // OTLP SpanKind is the API SpanKind + 1 (OTLP reserves 0 for UNSPECIFIED).
86
+ kind: span.kind + 1,
87
+ startTimeUnixNano: hrTimeToNanoString(span.startTime),
88
+ endTimeUnixNano: hrTimeToNanoString(span.endTime),
89
+ attributes: toAttributes(span.attributes),
90
+ events: span.events.map(e => ({
91
+ timeUnixNano: hrTimeToNanoString(e.time),
92
+ name: e.name,
93
+ attributes: toAttributes(e.attributes ?? {}),
94
+ })),
95
+ status: { code: span.status.code },
96
+ };
97
+ }
98
+ function toAttributes(attrs) {
99
+ return Object.entries(attrs)
100
+ .filter(([, v]) => v !== undefined)
101
+ .map(([key, value]) => ({ key, value: toAnyValue(value) }));
102
+ }
103
+ function toAnyValue(value) {
104
+ if (typeof value === "string") {
105
+ return { stringValue: value };
106
+ }
107
+ if (typeof value === "boolean") {
108
+ return { boolValue: value };
109
+ }
110
+ if (typeof value === "number") {
111
+ // int64 fields are encoded as strings in OTLP/JSON.
112
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
113
+ }
114
+ if (Array.isArray(value)) {
115
+ return { arrayValue: { values: value.map(v => toAnyValue(v)) } };
116
+ }
117
+ return { stringValue: String(value) };
118
+ }
119
+ // HrTime is [seconds, nanos]. Combine via BigInt to avoid the precision loss of
120
+ // representing epoch-nanoseconds (~1.8e18) as a JS number.
121
+ function hrTimeToNanoString(time) {
122
+ return (BigInt(time[0]) * 1000000000n + BigInt(time[1])).toString();
123
+ }
124
+ // Mark a span as failed: record the error and set its status to ERROR. Accepts
125
+ // any thrown/rejected value (Futures reject with an arbitrary `E`), coercing
126
+ // non-Error values to a string.
127
+ function recordError(span, err) {
128
+ span.recordException(err instanceof Error ? err : String(err));
129
+ span.setStatus({ code: SpanStatusCode.ERROR });
130
+ }
131
+ class OpenTelemetryTracer {
132
+ tracer;
133
+ provider;
134
+ constructor(options) {
135
+ this.provider = new NodeTracerProvider({
136
+ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: options.serviceName }),
137
+ spanProcessors: [options.spanProcessor],
138
+ ...(options.sampler ? { sampler: options.sampler } : {}),
139
+ });
140
+ // The AsyncLocalStorage context manager is what lets `context.active()`
141
+ // return the right parent span inside `await`s and Fluture continuations.
142
+ this.provider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
143
+ this.tracer = otel.getTracer(options.serviceName);
144
+ }
145
+ // Begin a span as a child of the active one (or a new root when none is
146
+ // active) and return both the span and the context that makes it active.
147
+ begin(name, attributes) {
148
+ const parentCtx = context.active();
149
+ const span = this.tracer.startSpan(name, { attributes }, parentCtx);
150
+ return { span, ctx: otel.setSpan(parentCtx, span) };
151
+ }
152
+ // Trace a synchronous function.
153
+ trace(name, attributes, f) {
154
+ const { span, ctx } = this.begin(name, attributes);
155
+ try {
156
+ return context.with(ctx, f);
157
+ }
158
+ catch (err) {
159
+ recordError(span, err);
160
+ throw err;
161
+ }
162
+ finally {
163
+ span.end();
164
+ }
165
+ }
166
+ // Trace an asynchronous function. `context.with` keeps the span active across
167
+ // the awaits inside `f` (AsyncLocalStorage propagates it), so spans created
168
+ // within `f` nest under it. After the awaited promise settles we are back in
169
+ // the caller's context, so the span ends in the right place and a subsequent
170
+ // `traceP` becomes a sibling.
171
+ async traceP(name, attributes, f) {
172
+ const { span, ctx } = this.begin(name, attributes);
173
+ try {
174
+ return await context.with(ctx, f);
175
+ }
176
+ catch (err) {
177
+ recordError(span, err);
178
+ throw err;
179
+ }
180
+ finally {
181
+ span.end();
182
+ }
183
+ }
184
+ // Trace a Future. Because Futures are lazy, the span begins when the Future is
185
+ // forked (its real start) and ends when it settles. The span is made active
186
+ // for the Future's execution so nested spans nest under it; the parent context
187
+ // is restored when settling so that a `.chain` *after* `traceF(...)` is a
188
+ // sibling rather than a descendant.
189
+ traceF(name, attributes, f) {
190
+ return Future.create((reject, resolve) => {
191
+ const parentCtx = context.active();
192
+ const span = this.tracer.startSpan(name, { attributes }, parentCtx);
193
+ const ctx = otel.setSpan(parentCtx, span);
194
+ return context.with(ctx, () => f.fork(err => {
195
+ recordError(span, err);
196
+ span.end();
197
+ context.with(parentCtx, () => reject(err));
198
+ }, val => {
199
+ span.end();
200
+ context.with(parentCtx, () => resolve(val));
201
+ }));
202
+ });
203
+ }
204
+ // Record a point-in-time event on the currently-active span (the span made
205
+ // active by an enclosing trace/traceP/traceF). No-op when none is active.
206
+ event(name, attributes) {
207
+ otel.getActiveSpan()?.addEvent(name, attributes);
208
+ }
209
+ // Flush any pending spans, then shut down the provider and its span processors.
210
+ // Call on graceful shutdown (SIGTERM/SIGINT) so the final batch is not dropped.
211
+ async shutdown() {
212
+ await this.provider.forceFlush();
213
+ await this.provider.shutdown();
214
+ }
215
+ }
@@ -0,0 +1,20 @@
1
+ export { ProxyTracer, type Config };
2
+ import { type Tracer, type Attributes } from "../tracing";
3
+ import type { Future } from "../future";
4
+ interface Config {
5
+ tracer: Tracer;
6
+ traceFilter?: (name: string) => boolean;
7
+ includeCallSite?: boolean;
8
+ }
9
+ declare class ProxyTracer implements Tracer {
10
+ private readonly inner;
11
+ private readonly traceFilter;
12
+ private readonly includeCallSite;
13
+ constructor(config: Config);
14
+ private enabled;
15
+ private withCallSite;
16
+ trace<A>(name: string, attributes: Attributes, f: () => A): A;
17
+ traceP<A>(name: string, attributes: Attributes, f: () => Promise<A>): Promise<A>;
18
+ traceF<E, A>(name: string, attributes: Attributes, f: Future<E, A>): Future<E, A>;
19
+ event(name: string, attributes?: Attributes): void;
20
+ }