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 +14 -7
- package/dist/src/bus.d.ts +8 -1
- package/dist/src/bus.js +19 -6
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/otel/config.d.ts +58 -0
- package/dist/src/otel/config.js +119 -0
- package/dist/src/otel/export.d.ts +44 -0
- package/dist/src/otel/export.js +185 -0
- package/dist/src/otel/index.d.ts +40 -0
- package/dist/src/otel/index.js +65 -0
- package/dist/src/otel/otlp.d.ts +104 -0
- package/dist/src/otel/otlp.js +358 -0
- package/dist/src/otel/span.d.ts +67 -0
- package/dist/src/otel/span.js +217 -0
- package/dist/src/server.js +4 -0
- package/dist/src/trace.d.ts +1 -0
- package/dist/src/trace.js +3 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +2 -2
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
|
-
> **
|
|
28
|
-
>
|
|
29
|
-
>
|
|
30
|
-
>
|
|
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
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|