apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.

Files draw
728 lines · 22.6 KB · ~6.6k tok Raw History
1
/**
2
 * OpenTelemetry in one file, with nothing to install: W3C trace context in
3
 * and out, spans, metrics and logs exported as OTLP/JSON over plain `fetch`. The
4
 * forge traces its own requests with it, and every app the forge scaffolds
5
 * carries this same file as `src/otel.ts` — one implementation, read by both.
6
 *
7
 * Configured by the standard variables, so any OpenTelemetry collector works:
8
 *
9
 *   OTEL_EXPORTER_OTLP_ENDPOINT   e.g. http://otel-collector:4318 (unset = off)
10
 *   OTEL_SERVICE_NAME             what this process is called in a trace
11
 *   OTEL_RESOURCE_ATTRIBUTES      k=v,k=v added to every span and metric
12
 *   OTEL_SDK_DISABLED=true        off, whatever else is set
13
 *
14
 * With no endpoint every call here is a cheap no-op: spans are still made
15
 * (so code reads the same) but nothing is kept or sent. Export never throws
16
 * and never blocks a request; a collector that is down costs a dropped
17
 * batch, not an error page. The queue is bounded — the oldest spans go first.
18
 */
19
20
import { AsyncLocalStorage } from "node:async_hooks";
21
import { randomBytes } from "node:crypto";
22
23
// The real fetch, taken before anyone (an app's own setup) can replace the
24
// global with `tracedFetch`: export and tracedFetch's own call use it, so
25
// neither traces itself.
26
const nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis);
27
28
export type Attr = string | number | boolean;
29
export type Attrs = Record<string, Attr | undefined>;
30
31
export const SpanKind = { internal: 1, server: 2, client: 3 } as const;
32
export type SpanKindName = keyof typeof SpanKind;
33
34
export interface SpanContext {
35
  readonly traceId: string;
36
  readonly spanId: string;
37
  readonly sampled: boolean;
38
}
39
40
interface Config {
41
  readonly endpoint: string | undefined;
42
  readonly service: string;
43
  readonly resource: Attrs;
44
}
45
46
const env = (name: string): string | undefined => {
47
  const value = process.env[name]?.trim();
48
  return value === "" ? undefined : value;
49
};
50
51
/** `k=v,k=v` as the spec writes it (values may be percent-encoded). */
52
export function parseResourceAttributes(raw: string | undefined): Attrs {
53
  const out: Attrs = {};
54
  for (const pair of (raw ?? "").split(",")) {
55
    const at = pair.indexOf("=");
56
    if (at <= 0) continue;
57
    const key = pair.slice(0, at).trim();
58
    let value = pair.slice(at + 1).trim();
59
    try {
60
      value = decodeURIComponent(value);
61
    } catch {
62
      // A stray % is kept as written.
63
    }
64
    if (key !== "") out[key] = value;
65
  }
66
  return out;
67
}
68
69
function configFromEnv(): Config {
70
  const disabled = env("OTEL_SDK_DISABLED") === "true";
71
  const resource = parseResourceAttributes(env("OTEL_RESOURCE_ATTRIBUTES"));
72
  const service =
73
    env("OTEL_SERVICE_NAME") ??
74
    (typeof resource["service.name"] === "string"
75
      ? resource["service.name"]
76
      : "unknown_service:bun");
77
  return {
78
    endpoint: disabled
79
      ? undefined
80
      : env("OTEL_EXPORTER_OTLP_ENDPOINT")?.replace(/\/+$/, ""),
81
    service,
82
    resource,
83
  };
84
}
85
86
let config: Config = configFromEnv();
87
88
/** Re-read the environment, or set explicitly (tests, embedders). */
89
export function configure(next?: Partial<Config>): void {
90
  config = { ...configFromEnv(), ...next };
91
}
92
93
export const enabled = (): boolean => config.endpoint !== undefined;
94
95
// ---- trace context ----------------------------------------------------------
96
97
const hex = (bytes: number): string => randomBytes(bytes).toString("hex");
98
const ZERO_TRACE = "0".repeat(32);
99
const ZERO_SPAN = "0".repeat(16);
100
101
/** `00-<trace>-<span>-<flags>`, or undefined for anything malformed. */
102
export function parseTraceparent(
103
  header: string | null | undefined,
104
): SpanContext | undefined {
105
  const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
106
    (header ?? "").trim().toLowerCase(),
107
  );
108
  if (match === null) return undefined;
109
  const [, version, traceId, spanId, flags] = match as unknown as [
110
    string,
111
    string,
112
    string,
113
    string,
114
    string,
115
  ];
116
  if (version === "ff" || traceId === ZERO_TRACE || spanId === ZERO_SPAN)
117
    return undefined;
118
  return { traceId, spanId, sampled: (Number.parseInt(flags, 16) & 1) === 1 };
119
}
120
121
export const formatTraceparent = (ctx: SpanContext): string =>
122
  `00-${ctx.traceId}-${ctx.spanId}-${ctx.sampled ? "01" : "00"}`;
123
124
// ---- spans ------------------------------------------------------------------
125
126
/** Wall-clock nanoseconds, as a decimal string (OTLP/JSON's uint64 form). */
127
const nowNs = (): bigint =>
128
  BigInt(Math.round((performance.timeOrigin + performance.now()) * 1e3)) *
129
  1000n;
130
131
export class Span implements SpanContext {
132
  readonly traceId: string;
133
  readonly spanId: string;
134
  readonly parentSpanId: string | undefined;
135
  readonly sampled: boolean;
136
  readonly startNs: bigint;
137
  endNs: bigint | undefined;
138
  readonly attributes: Attrs;
139
  status: { code: 0 | 1 | 2; message?: string } = { code: 0 };
140
  readonly events: { name: string; timeNs: bigint; attributes: Attrs }[] = [];
141
142
  constructor(
143
    public name: string,
144
    readonly kind: SpanKindName,
145
    parent: SpanContext | undefined,
146
    attributes: Attrs,
147
    startTime?: number,
148
  ) {
149
    // A span recorded after the fact (a run that finished) says when it began.
150
    this.startNs = startTime === undefined ? nowNs() : BigInt(Math.round(startTime * 1e3)) * 1000n;
151
    this.traceId = parent?.traceId ?? hex(16);
152
    this.parentSpanId = parent?.spanId;
153
    this.sampled = parent?.sampled ?? true;
154
    this.spanId = hex(8);
155
    this.attributes = { ...attributes };
156
  }
157
158
  set(key: string, value: Attr | undefined): this {
159
    if (value !== undefined) this.attributes[key] = value;
160
    return this;
161
  }
162
163
  event(name: string, attributes: Attrs = {}): this {
164
    this.events.push({ name, timeNs: nowNs(), attributes });
165
    return this;
166
  }
167
168
  /** Mark failed. An exception's message is kept; its stack is not. */
169
  fail(error: unknown): this {
170
    const message = error instanceof Error ? error.message : String(error);
171
    this.status = { code: 2, message };
172
    this.event("exception", {
173
      "exception.type": error instanceof Error ? error.name : typeof error,
174
      "exception.message": message,
175
    });
176
    return this;
177
  }
178
179
  end(): void {
180
    if (this.endNs !== undefined) return;
181
    this.endNs = nowNs();
182
    if (this.sampled && enabled()) enqueue(this);
183
  }
184
185
  get durationSeconds(): number {
186
    return Number((this.endNs ?? nowNs()) - this.startNs) / 1e9;
187
  }
188
}
189
190
const active = new AsyncLocalStorage<Span>();
191
192
/** The span the current code runs inside, if any. */
193
export const currentSpan = (): Span | undefined => active.getStore();
194
195
export interface SpanOptions {
196
  readonly kind?: SpanKindName;
197
  /** Parent explicitly; default is the current span. `null` = a new trace. */
198
  readonly parent?: SpanContext | null;
199
  readonly attributes?: Attrs;
200
  /** When it began, in epoch milliseconds, if before now. */
201
  readonly startTime?: number;
202
}
203
204
export function startSpan(name: string, options: SpanOptions = {}): Span {
205
  const parent =
206
    options.parent === null ? undefined : (options.parent ?? currentSpan());
207
  return new Span(name, options.kind ?? "internal", parent, options.attributes ?? {}, options.startTime);
208
}
209
210
/**
211
 * Run `fn` inside a new span: it is current for everything `fn` awaits, it
212
 * ends when `fn` settles, and a throw marks it failed (and still throws).
213
 */
214
export async function span<T>(
215
  name: string,
216
  fn: (span: Span) => T | Promise<T>,
217
  options: SpanOptions = {},
218
): Promise<T> {
219
  const s = startSpan(name, options);
220
  try {
221
    return await active.run(s, () => fn(s));
222
  } catch (error) {
223
    s.fail(error);
224
    throw error;
225
  } finally {
226
    s.end();
227
  }
228
}
229
230
/** Run `fn` with `s` as the current span, without ending it. */
231
export const withSpan = <T>(s: Span, fn: () => T): T => active.run(s, fn);
232
233
// ---- HTTP -------------------------------------------------------------------
234
235
/**
236
 * A server span for one request, parented on the caller's `traceparent`.
237
 * Records `http.server.request.duration` too. `route` names the matched
238
 * route template (`/notes/:id`) once it is known — set it on the span.
239
 */
240
export async function serveSpan<R extends Response | undefined>(
241
  request: Request,
242
  handle: (span: Span) => R | Promise<R>,
243
  attributes: Attrs = {},
244
): Promise<R> {
245
  const url = new URL(request.url);
246
  const s = startSpan(request.method, {
247
    kind: "server",
248
    parent: parseTraceparent(request.headers.get("traceparent")) ?? null,
249
    attributes: {
250
      "http.request.method": request.method,
251
      "url.path": url.pathname,
252
      "url.scheme":
253
        request.headers.get("x-forwarded-proto") ?? url.protocol.slice(0, -1),
254
      "server.address": (
255
        request.headers.get("x-forwarded-host") ??
256
        request.headers.get("host") ??
257
        url.hostname
258
      ).replace(/:\d+$/, ""),
259
      "user_agent.original": request.headers.get("user-agent") ?? undefined,
260
      ...attributes,
261
    },
262
  });
263
  let status = 500;
264
  try {
265
    const response = await active.run(s, () => handle(s));
266
    // undefined = the socket was upgraded (a websocket): it answered 101.
267
    status = response?.status ?? 101;
268
    return response;
269
  } catch (error) {
270
    s.fail(error);
271
    throw error;
272
  } finally {
273
    endServerSpan(s, status);
274
  }
275
}
276
277
/**
278
 * Trace a whole `Bun.serve` options object: its `fetch` runs inside
279
 * `serveSpan`, everything else (websocket handlers, port, error) untouched.
280
 * The one-line retrofit for an app that routes by hand:
281
 *
282
 *   Bun.serve(traceServe({ port, fetch(request, server) { ... } }))
283
 *
284
 * A route template isn't known here, so spans are named by method only —
285
 * set `http.route` on `currentSpan()` from the handler if you route by name.
286
 */
287
export function traceServe<
288
  T extends { fetch: (request: Request, ...rest: never[]) => unknown },
289
>(options: T): T {
290
  const inner = options.fetch;
291
  return {
292
    ...options,
293
    fetch(this: unknown, request: Request, ...rest: never[]) {
294
      return serveSpan(
295
        request,
296
        async () =>
297
          (await inner.call(this, request, ...rest)) as Response | undefined,
298
      );
299
    },
300
  };
301
}
302
303
/**
304
 * Finish a server span made by hand (a framework's request hooks): status,
305
 * the `METHOD /route` name, and the duration histogram — what `serveSpan`
306
 * does in its `finally`.
307
 */
308
export function endServerSpan(s: Span, status: number): void {
309
  const method = s.attributes["http.request.method"];
310
  s.set("http.response.status_code", status);
311
  if (status >= 500 && s.status.code === 0) s.status = { code: 2 };
312
  const route = s.attributes["http.route"];
313
  if (typeof route === "string" && typeof method === "string")
314
    s.name = `${method} ${route}`;
315
  s.end();
316
  requestDuration.record(s.durationSeconds, {
317
    "http.request.method": typeof method === "string" ? method : undefined,
318
    "http.route": typeof route === "string" ? route : undefined,
319
    "http.response.status_code": status,
320
  });
321
}
322
323
/**
324
 * `fetch` that makes a client span and hands the callee our `traceparent`,
325
 * so its spans join this trace. Same signature as `fetch`.
326
 */
327
export async function tracedFetch(
328
  input: string | URL | Request,
329
  init: RequestInit = {},
330
): Promise<Response> {
331
  const request =
332
    input instanceof Request
333
      ? new Request(input, init)
334
      : new Request(String(input), init);
335
  const url = new URL(request.url);
336
  return span(
337
    `${request.method}`,
338
    async (s) => {
339
      const headers = new Headers(request.headers);
340
      headers.set("traceparent", formatTraceparent(s));
341
      const response = await nativeFetch(new Request(request, { headers }));
342
      s.set("http.response.status_code", response.status);
343
      if (response.status >= 500) s.status = { code: 2 };
344
      return response;
345
    },
346
    {
347
      kind: "client",
348
      attributes: {
349
        "http.request.method": request.method,
350
        "server.address": url.hostname,
351
        "server.port": url.port === "" ? undefined : Number(url.port),
352
        "url.full": `${url.origin}${url.pathname}`,
353
      },
354
    },
355
  );
356
}
357
358
// ---- metrics ----------------------------------------------------------------
359
360
const DURATION_BUCKETS = [
361
  0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
362
];
363
364
const attrKey = (attrs: Attrs): string =>
365
  JSON.stringify(
366
    Object.entries(attrs)
367
      .filter(([, v]) => v !== undefined)
368
      .sort(([a], [b]) => (a < b ? -1 : 1)),
369
  );
370
371
interface Instrument {
372
  readonly name: string;
373
  readonly unit: string;
374
  readonly description: string;
375
  toOtlp(startNs: string, nowNs: string): unknown | undefined;
376
}
377
378
const instruments: Instrument[] = [];
379
const processStartNs = nowNs().toString();
380
381
export interface Histogram {
382
  record(value: number, attributes?: Attrs): void;
383
}
384
385
/** A cumulative explicit-bucket histogram. Keep attribute values few. */
386
export function histogram(
387
  name: string,
388
  options: { unit?: string; description?: string; buckets?: number[] } = {},
389
): Histogram {
390
  const bounds = options.buckets ?? DURATION_BUCKETS;
391
  const points = new Map<
392
    string,
393
    { attrs: Attrs; count: number; sum: number; buckets: number[] }
394
  >();
395
  instruments.push({
396
    name,
397
    unit: options.unit ?? "s",
398
    description: options.description ?? "",
399
    toOtlp(startNs, timeNs) {
400
      if (points.size === 0) return undefined;
401
      return {
402
        histogram: {
403
          aggregationTemporality: 2,
404
          dataPoints: [...points.values()].map((p) => ({
405
            attributes: otlpAttrs(p.attrs),
406
            startTimeUnixNano: startNs,
407
            timeUnixNano: timeNs,
408
            count: String(p.count),
409
            sum: p.sum,
410
            bucketCounts: p.buckets.map(String),
411
            explicitBounds: bounds,
412
          })),
413
        },
414
      };
415
    },
416
  });
417
  return {
418
    record(value, attributes = {}) {
419
      if (!enabled() || !Number.isFinite(value)) return;
420
      const key = attrKey(attributes);
421
      let p = points.get(key);
422
      if (p === undefined) {
423
        p = {
424
          attrs: attributes,
425
          count: 0,
426
          sum: 0,
427
          buckets: new Array(bounds.length + 1).fill(0),
428
        };
429
        points.set(key, p);
430
      }
431
      p.count += 1;
432
      p.sum += value;
433
      let i = bounds.findIndex((b) => value <= b);
434
      if (i === -1) i = bounds.length;
435
      p.buckets[i] = (p.buckets[i] ?? 0) + 1;
436
    },
437
  };
438
}
439
440
export interface Counter {
441
  add(value?: number, attributes?: Attrs): void;
442
}
443
444
/** A cumulative monotonic sum. */
445
export function counter(
446
  name: string,
447
  options: { unit?: string; description?: string } = {},
448
): Counter {
449
  const points = new Map<string, { attrs: Attrs; value: number }>();
450
  instruments.push({
451
    name,
452
    unit: options.unit ?? "1",
453
    description: options.description ?? "",
454
    toOtlp(startNs, timeNs) {
455
      if (points.size === 0) return undefined;
456
      return {
457
        sum: {
458
          aggregationTemporality: 2,
459
          isMonotonic: true,
460
          dataPoints: [...points.values()].map((p) => ({
461
            attributes: otlpAttrs(p.attrs),
462
            startTimeUnixNano: startNs,
463
            timeUnixNano: timeNs,
464
            asDouble: p.value,
465
          })),
466
        },
467
      };
468
    },
469
  });
470
  return {
471
    add(value = 1, attributes = {}) {
472
      if (!enabled() || !(value >= 0)) return;
473
      const key = attrKey(attributes);
474
      const p = points.get(key);
475
      if (p === undefined) points.set(key, { attrs: attributes, value });
476
      else p.value += value;
477
    },
478
  };
479
}
480
481
/** A value read at export time: queue depth, rows in a table, players online. */
482
export function gauge(
483
  name: string,
484
  read: () => number | { value: number; attributes?: Attrs }[],
485
  options: { unit?: string; description?: string } = {},
486
): void {
487
  instruments.push({
488
    name,
489
    unit: options.unit ?? "1",
490
    description: options.description ?? "",
491
    toOtlp(_startNs, timeNs) {
492
      let values: { value: number; attributes?: Attrs }[];
493
      try {
494
        const got = read();
495
        values = typeof got === "number" ? [{ value: got }] : got;
496
      } catch {
497
        return undefined;
498
      }
499
      if (values.length === 0) return undefined;
500
      return {
501
        gauge: {
502
          dataPoints: values.map((v) => ({
503
            attributes: otlpAttrs(v.attributes ?? {}),
504
            timeUnixNano: timeNs,
505
            asDouble: v.value,
506
          })),
507
        },
508
      };
509
    },
510
  });
511
}
512
513
/** The semantic-convention HTTP server histogram `serveSpan` records into. */
514
const requestDuration = histogram("http.server.request.duration", {
515
  unit: "s",
516
  description: "Duration of HTTP server requests.",
517
});
518
519
// ---- logs -------------------------------------------------------------------
520
521
export type Severity = "debug" | "info" | "warn" | "error";
522
const SEVERITY_NUMBER: Record<Severity, number> = { debug: 5, info: 9, warn: 13, error: 17 };
523
524
export interface LogOptions {
525
  readonly severity?: Severity;
526
  readonly attributes?: Attrs;
527
  /**
528
   * Who is speaking, when it is not this process: the forge files each
529
   * app's output under the app's own `service.name`. Added to (and wins
530
   * over) this process's resource.
531
   */
532
  readonly resource?: Attrs;
533
}
534
535
interface LogEntry {
536
  readonly timeNs: bigint;
537
  readonly body: string;
538
  readonly severity: Severity;
539
  readonly attributes: Attrs;
540
  readonly resource: Attrs | undefined;
541
  readonly traceId: string | undefined;
542
  readonly spanId: string | undefined;
543
}
544
545
const MAX_LOG_QUEUE = 4096;
546
let logQueue: LogEntry[] = [];
547
548
/**
549
 * One log record, tied to the current span when there is one. A no-op
550
 * without an endpoint; never throws, never blocks.
551
 */
552
export function emitLog(body: string, options: LogOptions = {}): void {
553
  if (!enabled()) return;
554
  const s = currentSpan();
555
  logQueue.push({
556
    timeNs: nowNs(),
557
    body,
558
    severity: options.severity ?? "info",
559
    attributes: options.attributes ?? {},
560
    resource: options.resource,
561
    traceId: s?.traceId,
562
    spanId: s?.spanId,
563
  });
564
  // The oldest go first: a flood keeps its newest lines.
565
  if (logQueue.length > MAX_LOG_QUEUE) logQueue = logQueue.slice(-MAX_LOG_QUEUE);
566
  startTimer();
567
}
568
569
/** Log records in OTLP/JSON, grouped by who spoke. */
570
export function logsToOtlp(entries: readonly LogEntry[]): unknown {
571
  const byResource = new Map<string, { resource: Attrs | undefined; records: unknown[] }>();
572
  for (const e of entries) {
573
    const key = e.resource === undefined ? "" : attrKey(e.resource);
574
    let group = byResource.get(key);
575
    if (group === undefined) byResource.set(key, (group = { resource: e.resource, records: [] }));
576
    group.records.push({
577
      timeUnixNano: e.timeNs.toString(),
578
      observedTimeUnixNano: e.timeNs.toString(),
579
      severityNumber: SEVERITY_NUMBER[e.severity],
580
      severityText: e.severity.toUpperCase(),
581
      body: { stringValue: e.body },
582
      attributes: otlpAttrs(e.attributes),
583
      ...(e.traceId === undefined ? {} : { traceId: e.traceId }),
584
      ...(e.spanId === undefined ? {} : { spanId: e.spanId }),
585
    });
586
  }
587
  return {
588
    resourceLogs: [...byResource.values()].map((g) => ({
589
      resource: resource(g.resource),
590
      scopeLogs: [{ scope: SCOPE, logRecords: g.records }],
591
    })),
592
  };
593
}
594
595
// ---- export -----------------------------------------------------------------
596
597
function otlpValue(value: Attr): Record<string, unknown> {
598
  if (typeof value === "boolean") return { boolValue: value };
599
  if (typeof value === "number")
600
    return Number.isInteger(value)
601
      ? { intValue: String(value) }
602
      : { doubleValue: value };
603
  return { stringValue: value };
604
}
605
606
export function otlpAttrs(attrs: Attrs): unknown[] {
607
  return Object.entries(attrs)
608
    .filter((e): e is [string, Attr] => e[1] !== undefined)
609
    .map(([key, value]) => ({ key, value: otlpValue(value) }));
610
}
611
612
const resource = (over?: Attrs) => ({
613
  attributes: otlpAttrs({
614
    "telemetry.sdk.name": "forge-otel",
615
    "telemetry.sdk.language": "js",
616
    "process.runtime.name": process.versions.bun === undefined ? "node" : "bun",
617
    "process.runtime.version": process.versions.bun ?? process.versions.node,
618
    ...config.resource,
619
    "service.name": config.service,
620
    ...over,
621
  }),
622
});
623
624
const SCOPE = { name: "forge-otel", version: "1" };
625
626
/** One span in OTLP/JSON (ids are lowercase hex, times decimal ns). */
627
export function spanToOtlp(s: Span): unknown {
628
  return {
629
    traceId: s.traceId,
630
    spanId: s.spanId,
631
    ...(s.parentSpanId === undefined ? {} : { parentSpanId: s.parentSpanId }),
632
    name: s.name,
633
    kind: SpanKind[s.kind],
634
    startTimeUnixNano: s.startNs.toString(),
635
    endTimeUnixNano: (s.endNs ?? s.startNs).toString(),
636
    attributes: otlpAttrs(s.attributes),
637
    events: s.events.map((e) => ({
638
      name: e.name,
639
      timeUnixNano: e.timeNs.toString(),
640
      attributes: otlpAttrs(e.attributes),
641
    })),
642
    status: s.status,
643
  };
644
}
645
646
const MAX_QUEUE = 2048;
647
const BATCH = 512;
648
let queue: Span[] = [];
649
let timer: ReturnType<typeof setInterval> | undefined;
650
let lastMetrics = 0;
651
652
function enqueue(s: Span): void {
653
  queue.push(s);
654
  if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
655
  startTimer();
656
}
657
658
function startTimer(): void {
659
  if (timer === undefined) {
660
    timer = setInterval(() => void flush(), 5_000);
661
    // Telemetry must never be the reason a process stays alive.
662
    (timer as { unref?: () => void }).unref?.();
663
  }
664
}
665
666
async function post(path: string, body: unknown): Promise<boolean> {
667
  if (config.endpoint === undefined) return false;
668
  try {
669
    const response = await nativeFetch(`${config.endpoint}${path}`, {
670
      method: "POST",
671
      headers: { "content-type": "application/json" },
672
      body: JSON.stringify(body),
673
      signal: AbortSignal.timeout(5_000),
674
    });
675
    return response.ok;
676
  } catch {
677
    return false;
678
  }
679
}
680
681
/** Send what is queued now. Resolves when sent (or dropped); never throws. */
682
export async function flush(): Promise<void> {
683
  while (queue.length > 0) {
684
    const batch = queue.splice(0, BATCH);
685
    await post("/v1/traces", {
686
      resourceSpans: [
687
        { resource: resource(), scopeSpans: [{ scope: SCOPE, spans: batch.map(spanToOtlp) }] },
688
      ],
689
    });
690
  }
691
  while (logQueue.length > 0) {
692
    const batch = logQueue.splice(0, BATCH);
693
    await post("/v1/logs", logsToOtlp(batch));
694
  }
695
  if (Date.now() - lastMetrics >= 30_000) {
696
    lastMetrics = Date.now();
697
    await flushMetrics();
698
  }
699
}
700
701
export function metricsToOtlp(): unknown | undefined {
702
  const timeNs = nowNs().toString();
703
  const metrics = instruments.flatMap((i) => {
704
    const data = i.toOtlp(processStartNs, timeNs);
705
    return data === undefined
706
      ? []
707
      : [{ name: i.name, unit: i.unit, description: i.description, ...(data as object) }];
708
  });
709
  if (metrics.length === 0) return undefined;
710
  return {
711
    resourceMetrics: [
712
      { resource: resource(), scopeMetrics: [{ scope: SCOPE, metrics }] },
713
    ],
714
  };
715
}
716
717
export async function flushMetrics(): Promise<void> {
718
  if (!enabled()) return;
719
  const body = metricsToOtlp();
720
  if (body !== undefined) await post("/v1/metrics", body);
721
}
722
723
/** Start the periodic exporter even before the first span (metrics-only apps). */
724
export function start(): void {
725
  if (!enabled() || timer !== undefined) return;
726
  timer = setInterval(() => void flush(), 5_000);
727
  (timer as { unref?: () => void }).unref?.();
728
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts