mercury-composable 4.12.1 → 4.12.15

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/README.md CHANGED
@@ -14,7 +14,12 @@ This package is a deliberately **lightweight wrapper of the Event-over-HTTP prot
14
14
  - a **primitive in-process event bus** — the single dispatch pipeline: one FIFO mailbox
15
15
  per route consumed by `instances` worker loops, and
16
16
  - the **minimalist utilities** shared with the engines for consistency: configuration
17
- management, logging in the engines' presentation format, and distributed-trace context.
17
+ management, logging in the engines' presentation format, and distributed-trace context, and
18
+ - the **OpenTelemetry forwarder** (opt-in, `otel.forwarding=true`): the host's trace spans
19
+ exported over OTLP/HTTP to Dynatrace, Splunk or a collector — no SDK, no new dependency, and
20
+ - the **AI nodes** `llm.chat` / `llm.stream` in the demo (`examples/llm-nodes.mjs`): provider-neutral
21
+ Gemini and Anthropic adapters over their REST APIs through `fetch` — the graph decides, the model
22
+ advises; `llm.stream` relays the provider's real token stream progressively.
18
23
 
19
24
  Orchestration deliberately stays in the engines. Functions written here are addressed by
20
25
  route name through the engines' declarative `yaml.event.over.http` map, so a flow or a
@@ -24,10 +29,12 @@ graph task calls a Node.js function exactly as if it were local.
24
29
  [AI Agent Guide](https://accenture.github.io/mercury-nodejs/guides/ai-agent-guide/)
25
30
  for deterministic function generation.
26
31
 
27
- > **Status: pre-release.** This repository was repurposed in August 2026 for the polyglot
28
- > initiative: instead of re-porting the full composable foundation to Node.js, the fresh
29
- > start rides the engines' Event-over-HTTP protocol light by design. The previous
30
- > Node.js port (up to v4.3.28) remains available in the git history and on npm.
32
+ > **Published on npm.** `mercury-composable` v4.12.15 is live
33
+ > `npm install mercury-composable`. This repository was repurposed in August 2026 for the
34
+ > polyglot initiative: instead of re-porting the full composable foundation to Node.js, the
35
+ > fresh start rides the engines' Event-over-HTTP protocol light by design. The previous
36
+ > Node.js port (up to v4.3.28) remains available in the git history only — it never
37
+ > occupied this name on the public registry.
31
38
 
32
39
  ## Quick start
33
40
 
@@ -48,8 +55,8 @@ await platform.run(); // port from rest.server.port (default 8085)
48
55
  Run it:
49
56
 
50
57
  ```bash
51
- npm install && npm run build
52
- node dist/src/cli.js app.mjs -Drest.server.port=8087 # or: mercury-serve app.mjs -Drest.server.port=8087
58
+ npm install mercury-composable
59
+ mercury-serve app.mjs -Drest.server.port=8087
53
60
  ```
54
61
 
55
62
  Call it from a Mercury engine application with two configuration entries and no code —
package/dist/src/bus.d.ts CHANGED
@@ -96,7 +96,7 @@ export declare class EventBus {
96
96
  close(): void;
97
97
  private runWorker;
98
98
  /** Run the handler under its trace context and shape the outcome as a reply. */
99
- private static execute;
99
+ private execute;
100
100
  /**
101
101
  * Run an interceptor handler: it receives the raw envelope, replies
102
102
  * manually through reply_to (the engines' EventInterceptor contract), and
@@ -105,6 +105,13 @@ export declare class EventBus {
105
105
  * sink sees it - and a streaming host renders it in-band.
106
106
  */
107
107
  private executeInterceptor;
108
+ /**
109
+ * Hand an emitted dataset to the engines' extension route when a function is
110
+ * registered there (the OpenTelemetry forwarder, or an application's own): a
111
+ * drop-n-forget envelope routed like a reply, carrying no trace - so the
112
+ * forwarder's own execution emits no dataset, the engines' zero-tracing forwarder.
113
+ */
114
+ private forward;
108
115
  private replyInterceptorError;
109
116
  }
110
117
  export {};
package/dist/src/bus.js CHANGED
@@ -37,7 +37,7 @@ import { AppException } from './exceptions.js';
37
37
  import { getLogger } from './log.js';
38
38
  import { randomBytes } from 'node:crypto';
39
39
  import { appOrigin } from './actuator.js';
40
- import { MY_CID_TAG, MY_CORRELATION_ID, RPC_TAG, runWithTrace } from './trace.js';
40
+ import { DISTRIBUTED_TRACE_FORWARDER, MY_CID_TAG, MY_CORRELATION_ID, RPC_TAG, runWithTrace } from './trace.js';
41
41
  const log = getLogger('mercury.bus');
42
42
  /** An RPC delivery missed its deadline; adapters shape the 408 for their protocol. */
43
43
  export class DeliveryTimeout extends Error {
@@ -106,7 +106,7 @@ function isRpc(delivery) {
106
106
  */
107
107
  function emitTrace(delivery, info, start, execTime, status, success, exception) {
108
108
  if (!info.traceId || isRpc(delivery)) {
109
- return;
109
+ return undefined;
110
110
  }
111
111
  const trace = {
112
112
  origin: appOrigin(),
@@ -133,6 +133,7 @@ function emitTrace(delivery, info, start, execTime, status, success, exception)
133
133
  dataset.annotations = { ...info.annotations };
134
134
  }
135
135
  telemetryLog.info(dataset);
136
+ return dataset;
136
137
  }
137
138
  const CLOSED = Symbol('bus-closed');
138
139
  /** Unbounded FIFO handing items to awaiting consumers (node's missing asyncio.Queue). */
@@ -285,7 +286,7 @@ export class EventBus {
285
286
  }
286
287
  const reply = delivery.service.interceptor
287
288
  ? await this.executeInterceptor(delivery)
288
- : await EventBus.execute(delivery);
289
+ : await this.execute(delivery);
289
290
  if (delivery.resolve) {
290
291
  if (!delivery.settled) {
291
292
  delivery.settled = true;
@@ -299,7 +300,7 @@ export class EventBus {
299
300
  }
300
301
  }
301
302
  /** Run the handler under its trace context and shape the outcome as a reply. */
302
- static async execute(delivery) {
303
+ async execute(delivery) {
303
304
  const myCid = businessCid(delivery);
304
305
  const headers = headersView(delivery, myCid);
305
306
  const info = traceInfoOf(delivery, myCid);
@@ -329,7 +330,7 @@ export class EventBus {
329
330
  if (Object.keys(info.annotations).length) {
330
331
  reply.annotations = { ...info.annotations, ...reply.annotations };
331
332
  }
332
- emitTrace(delivery, info, startIso, reply.execTime, reply.getStatus(), !reply.hasError(), reply.hasError() ? String(reply.body) : undefined);
333
+ this.forward(emitTrace(delivery, info, startIso, reply.execTime, reply.getStatus(), !reply.hasError(), reply.hasError() ? String(reply.body) : undefined));
333
334
  return reply;
334
335
  }
335
336
  /**
@@ -357,10 +358,22 @@ export class EventBus {
357
358
  }
358
359
  const status = error instanceof AppException ? error.status : (error ? 500 : 200);
359
360
  const execTime = Math.round(Number(process.hrtime.bigint() - start) / 1000) / 1000;
360
- emitTrace(delivery, info, startIso, execTime, status, !error, error ? String(error.message ?? error) : undefined);
361
+ this.forward(emitTrace(delivery, info, startIso, execTime, status, !error, error ? String(error.message ?? error) : undefined));
361
362
  // an interceptor's own outcome is never auto-replied
362
363
  return new EventEnvelope();
363
364
  }
365
+ /**
366
+ * Hand an emitted dataset to the engines' extension route when a function is
367
+ * registered there (the OpenTelemetry forwarder, or an application's own): a
368
+ * drop-n-forget envelope routed like a reply, carrying no trace - so the
369
+ * forwarder's own execution emits no dataset, the engines' zero-tracing forwarder.
370
+ */
371
+ forward(dataset) {
372
+ if (!dataset || !this.router) {
373
+ return;
374
+ }
375
+ this.router(new EventEnvelope(DISTRIBUTED_TRACE_FORWARDER, dataset));
376
+ }
364
377
  replyInterceptorError(route, event, e) {
365
378
  let error;
366
379
  if (e instanceof AppException) {
@@ -19,6 +19,7 @@ export { getLogger, Logger } from './log.js';
19
19
  export { defaultRegistry, FunctionRegistry, preload, validateRoute } from './registry.js';
20
20
  export type { Handler, ServiceDef } from './registry.js';
21
21
  export { EventApiServer, Platform, platform } from './server.js';
22
- export { annotateTrace, getTrace, runWithTrace, updateContext } from './trace.js';
22
+ export * as otel from './otel/index.js';
23
+ export { annotateTrace, DISTRIBUTED_TRACE_FORWARDER, getTrace, runWithTrace, updateContext } from './trace.js';
23
24
  export type { TraceInfo } from './trace.js';
24
25
  export { VERSION } from './version.js';
package/dist/src/index.js CHANGED
@@ -17,5 +17,6 @@ export { AppException, CompactFormatError } from './exceptions.js';
17
17
  export { getLogger, Logger } from './log.js';
18
18
  export { defaultRegistry, FunctionRegistry, preload, validateRoute } from './registry.js';
19
19
  export { EventApiServer, Platform, platform } from './server.js';
20
- export { annotateTrace, getTrace, runWithTrace, updateContext } from './trace.js';
20
+ export * as otel from './otel/index.js';
21
+ export { annotateTrace, DISTRIBUTED_TRACE_FORWARDER, getTrace, runWithTrace, updateContext } from './trace.js';
21
22
  export { VERSION } from './version.js';
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The forwarder's configuration: the engines' `otel.*` keys, read the engines'
3
+ * way (the Rust port's `config.rs` and `headers.rs`).
4
+ *
5
+ * | Key | Meaning | Default |
6
+ * |-----|---------|---------|
7
+ * | `otel.forwarding` | the master switch | `false` |
8
+ * | `otel.exporter.otlp.endpoint` | the OTLP/HTTP traces URL incl. the signal path | `http://localhost:4318/v1/traces` |
9
+ * | `otel.exporter.otlp.timeout` | per-export timeout, milliseconds | `10000` |
10
+ * | `otel.exporter.otlp.connect.timeout` | no effect here (fetch has one overall timeout) | - |
11
+ * | `otel.exporter.otlp.headers` | request headers: `k=v` or `k: v`, comma list | (none) |
12
+ * | `otel.exporter.otlp.compression` | only `none` is honoured (a warning otherwise) | `none` |
13
+ * | `otel.service.name` | the `service.name` resource attribute | `application.name` |
14
+ */
15
+ import type { AppConfig } from '../config.js';
16
+ export declare const FORWARDING_SWITCH = "otel.forwarding";
17
+ export declare const ENDPOINT = "otel.exporter.otlp.endpoint";
18
+ export declare const TIMEOUT = "otel.exporter.otlp.timeout";
19
+ export declare const CONNECT_TIMEOUT = "otel.exporter.otlp.connect.timeout";
20
+ export declare const COMPRESSION = "otel.exporter.otlp.compression";
21
+ export declare const HEADERS = "otel.exporter.otlp.headers";
22
+ export declare const SERVICE_NAME = "otel.service.name";
23
+ export declare const DEFAULT_ENDPOINT = "http://localhost:4318/v1/traces";
24
+ export declare const DEFAULT_TIMEOUT_MS = 10000;
25
+ export declare const DEFAULT_COMPRESSION = "none";
26
+ export declare const DEFAULT_SERVICE = "mercury";
27
+ /** The request headers, resolved per export. */
28
+ export type HeaderSupplier = () => Array<[string, string]>;
29
+ export interface ForwarderSettings {
30
+ endpoint: string;
31
+ serviceName: string;
32
+ scopeVersion: string;
33
+ timeoutMs: number;
34
+ compression: string;
35
+ headers: HeaderSupplier;
36
+ }
37
+ /**
38
+ * Read the `otel.*` keys. Infallible: an unparseable number falls back to its
39
+ * default with a warning; the endpoint URL is validated when the exporter is built.
40
+ */
41
+ export declare function settingsFromConfig(config: AppConfig): ForwarderSettings;
42
+ /** Settings with a FIXED header list - for tests and callers whose credentials are known up front. */
43
+ export declare function fixedSettings(endpoint: string, options?: {
44
+ timeoutMs?: number;
45
+ headers?: Array<[string, string]>;
46
+ serviceName?: string;
47
+ }): ForwarderSettings;
48
+ /**
49
+ * A supplier that re-parses the raw header setting on every call and announces
50
+ * the header NAMES once when they first resolve (never values).
51
+ */
52
+ export declare function reloadingHeaders(raw: () => string | undefined): HeaderSupplier;
53
+ /**
54
+ * The OpenTelemetry `key=value,key2=value2` list, also accepting the `key: value`
55
+ * form; the FIRST separator splits, so a token containing `=` or `:` survives; a
56
+ * repeated name keeps its last value.
57
+ */
58
+ export declare function parseHeaders(raw: string | undefined): Array<[string, string]>;
@@ -0,0 +1,119 @@
1
+ import { getLogger } from '../log.js';
2
+ import { VERSION } from '../version.js';
3
+ const log = getLogger('mercury.otel');
4
+ export const FORWARDING_SWITCH = 'otel.forwarding';
5
+ export const ENDPOINT = 'otel.exporter.otlp.endpoint';
6
+ export const TIMEOUT = 'otel.exporter.otlp.timeout';
7
+ export const CONNECT_TIMEOUT = 'otel.exporter.otlp.connect.timeout';
8
+ export const COMPRESSION = 'otel.exporter.otlp.compression';
9
+ export const HEADERS = 'otel.exporter.otlp.headers';
10
+ export const SERVICE_NAME = 'otel.service.name';
11
+ const APP_NAME = 'application.name';
12
+ const APP_VERSION = 'info.app.version';
13
+ export const DEFAULT_ENDPOINT = 'http://localhost:4318/v1/traces';
14
+ export const DEFAULT_TIMEOUT_MS = 10_000;
15
+ export const DEFAULT_COMPRESSION = 'none';
16
+ export const DEFAULT_SERVICE = 'mercury';
17
+ /**
18
+ * Read the `otel.*` keys. Infallible: an unparseable number falls back to its
19
+ * default with a warning; the endpoint URL is validated when the exporter is built.
20
+ */
21
+ export function settingsFromConfig(config) {
22
+ const serviceName = config.getProperty(SERVICE_NAME) ?? config.getProperty(APP_NAME)
23
+ ?? DEFAULT_SERVICE;
24
+ const endpoint = config.getProperty(ENDPOINT) ?? DEFAULT_ENDPOINT;
25
+ const timeoutMs = millis(config.getProperty(TIMEOUT), TIMEOUT, DEFAULT_TIMEOUT_MS);
26
+ const compression = (config.getProperty(COMPRESSION) ?? '').trim() || DEFAULT_COMPRESSION;
27
+ if (compression.toLowerCase() !== DEFAULT_COMPRESSION) {
28
+ log.warn(`${COMPRESSION}=${compression} is not supported by this host - exporting ` +
29
+ 'uncompressed (the payload is one span per request); set none to silence this');
30
+ }
31
+ if (config.exists(CONNECT_TIMEOUT)) {
32
+ log.warn(`${CONNECT_TIMEOUT} has no effect on this host - ${TIMEOUT} bounds the whole ` +
33
+ 'export, connect included');
34
+ }
35
+ const scopeVersion = config.getProperty(APP_VERSION) ?? VERSION;
36
+ // read through a supplier so a credential published AFTER this start-up read
37
+ // (a runtime override, the engines' -D / config.set analog a credential
38
+ // bootstrap uses) is picked up rather than frozen out
39
+ const headers = reloadingHeaders(() => config.getProperty(HEADERS));
40
+ return { endpoint, serviceName, scopeVersion, timeoutMs, compression, headers };
41
+ }
42
+ /** Settings with a FIXED header list - for tests and callers whose credentials are known up front. */
43
+ export function fixedSettings(endpoint, options = {}) {
44
+ const fixed = [...(options.headers ?? [])];
45
+ return {
46
+ endpoint,
47
+ serviceName: options.serviceName ?? DEFAULT_SERVICE,
48
+ scopeVersion: VERSION,
49
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
50
+ compression: DEFAULT_COMPRESSION,
51
+ headers: () => [...fixed]
52
+ };
53
+ }
54
+ /**
55
+ * A supplier that re-parses the raw header setting on every call and announces
56
+ * the header NAMES once when they first resolve (never values).
57
+ */
58
+ export function reloadingHeaders(raw) {
59
+ let announced = false;
60
+ return () => {
61
+ const headers = parseHeaders(raw());
62
+ if (headers.length && !announced) {
63
+ announced = true;
64
+ log.info(`OTLP credential header resolved - ${JSON.stringify(headers.map(([k]) => k))}`);
65
+ }
66
+ return headers;
67
+ };
68
+ }
69
+ /**
70
+ * The OpenTelemetry `key=value,key2=value2` list, also accepting the `key: value`
71
+ * form; the FIRST separator splits, so a token containing `=` or `:` survives; a
72
+ * repeated name keeps its last value.
73
+ */
74
+ export function parseHeaders(raw) {
75
+ const out = [];
76
+ if (raw === undefined || !raw.trim() || raw.trim() === 'null') {
77
+ return out;
78
+ }
79
+ for (const pair of raw.split(',')) {
80
+ const sep = firstSeparator(pair);
81
+ if (sep === undefined)
82
+ continue;
83
+ const key = pair.slice(0, sep).trim();
84
+ const value = pair.slice(sep + 1).trim();
85
+ if (!key)
86
+ continue;
87
+ const existing = out.findIndex(([k]) => k === key);
88
+ if (existing >= 0) {
89
+ out[existing] = [key, value];
90
+ }
91
+ else {
92
+ out.push([key, value]);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ function firstSeparator(pair) {
98
+ const eq = pair.indexOf('=');
99
+ const colon = pair.indexOf(':');
100
+ let sep;
101
+ if (eq < 0 && colon < 0)
102
+ return undefined;
103
+ if (eq < 0)
104
+ sep = colon;
105
+ else if (colon < 0)
106
+ sep = eq;
107
+ else
108
+ sep = Math.min(eq, colon);
109
+ return sep > 0 ? sep : undefined;
110
+ }
111
+ function millis(value, key, fallback) {
112
+ if (value === undefined)
113
+ return fallback;
114
+ const ms = Number.parseInt(value.trim(), 10);
115
+ if (Number.isFinite(ms) && ms > 0)
116
+ return ms;
117
+ log.warn(`${key}=${value} is not a positive number of milliseconds - using ${fallback}`);
118
+ return fallback;
119
+ }
@@ -0,0 +1,44 @@
1
+ import { ForwarderSettings } from './config.js';
2
+ import { Span } from './span.js';
3
+ export declare const INSTRUMENTATION_SCOPE = "mercury-composable-nodejs";
4
+ /** Statuses worth another attempt (the OpenTelemetry SDK's set, plus 408). */
5
+ export declare const RETRYABLE_STATUSES: number[];
6
+ /** The waits between attempts: 5 attempts, 1 s x 1.5^n. */
7
+ export declare const DEFAULT_BACKOFF_MS: number[];
8
+ /** Why an export gave up: the last attempt's diagnostic and the attempt count. */
9
+ export declare class ExportFailure extends Error {
10
+ readonly attempts: number;
11
+ readonly detail: string;
12
+ constructor(attempts: number, detail: string);
13
+ }
14
+ /**
15
+ * The endpoint must be an http(s) URL with a host - checked at start-up so a
16
+ * misconfiguration surfaces before the first span.
17
+ */
18
+ export declare function validateEndpoint(url: string): string;
19
+ /** The OTLP/HTTP exporter for one endpoint. */
20
+ export declare class Exporter {
21
+ readonly endpoint: string;
22
+ readonly serviceName: string;
23
+ readonly scopeVersion: string;
24
+ readonly compression: string;
25
+ readonly timeoutMs: number;
26
+ private readonly headers;
27
+ private readonly backoffMs;
28
+ constructor(settings: ForwarderSettings, backoffMs?: number[]);
29
+ /** The names of the request headers that resolve right now (values are never exposed). */
30
+ headerNames(): string[];
31
+ /** The OTLP request body for one span. */
32
+ encode(span: Span): Uint8Array;
33
+ /**
34
+ * Export one span, retrying transient failures on the backoff schedule; throws
35
+ * `ExportFailure` when the attempts are exhausted or the backend's answer is final.
36
+ */
37
+ export(span: Span): Promise<void>;
38
+ private attempt;
39
+ }
40
+ /**
41
+ * Render an HTTP rejection so it is actionable from one log line: the status, the
42
+ * backend's own explanation (bounded), and a hint for the usual causes.
43
+ */
44
+ export declare function describeHttpFailure(status: number, body: string): string;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The OTLP/HTTP export: one request per span through the runtime's fetch, with
3
+ * the engines' retry policy and failure diagnostics (the Rust port's `export.rs`,
4
+ * the Java `OtelForwarderContext`).
5
+ *
6
+ * Retry: telemetry delivery is at-least-once by design - duplicates are tolerated,
7
+ * drops are what hurt - so a transport failure (connect refused, TLS, a killed
8
+ * keep-alive, a timeout) and the retryable HTTP statuses (408, 429, 502, 503, 504)
9
+ * are retried on the OpenTelemetry SDK's default bounded backoff: 5 attempts, 1 s
10
+ * growing by 1.5x. Any other status fails at once - a 401 will not get better by
11
+ * waiting.
12
+ *
13
+ * Diagnostics: a rejected export is actionable from the forwarder's own warning
14
+ * line: the status leads, the backend's response body follows (whitespace-collapsed,
15
+ * bounded), and the rejections that actually happen get a hint. Request headers are
16
+ * never rendered, so no credential can reach the log.
17
+ */
18
+ import { getLogger } from '../log.js';
19
+ import { ENDPOINT } from './config.js';
20
+ import { encodeExportRequest, partialSuccess } from './otlp.js';
21
+ import { hex } from './span.js';
22
+ const log = getLogger('mercury.otel');
23
+ export const INSTRUMENTATION_SCOPE = 'mercury-composable-nodejs';
24
+ /** Statuses worth another attempt (the OpenTelemetry SDK's set, plus 408). */
25
+ export const RETRYABLE_STATUSES = [408, 429, 502, 503, 504];
26
+ /** The waits between attempts: 5 attempts, 1 s x 1.5^n. */
27
+ export const DEFAULT_BACKOFF_MS = [1000, 1500, 2250, 3375];
28
+ const CONTENT_TYPE = 'application/x-protobuf';
29
+ const MAX_BODY_CHARS = 256;
30
+ /** Why an export gave up: the last attempt's diagnostic and the attempt count. */
31
+ export class ExportFailure extends Error {
32
+ attempts;
33
+ detail;
34
+ constructor(attempts, detail) {
35
+ super(attempts > 1 ? `${detail} (after ${attempts} attempts)` : detail);
36
+ this.attempts = attempts;
37
+ this.detail = detail;
38
+ this.name = 'ExportFailure';
39
+ }
40
+ }
41
+ class AttemptError extends Error {
42
+ retryable;
43
+ detail;
44
+ constructor(retryable, detail) {
45
+ super(detail);
46
+ this.retryable = retryable;
47
+ this.detail = detail;
48
+ }
49
+ }
50
+ /**
51
+ * The endpoint must be an http(s) URL with a host - checked at start-up so a
52
+ * misconfiguration surfaces before the first span.
53
+ */
54
+ export function validateEndpoint(url) {
55
+ const trimmed = url.trim();
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(trimmed);
59
+ }
60
+ catch {
61
+ parsed = undefined;
62
+ }
63
+ if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || !parsed.host) {
64
+ throw new Error(`${ENDPOINT}='${url}' must be an http(s) URL including the signal path, ` +
65
+ 'e.g. http://localhost:4318/v1/traces');
66
+ }
67
+ return trimmed;
68
+ }
69
+ /** The OTLP/HTTP exporter for one endpoint. */
70
+ export class Exporter {
71
+ endpoint;
72
+ serviceName;
73
+ scopeVersion;
74
+ compression;
75
+ timeoutMs;
76
+ headers;
77
+ backoffMs;
78
+ constructor(settings, backoffMs = DEFAULT_BACKOFF_MS) {
79
+ this.endpoint = validateEndpoint(settings.endpoint);
80
+ this.serviceName = settings.serviceName;
81
+ this.scopeVersion = settings.scopeVersion;
82
+ this.compression = settings.compression;
83
+ this.timeoutMs = settings.timeoutMs;
84
+ this.headers = settings.headers;
85
+ this.backoffMs = [...backoffMs];
86
+ }
87
+ /** The names of the request headers that resolve right now (values are never exposed). */
88
+ headerNames() {
89
+ return this.headers().map(([k]) => k);
90
+ }
91
+ /** The OTLP request body for one span. */
92
+ encode(span) {
93
+ return encodeExportRequest(this.serviceName, INSTRUMENTATION_SCOPE, this.scopeVersion, span);
94
+ }
95
+ /**
96
+ * Export one span, retrying transient failures on the backoff schedule; throws
97
+ * `ExportFailure` when the attempts are exhausted or the backend's answer is final.
98
+ */
99
+ async export(span) {
100
+ const body = this.encode(span);
101
+ let attempt = 0;
102
+ for (;;) {
103
+ attempt += 1;
104
+ try {
105
+ await this.attempt(body);
106
+ return;
107
+ }
108
+ catch (e) {
109
+ const failure = e;
110
+ if (failure.retryable && attempt <= this.backoffMs.length) {
111
+ log.debug(`OTLP export attempt ${attempt} for span ${hex(span.spanId)} failed ` +
112
+ `(${failure.detail}) - retrying`);
113
+ await new Promise((resolve) => setTimeout(resolve, this.backoffMs[attempt - 1]).unref());
114
+ continue;
115
+ }
116
+ throw new ExportFailure(attempt, failure.detail);
117
+ }
118
+ }
119
+ }
120
+ async attempt(body) {
121
+ const headers = { 'content-type': CONTENT_TYPE, accept: CONTENT_TYPE };
122
+ // headers are resolved per export, never baked in: a credential published
123
+ // after start-up takes effect without a restart
124
+ for (const [name, value] of this.headers()) {
125
+ headers[name] = value;
126
+ }
127
+ let response;
128
+ let payload;
129
+ try {
130
+ response = await fetch(this.endpoint, {
131
+ method: 'POST', headers, body: body,
132
+ signal: AbortSignal.timeout(this.timeoutMs)
133
+ });
134
+ payload = new Uint8Array(await response.arrayBuffer());
135
+ }
136
+ catch (e) {
137
+ // the transport failed before an HTTP answer (fetch failed, timeout, TLS) -
138
+ // retried like the Java exporter retries every IOException
139
+ const error = e;
140
+ const detail = error.cause?.message ?? error.message ?? String(e);
141
+ throw new AttemptError(true, detail);
142
+ }
143
+ classify(response.status, payload);
144
+ }
145
+ }
146
+ function classify(status, payload) {
147
+ if (status >= 200 && status < 300) {
148
+ const partial = partialSuccess(payload);
149
+ if (partial) {
150
+ log.warn(`OTLP backend accepted the request but rejected ${partial.rejectedSpans} span(s) - ` +
151
+ partial.errorMessage);
152
+ }
153
+ return;
154
+ }
155
+ const text = new TextDecoder('utf-8').decode(payload);
156
+ throw new AttemptError(RETRYABLE_STATUSES.includes(status), describeHttpFailure(status, text));
157
+ }
158
+ /**
159
+ * Render an HTTP rejection so it is actionable from one log line: the status, the
160
+ * backend's own explanation (bounded), and a hint for the usual causes.
161
+ */
162
+ export function describeHttpFailure(status, body) {
163
+ let text = `HTTP ${status}`;
164
+ const collapsed = collapse(body);
165
+ if (collapsed)
166
+ text += ` - ${collapsed}`;
167
+ const hint = HINTS[status];
168
+ if (hint)
169
+ text += ` | ${hint}`;
170
+ return text;
171
+ }
172
+ const HINTS = {
173
+ 404: 'check otel.exporter.otlp.endpoint includes the signal path (e.g. .../v1/traces), ' +
174
+ 'not just the vendor base URL',
175
+ 401: 'the backend rejected the credential itself - check otel.exporter.otlp.headers (the ' +
176
+ 'header name and any auth scheme must match what the backend expects)',
177
+ 403: 'the credential was accepted but lacks permission - grant the trace-ingest scope on ' +
178
+ 'the token (the response body above names it)',
179
+ 413: 'the backend rejected the payload as too large',
180
+ 429: 'the backend is rate-limiting; the exporter retries with backoff'
181
+ };
182
+ function collapse(body) {
183
+ const collapsed = body.split(/\s+/).filter(Boolean).join(' ');
184
+ return collapsed.length > MAX_BODY_CHARS ? `${collapsed.slice(0, MAX_BODY_CHARS)}...` : collapsed;
185
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The OpenTelemetry trace forwarder - the engines' `opentelemetry-forwarder`
3
+ * extension for this host, opt-in and dependency-free.
4
+ *
5
+ * Every traced, non-RPC execution already emits the engines' distributed-trace
6
+ * dataset on the `distributed.tracing` log stream. With `otel.forwarding=true` the
7
+ * host ALSO hands each dataset to a function on the engines' extension route
8
+ * `distributed.trace.forwarder`; the built-in forwarder registered there maps the
9
+ * dataset to one OpenTelemetry span carrying the host's exact W3C ids and exports
10
+ * it over OTLP/HTTP (protobuf) to the configured endpoint - Dynatrace, Splunk, an
11
+ * OpenTelemetry Collector - so one trace spans the engines and the polyglot
12
+ * functions they call. Off by default: the switch is the only thing that turns it on.
13
+ */
14
+ import type { AppConfig } from '../config.js';
15
+ import type { FunctionRegistry } from '../registry.js';
16
+ import { Exporter } from './export.js';
17
+ export { DISTRIBUTED_TRACE_FORWARDER } from '../trace.js';
18
+ export { FORWARDING_SWITCH, fixedSettings, parseHeaders, settingsFromConfig } from './config.js';
19
+ export type { ForwarderSettings, HeaderSupplier } from './config.js';
20
+ export { describeHttpFailure, Exporter, ExportFailure, INSTRUMENTATION_SCOPE } from './export.js';
21
+ export { attribute, parseIso8601Nanos, spanFromDataset } from './span.js';
22
+ export type { AttributeValue, Span } from './span.js';
23
+ export { encodeExportRequest, partialSuccess, ProtoReader, ProtoWriter, SPAN_FLAGS_SAMPLED_LOCAL } from './otlp.js';
24
+ /** The engines run the forwarder with two workers. */
25
+ export declare const FORWARDER_INSTANCES = 2;
26
+ /**
27
+ * The `distributed.trace.forwarder` function bound to one exporter: map the
28
+ * dataset to a span and export it, logging - never throwing - a failure.
29
+ */
30
+ export declare function forwarder(exporter: Exporter): (headers: Record<string, string>, body: unknown) => Promise<void>;
31
+ /** Whether `otel.forwarding` is switched on (the text `true`, case-insensitive). */
32
+ export declare function enabled(config: AppConfig): boolean;
33
+ /**
34
+ * Register the built-in forwarder on `distributed.trace.forwarder` when
35
+ * `otel.forwarding=true` - the host's start-up hook. Returns the exporter, or
36
+ * undefined when forwarding is off or a function already occupies the route (an
37
+ * application's own forwarder wins, as in the engines). A misconfigured endpoint
38
+ * fails the start.
39
+ */
40
+ export declare function activate(config: AppConfig, registry: FunctionRegistry): Exporter | undefined;