@gobing-ai/ts-infra 0.3.9 → 0.3.11
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 +96 -22
- package/dist/api-client.d.ts +44 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +144 -22
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +3 -3
- package/dist/application/plugins/builtins.d.ts.map +1 -1
- package/dist/application/plugins/builtins.js +9 -7
- package/dist/application/types.d.ts +1 -1
- package/dist/application-node.d.ts +18 -2
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +12 -7
- package/dist/event-bus/event-bus.d.ts +22 -2
- package/dist/event-bus/event-bus.d.ts.map +1 -1
- package/dist/event-bus/event-bus.js +63 -3
- package/dist/event-bus/index.d.ts +1 -1
- package/dist/event-bus/index.d.ts.map +1 -1
- package/dist/event-bus/types.d.ts +18 -0
- package/dist/event-bus/types.d.ts.map +1 -1
- package/dist/events.d.ts +5 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/job-queue/db-job-queue.d.ts +5 -1
- package/dist/job-queue/db-job-queue.d.ts.map +1 -1
- package/dist/job-queue/db-job-queue.js +55 -3
- package/dist/job-queue/types.d.ts +8 -0
- package/dist/job-queue/types.d.ts.map +1 -1
- package/dist/scheduler/action.js +1 -1
- package/dist/scheduler/node.d.ts.map +1 -1
- package/dist/scheduler/node.js +21 -11
- package/dist/scheduler/wrap-handler.js +1 -1
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +21 -1
- package/dist/telemetry/sdk.d.ts +7 -2
- package/dist/telemetry/sdk.d.ts.map +1 -1
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +18 -2
- package/package.json +5 -5
- package/src/api-client.ts +212 -24
- package/src/application/index.ts +4 -3
- package/src/application/plugins/builtins.ts +9 -7
- package/src/application/types.ts +1 -1
- package/src/application-node.ts +33 -10
- package/src/event-bus/event-bus.ts +72 -5
- package/src/event-bus/index.ts +1 -0
- package/src/event-bus/types.ts +19 -0
- package/src/events.ts +5 -2
- package/src/index.ts +9 -1
- package/src/job-queue/db-job-queue.ts +59 -4
- package/src/job-queue/types.ts +9 -0
- package/src/scheduler/action.ts +1 -1
- package/src/scheduler/node.ts +20 -12
- package/src/scheduler/wrap-handler.ts +1 -1
- package/src/telemetry/metrics.ts +20 -1
- package/src/telemetry/sdk.ts +7 -2
- package/src/telemetry/tracing.ts +20 -2
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* OpenTelemetry metrics — lazy-initialized instruments.
|
|
3
3
|
* All degrade to no-ops when telemetry is disabled.
|
|
4
4
|
*/
|
|
5
|
-
import { metrics } from '@opentelemetry/api';
|
|
5
|
+
import { createNoopMeter, metrics } from '@opentelemetry/api';
|
|
6
|
+
import { getResolvedConfig } from './sdk.js';
|
|
6
7
|
let metricsInitialized = false;
|
|
7
8
|
/** Whether the metrics subsystem has been initialized via {@link initMetrics}. */
|
|
8
9
|
export function isMetricsInitialized() {
|
|
@@ -16,13 +17,32 @@ function getMeter() {
|
|
|
16
17
|
}
|
|
17
18
|
// ── Instrument cache ────────────────────────────────────────────────
|
|
18
19
|
const instruments = {};
|
|
20
|
+
// Master switch (`TelemetryConfig.enabled`): when explicitly disabled, getters
|
|
21
|
+
// return shared no-op instruments WITHOUT touching the cache, so a later
|
|
22
|
+
// re-enable rebuilds real instruments against the live meter.
|
|
23
|
+
let _noopCounter;
|
|
24
|
+
let _noopHistogram;
|
|
25
|
+
function noopCounter() {
|
|
26
|
+
if (!_noopCounter)
|
|
27
|
+
_noopCounter = createNoopMeter().createCounter('noop');
|
|
28
|
+
return _noopCounter;
|
|
29
|
+
}
|
|
30
|
+
function noopHistogram() {
|
|
31
|
+
if (!_noopHistogram)
|
|
32
|
+
_noopHistogram = createNoopMeter().createHistogram('noop');
|
|
33
|
+
return _noopHistogram;
|
|
34
|
+
}
|
|
19
35
|
function getOrCreateCounter(key, name, description, unit = '{operation}') {
|
|
36
|
+
if (!getResolvedConfig().enabled)
|
|
37
|
+
return noopCounter();
|
|
20
38
|
if (!instruments[key]) {
|
|
21
39
|
instruments[key] = getMeter().createCounter(name, { description, unit });
|
|
22
40
|
}
|
|
23
41
|
return instruments[key];
|
|
24
42
|
}
|
|
25
43
|
function getOrCreateHistogram(key, name, description, unit = 'ms') {
|
|
44
|
+
if (!getResolvedConfig().enabled)
|
|
45
|
+
return noopHistogram();
|
|
26
46
|
if (!instruments[key]) {
|
|
27
47
|
instruments[key] = getMeter().createHistogram(name, { description, unit });
|
|
28
48
|
}
|
package/dist/telemetry/sdk.d.ts
CHANGED
|
@@ -13,7 +13,11 @@ import { type Tracer } from '@opentelemetry/api';
|
|
|
13
13
|
* environment, and debug-level DB statement capture.
|
|
14
14
|
*/
|
|
15
15
|
export interface TelemetryConfig {
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Master switch — when false, infra-created spans and metric instruments
|
|
18
|
+
* degrade to no-ops even if a global OTel provider is registered.
|
|
19
|
+
* Explicitly injected tracer ports (ADR-009 addendum) are unaffected.
|
|
20
|
+
*/
|
|
17
21
|
enabled: boolean;
|
|
18
22
|
/** Logical service name emitted on every span. */
|
|
19
23
|
serviceName: string;
|
|
@@ -26,7 +30,8 @@ export interface TelemetryConfig {
|
|
|
26
30
|
* attribute. SQL text is redacted — parameter values, literals, and
|
|
27
31
|
* identifiers are stripped before capture.
|
|
28
32
|
*
|
|
29
|
-
* Default: `false`.
|
|
33
|
+
* Default: `false`. Set via config — ts-infra core never reads env vars
|
|
34
|
+
* (ADR-011); map an env var to this flag in your bootstrap if desired.
|
|
30
35
|
*/
|
|
31
36
|
dbStatementDebug: boolean;
|
|
32
37
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sdk.d.ts","sourceRoot":"","sources":["../../src/telemetry/sdk.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,KAAK,MAAM,EAAS,MAAM,oBAAoB,CAAC;AAIxD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC5B
|
|
1
|
+
{"version":3,"file":"sdk.d.ts","sourceRoot":"","sources":["../../src/telemetry/sdk.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,KAAK,MAAM,EAAS,MAAM,oBAAoB,CAAC;AAIxD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC5B;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,OAAO,CAAC;CAC7B;AAED,mEAAmE;AACnE,MAAM,WAAW,sBAAsB;IACnC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/B;AAQD,qFAAqF;AACrF,wBAAgB,kBAAkB,CAAC,aAAa,GAAE,sBAA2B,GAAG,eAAe,CAO9F;AAQD,uEAAuE;AACvE,wBAAgB,iBAAiB,IAAI,eAAe,CAEnD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAIrE;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAGjD;AAED,gFAAgF;AAChF,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,oDAAoD;AACpD,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED,6EAA6E;AAC7E,wBAAgB,eAAe,IAAI,IAAI,CAGtC;AAED,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracing.d.ts","sourceRoot":"","sources":["../../src/telemetry/tracing.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"tracing.d.ts","sourceRoot":"","sources":["../../src/telemetry/tracing.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAiC,KAAK,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,MAAM,EAAS,MAAM,oBAAoB,CAAC;AAiBpH;;;GAGG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAC9B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAC9B,OAAO,CAAC,EAAE,WAAW,EACrB,MAAM,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,CAAC,CAAC,CAcZ;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,CAAC,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,CAc3G;AAED,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAK7F;AAED,uFAAuF;AACvF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAKvG;AAED,kEAAkE;AAClE,wBAAgB,aAAa,IAAI,IAAI,GAAG,SAAS,CAEhD;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEtD;AAED,YAAY,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC"}
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* High-level tracing helpers for application code.
|
|
3
3
|
*/
|
|
4
|
-
import { context, trace } from '@opentelemetry/api';
|
|
5
|
-
import { getTracer } from './sdk.js';
|
|
4
|
+
import { context, INVALID_SPAN_CONTEXT, trace } from '@opentelemetry/api';
|
|
5
|
+
import { getResolvedConfig, getTracer } from './sdk.js';
|
|
6
|
+
/**
|
|
7
|
+
* Master switch (`TelemetryConfig.enabled`): when explicitly disabled, infra
|
|
8
|
+
* helpers bypass the global provider entirely. An explicitly injected tracer
|
|
9
|
+
* (ADR-009 addendum structural port) is honored regardless — the caller opted in.
|
|
10
|
+
*/
|
|
11
|
+
function isSuppressed(tracer) {
|
|
12
|
+
return tracer === undefined && !getResolvedConfig().enabled;
|
|
13
|
+
}
|
|
14
|
+
/** A non-recording span satisfying the `Span` interface — every method is a no-op. */
|
|
15
|
+
function nonRecordingSpan() {
|
|
16
|
+
return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
17
|
+
}
|
|
6
18
|
/**
|
|
7
19
|
* Execute an async function within an active OTel span.
|
|
8
20
|
* The span is automatically ended and its status set on error.
|
|
9
21
|
*/
|
|
10
22
|
export async function traceAsync(name, fn, options, tracer) {
|
|
23
|
+
if (isSuppressed(tracer))
|
|
24
|
+
return fn(nonRecordingSpan());
|
|
11
25
|
const resolvedTracer = tracer ?? getTracer();
|
|
12
26
|
return resolvedTracer.startActiveSpan(name, options ?? {}, async (span) => {
|
|
13
27
|
try {
|
|
@@ -27,6 +41,8 @@ export async function traceAsync(name, fn, options, tracer) {
|
|
|
27
41
|
* The span is automatically ended and its status set on error.
|
|
28
42
|
*/
|
|
29
43
|
export function traceSync(name, fn, options, tracer) {
|
|
44
|
+
if (isSuppressed(tracer))
|
|
45
|
+
return fn(nonRecordingSpan());
|
|
30
46
|
const resolvedTracer = tracer ?? getTracer();
|
|
31
47
|
return resolvedTracer.startActiveSpan(name, options ?? {}, (span) => {
|
|
32
48
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-infra",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "@gobing-ai/ts-infra — Infrastructure backbone: event bus, job queue, scheduler, telemetry, API client, and logging.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"@logtape/logtape": "^2.0.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
|
-
"@gobing-ai/ts-db": "^0.3.
|
|
81
|
-
"@gobing-ai/ts-runtime": "^0.3.
|
|
80
|
+
"@gobing-ai/ts-db": "^0.3.11",
|
|
81
|
+
"@gobing-ai/ts-runtime": "^0.3.11",
|
|
82
82
|
"@opentelemetry/api": "^1.9.0",
|
|
83
83
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
84
84
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
"devDependencies": {
|
|
114
|
-
"@gobing-ai/ts-db": "^0.3.
|
|
115
|
-
"@gobing-ai/ts-runtime": "^0.3.
|
|
114
|
+
"@gobing-ai/ts-db": "^0.3.11",
|
|
115
|
+
"@gobing-ai/ts-runtime": "^0.3.11",
|
|
116
116
|
"@types/bun": "1.3.14",
|
|
117
117
|
"@opentelemetry/api": "^1.9.0",
|
|
118
118
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
package/src/api-client.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
|
8
8
|
ATTR_URL_FULL,
|
|
9
9
|
} from '@opentelemetry/semantic-conventions';
|
|
10
|
+
import type { EventBus } from './event-bus/event-bus';
|
|
11
|
+
import type { ApiClientEvents } from './events';
|
|
10
12
|
import {
|
|
11
13
|
getHttpClientRequestDuration,
|
|
12
14
|
getHttpClientRequestErrors,
|
|
@@ -22,8 +24,13 @@ export interface APIClientConfig {
|
|
|
22
24
|
defaultHeaders?: Record<string, string>;
|
|
23
25
|
timeout?: number;
|
|
24
26
|
fetch?: typeof globalThis.fetch;
|
|
27
|
+
/**
|
|
28
|
+
* Optional bus for `api.request.error` events. Emitted on network errors,
|
|
29
|
+
* timeouts, and (for the JSON methods) non-2xx responses. `rawRequest`
|
|
30
|
+
* returns non-2xx statuses normally, so it emits only on network/timeout.
|
|
31
|
+
*/
|
|
32
|
+
events?: EventBus<ApiClientEvents>;
|
|
25
33
|
}
|
|
26
|
-
|
|
27
34
|
/** Per-request overrides: headers, timeout, operation name, and abort signal. */
|
|
28
35
|
export interface RequestOptions {
|
|
29
36
|
headers?: Record<string, string>;
|
|
@@ -32,7 +39,25 @@ export interface RequestOptions {
|
|
|
32
39
|
signal?: AbortSignal;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/**
|
|
42
|
+
/** Options for {@link APIClient.rawRequest}: extends RequestOptions with redirect policy and response-size cap. */
|
|
43
|
+
export interface RawRequestOptions extends RequestOptions {
|
|
44
|
+
/** Redirect policy (default `'manual'`). */
|
|
45
|
+
redirect?: 'follow' | 'error' | 'manual';
|
|
46
|
+
maxResponseBytes?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raw HTTP response returned by {@link APIClient.rawRequest} for all status codes. */
|
|
50
|
+
export interface RawHttpResponse {
|
|
51
|
+
status: number;
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
body: string;
|
|
54
|
+
/** True when `maxResponseBytes` capped the body. Omitted when no cap was applied. */
|
|
55
|
+
truncated?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* HTTP error with status code and response body text.
|
|
59
|
+
* Timeouts are reported with `status === 0` (no response was received).
|
|
60
|
+
*/
|
|
36
61
|
export class APIError extends Error {
|
|
37
62
|
constructor(
|
|
38
63
|
public readonly status: number,
|
|
@@ -43,6 +68,52 @@ export class APIError extends Error {
|
|
|
43
68
|
}
|
|
44
69
|
}
|
|
45
70
|
|
|
71
|
+
// ── Body reading ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a response body capped at `maxResponseBytes` (UTF-8 bytes). Returns the
|
|
75
|
+
* decoded text and whether the body was truncated. Falls back to `text()` with
|
|
76
|
+
* a character cap when the response exposes no readable stream.
|
|
77
|
+
*/
|
|
78
|
+
async function readBodyCapped(
|
|
79
|
+
response: Response,
|
|
80
|
+
maxResponseBytes: number,
|
|
81
|
+
): Promise<{ text: string; truncated?: boolean }> {
|
|
82
|
+
const reader = response.body?.getReader();
|
|
83
|
+
if (!reader) {
|
|
84
|
+
const full = await response.text();
|
|
85
|
+
if (full.length > maxResponseBytes) {
|
|
86
|
+
return { text: full.slice(0, maxResponseBytes), truncated: true };
|
|
87
|
+
}
|
|
88
|
+
return { text: full };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const chunks: string[] = [];
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let total = 0;
|
|
94
|
+
let truncated = false;
|
|
95
|
+
while (true) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done) break;
|
|
98
|
+
const remaining = maxResponseBytes - total;
|
|
99
|
+
if (value.length > remaining) {
|
|
100
|
+
// The streaming decoder holds back a trailing partial multibyte
|
|
101
|
+
// sequence, which is then dropped — the cap is a byte budget.
|
|
102
|
+
chunks.push(decoder.decode(value.slice(0, remaining), { stream: true }));
|
|
103
|
+
truncated = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
107
|
+
total += value.length;
|
|
108
|
+
}
|
|
109
|
+
if (truncated) {
|
|
110
|
+
// Stop the underlying stream instead of letting it keep downloading.
|
|
111
|
+
await reader.cancel().catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
reader.releaseLock();
|
|
114
|
+
return { text: chunks.join(''), ...(truncated ? { truncated } : {}) };
|
|
115
|
+
}
|
|
116
|
+
|
|
46
117
|
// ── Client ──────────────────────────────────────────────────────────
|
|
47
118
|
|
|
48
119
|
/**
|
|
@@ -60,26 +131,43 @@ export class APIClient {
|
|
|
60
131
|
private readonly defaultHeaders: Record<string, string>;
|
|
61
132
|
private readonly timeout: number;
|
|
62
133
|
private readonly fetchFn: typeof globalThis.fetch;
|
|
134
|
+
private readonly events: EventBus<ApiClientEvents> | undefined;
|
|
63
135
|
|
|
64
136
|
constructor(config: APIClientConfig) {
|
|
65
137
|
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
66
138
|
this.defaultHeaders = config.defaultHeaders ?? {};
|
|
67
139
|
this.timeout = config.timeout ?? 30_000;
|
|
68
140
|
this.fetchFn = config.fetch ?? globalThis.fetch;
|
|
141
|
+
this.events = config.events;
|
|
69
142
|
}
|
|
70
143
|
|
|
71
144
|
private buildUrl(path: string): string {
|
|
72
145
|
return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
73
146
|
}
|
|
74
147
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
...
|
|
80
|
-
|
|
81
|
-
};
|
|
148
|
+
private emitRequestError(method: string, url: string, error: string, status?: number): void {
|
|
149
|
+
void this.events?.emit('api.request.error', {
|
|
150
|
+
url,
|
|
151
|
+
method,
|
|
152
|
+
...(status !== undefined ? { status } : {}),
|
|
153
|
+
error,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
82
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Shared request lifecycle: span + attributes, timeout/abort wiring,
|
|
159
|
+
* total/duration/error metrics, the fetch call, and timeout-vs-abort error
|
|
160
|
+
* mapping. `consume` owns response handling (parse/throw policy).
|
|
161
|
+
*/
|
|
162
|
+
private async runRequest<T>(
|
|
163
|
+
method: string,
|
|
164
|
+
url: string,
|
|
165
|
+
headers: Record<string, string>,
|
|
166
|
+
body: string | undefined,
|
|
167
|
+
opts: RequestOptions | undefined,
|
|
168
|
+
redirect: RawRequestOptions['redirect'] | undefined,
|
|
169
|
+
consume: (response: Response, span: Span) => Promise<T>,
|
|
170
|
+
): Promise<T> {
|
|
83
171
|
const operationName = opts?.operationName ?? `HTTP ${method} ${url}`;
|
|
84
172
|
|
|
85
173
|
return traceAsync(
|
|
@@ -106,8 +194,9 @@ export class APIClient {
|
|
|
106
194
|
const response = await this.fetchFn(url, {
|
|
107
195
|
method,
|
|
108
196
|
headers,
|
|
109
|
-
body
|
|
197
|
+
body,
|
|
110
198
|
signal: combinedSignal,
|
|
199
|
+
...(redirect ? { redirect } : {}),
|
|
111
200
|
});
|
|
112
201
|
|
|
113
202
|
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
|
|
@@ -116,35 +205,40 @@ export class APIClient {
|
|
|
116
205
|
'http.request.method': method,
|
|
117
206
|
'http.response.status_code': response.status,
|
|
118
207
|
});
|
|
119
|
-
|
|
120
|
-
const duration = performance.now() - start;
|
|
121
|
-
getHttpClientRequestDuration().record(duration, {
|
|
208
|
+
getHttpClientRequestDuration().record(performance.now() - start, {
|
|
122
209
|
'http.request.method': method,
|
|
123
210
|
'http.response.status_code': response.status,
|
|
124
211
|
});
|
|
125
212
|
|
|
126
|
-
|
|
127
|
-
|
|
213
|
+
return await consume(response, span);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Timeout (our own AbortController fired) — but never relabel a
|
|
216
|
+
// caller-initiated abort via opts.signal as a timeout.
|
|
217
|
+
if (error instanceof DOMException && error.name === 'AbortError' && !opts?.signal?.aborted) {
|
|
128
218
|
getHttpClientRequestErrors().add(1, {
|
|
129
219
|
'http.request.method': method,
|
|
130
|
-
'error.type':
|
|
220
|
+
'error.type': 'Timeout',
|
|
131
221
|
});
|
|
132
|
-
|
|
222
|
+
const timeoutError = new APIError(
|
|
223
|
+
0,
|
|
224
|
+
`Request timed out after ${timeoutMs}ms: ${method} ${url}`,
|
|
225
|
+
);
|
|
226
|
+
this.emitRequestError(method, url, timeoutError.message);
|
|
227
|
+
throw timeoutError;
|
|
133
228
|
}
|
|
134
229
|
|
|
135
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
136
|
-
if (contentType.includes('application/json')) {
|
|
137
|
-
return (await response.json()) as T;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return (await response.text()) as unknown as T;
|
|
141
|
-
} catch (error) {
|
|
142
230
|
if (!(error instanceof APIError)) {
|
|
143
231
|
getHttpClientRequestErrors().add(1, {
|
|
144
232
|
'http.request.method': method,
|
|
145
233
|
'error.type': error instanceof Error ? error.name : 'Unknown',
|
|
146
234
|
});
|
|
147
235
|
}
|
|
236
|
+
this.emitRequestError(
|
|
237
|
+
method,
|
|
238
|
+
url,
|
|
239
|
+
error instanceof Error ? error.message : String(error),
|
|
240
|
+
error instanceof APIError && error.status > 0 ? error.status : undefined,
|
|
241
|
+
);
|
|
148
242
|
|
|
149
243
|
throw error;
|
|
150
244
|
} finally {
|
|
@@ -155,6 +249,96 @@ export class APIClient {
|
|
|
155
249
|
);
|
|
156
250
|
}
|
|
157
251
|
|
|
252
|
+
private async request<T>(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
|
253
|
+
const url = this.buildUrl(path);
|
|
254
|
+
const headers: Record<string, string> = {
|
|
255
|
+
'Content-Type': 'application/json',
|
|
256
|
+
...this.defaultHeaders,
|
|
257
|
+
...opts?.headers,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return this.runRequest<T>(
|
|
261
|
+
method,
|
|
262
|
+
url,
|
|
263
|
+
headers,
|
|
264
|
+
body !== undefined ? JSON.stringify(body) : undefined,
|
|
265
|
+
opts,
|
|
266
|
+
undefined,
|
|
267
|
+
async (response) => {
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
const text = await response.text();
|
|
270
|
+
getHttpClientRequestErrors().add(1, {
|
|
271
|
+
'http.request.method': method,
|
|
272
|
+
'error.type': `HTTP_${response.status}`,
|
|
273
|
+
});
|
|
274
|
+
throw new APIError(response.status, text);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
278
|
+
if (contentType.includes('application/json')) {
|
|
279
|
+
return (await response.json()) as T;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (await response.text()) as unknown as T;
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Make a raw HTTP request returning status, headers, and body for ALL status codes.
|
|
289
|
+
* Never throws on HTTP status codes (2xx, 4xx, 5xx all return a {@link RawHttpResponse}).
|
|
290
|
+
* Throws only on network/timeout errors.
|
|
291
|
+
*
|
|
292
|
+
* Deliberately does NOT force Content-Type: application/json — callers pass raw
|
|
293
|
+
* string bodies and set their own content-type header.
|
|
294
|
+
*/
|
|
295
|
+
async rawRequest(method: string, path: string, body?: string, opts?: RawRequestOptions): Promise<RawHttpResponse> {
|
|
296
|
+
const url = this.buildUrl(path);
|
|
297
|
+
const headers: Record<string, string> = {
|
|
298
|
+
...this.defaultHeaders,
|
|
299
|
+
...opts?.headers,
|
|
300
|
+
};
|
|
301
|
+
const redirect = opts?.redirect ?? 'manual';
|
|
302
|
+
const maxResponseBytes = opts?.maxResponseBytes;
|
|
303
|
+
|
|
304
|
+
return this.runRequest<RawHttpResponse>(
|
|
305
|
+
method,
|
|
306
|
+
url,
|
|
307
|
+
headers,
|
|
308
|
+
body ?? undefined,
|
|
309
|
+
opts,
|
|
310
|
+
redirect,
|
|
311
|
+
async (response) => {
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
getHttpClientRequestErrors().add(1, {
|
|
314
|
+
'http.request.method': method,
|
|
315
|
+
'error.type': `HTTP_${response.status}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let text: string;
|
|
320
|
+
let truncated: boolean | undefined;
|
|
321
|
+
if (maxResponseBytes !== undefined) {
|
|
322
|
+
({ text, truncated } = await readBodyCapped(response, maxResponseBytes));
|
|
323
|
+
} else {
|
|
324
|
+
text = await response.text();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const responseHeaders: Record<string, string> = {};
|
|
328
|
+
response.headers.forEach((value, key) => {
|
|
329
|
+
responseHeaders[key] = value;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
status: response.status,
|
|
334
|
+
headers: responseHeaders,
|
|
335
|
+
body: text,
|
|
336
|
+
...(truncated !== undefined ? { truncated } : {}),
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
158
342
|
async get<T>(path: string, opts?: RequestOptions): Promise<T> {
|
|
159
343
|
return this.request<T>('GET', path, undefined, opts);
|
|
160
344
|
}
|
|
@@ -167,6 +351,10 @@ export class APIClient {
|
|
|
167
351
|
return this.request<T>('PUT', path, body, opts);
|
|
168
352
|
}
|
|
169
353
|
|
|
354
|
+
async patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
|
355
|
+
return this.request<T>('PATCH', path, body, opts);
|
|
356
|
+
}
|
|
357
|
+
|
|
170
358
|
async delete<T>(path: string, opts?: RequestOptions): Promise<T> {
|
|
171
359
|
return this.request<T>('DELETE', path, undefined, opts);
|
|
172
360
|
}
|
package/src/application/index.ts
CHANGED
|
@@ -143,9 +143,10 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
143
143
|
attachDefaultObservers(lifecycleBus);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const events =
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
const events =
|
|
147
|
+
options.services?.events ??
|
|
148
|
+
options.config?.events?.bus ??
|
|
149
|
+
new EventBus<TEvents>({ lifecycleBus: lifecycleBus as EventBus<BusLifecycleEvents> | undefined });
|
|
149
150
|
|
|
150
151
|
// ── 4. Database (injected only) ────────────────────────────────
|
|
151
152
|
const db: DbAdapterLike | undefined = options.services?.db;
|
|
@@ -31,21 +31,23 @@ export function telemetryPlugin(config: ApplicationBootstrapConfig['telemetry'])
|
|
|
31
31
|
failFast: true,
|
|
32
32
|
onLoad: async () => {},
|
|
33
33
|
onStart: async () => {
|
|
34
|
+
// Always record the resolved config — `enabled: false` must reach the
|
|
35
|
+
// master switch so infra spans/instruments actually go quiet.
|
|
36
|
+
initTelemetry({
|
|
37
|
+
enabled: config.enabled,
|
|
38
|
+
serviceName: config.serviceName,
|
|
39
|
+
environment: config.environment,
|
|
40
|
+
dbStatementDebug: config.dbStatementDebug,
|
|
41
|
+
});
|
|
34
42
|
if (config.enabled) {
|
|
35
|
-
initTelemetry({
|
|
36
|
-
enabled: config.enabled,
|
|
37
|
-
serviceName: config.serviceName,
|
|
38
|
-
environment: config.environment,
|
|
39
|
-
dbStatementDebug: config.dbStatementDebug,
|
|
40
|
-
});
|
|
41
43
|
initMetrics();
|
|
42
44
|
}
|
|
43
45
|
},
|
|
44
46
|
onStop: async () => {
|
|
45
47
|
if (config.enabled) {
|
|
46
48
|
shutdownMetrics();
|
|
47
|
-
await shutdownTelemetry();
|
|
48
49
|
}
|
|
50
|
+
await shutdownTelemetry();
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
}
|
package/src/application/types.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface SchedulerOptions {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Fully-resolved bootstrap config (all optionals filled with defaults).
|
|
78
|
-
* Constructed internally by `
|
|
78
|
+
* Constructed internally by `runApplication`.
|
|
79
79
|
*/
|
|
80
80
|
export interface ApplicationBootstrapConfig {
|
|
81
81
|
readonly logging: Readonly<
|
package/src/application-node.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
ConfigValidationResult,
|
|
34
34
|
DbAdapterLike,
|
|
35
35
|
EventMap,
|
|
36
|
+
EventsOptions,
|
|
36
37
|
InfraEvents,
|
|
37
38
|
LoggingOptions,
|
|
38
39
|
SchedulerOptions,
|
|
@@ -161,12 +162,29 @@ function loadYamlConfig<TAppConfig>(
|
|
|
161
162
|
|
|
162
163
|
// ── Node/Bun options ──────────────────────────────────────────────────────
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Telemetry options for the Node subpath: the portable flags plus OTLP export
|
|
167
|
+
* wiring. Presence of `endpoint` (with `enabled` not false) turns on Node OTel
|
|
168
|
+
* export via `@gobing-ai/ts-infra/otel-node`.
|
|
169
|
+
*/
|
|
170
|
+
export interface NodeTelemetryBootstrapOptions extends TelemetryOptions {
|
|
171
|
+
/** OTLP/HTTP collector endpoint base, e.g. `http://localhost:4318`. */
|
|
172
|
+
endpoint?: string;
|
|
173
|
+
/** Extra headers sent on every OTLP request (e.g. an auth token). */
|
|
174
|
+
headers?: Record<string, string>;
|
|
175
|
+
}
|
|
176
|
+
|
|
164
177
|
/** Options for the Node/Bun convenience {@link runNodeApplication}. */
|
|
165
178
|
export interface NodeApplicationOptions<TAppConfig = unknown, TEvents extends EventMap = InfraEvents> {
|
|
166
179
|
/** YAML config loading options. When omitted, uses defaults. */
|
|
167
180
|
readonly configLoader?: ApplicationConfigLoader<TAppConfig>;
|
|
168
181
|
/** Inline bootstrap config (overrides YAML-loaded config). */
|
|
169
|
-
readonly config?:
|
|
182
|
+
readonly config?: {
|
|
183
|
+
logging?: LoggingOptions;
|
|
184
|
+
events?: EventsOptions<TEvents>;
|
|
185
|
+
telemetry?: NodeTelemetryBootstrapOptions;
|
|
186
|
+
scheduler?: SchedulerOptions;
|
|
187
|
+
};
|
|
170
188
|
/** Pre-built services to inject. */
|
|
171
189
|
readonly services?: ApplicationBootstrapOptions<TAppConfig, TEvents>['services'];
|
|
172
190
|
/** User callback: application logic. */
|
|
@@ -232,12 +250,12 @@ export async function runNodeApplication<TAppConfig = unknown, TEvents extends E
|
|
|
232
250
|
|
|
233
251
|
// ── Resolve bootstrap config from YAML + inline options ────────────
|
|
234
252
|
const yamlLog = yamlBootstrap.logging as Partial<LoggingOptions> | undefined;
|
|
235
|
-
const yamlTel = yamlBootstrap.telemetry as Partial<
|
|
253
|
+
const yamlTel = yamlBootstrap.telemetry as Partial<NodeTelemetryBootstrapOptions> | undefined;
|
|
236
254
|
const yamlSched = yamlBootstrap.scheduler as Partial<SchedulerOptions> | undefined;
|
|
237
255
|
const databaseOpts = (yamlBootstrap.database ?? {}) as Record<string, unknown>;
|
|
238
256
|
|
|
239
257
|
const loggingOpts: Partial<LoggingOptions> = { ...yamlLog, ...options.config?.logging };
|
|
240
|
-
const telemetryOpts: Partial<
|
|
258
|
+
const telemetryOpts: Partial<NodeTelemetryBootstrapOptions> = { ...yamlTel, ...options.config?.telemetry };
|
|
241
259
|
const schedulerOpts: Partial<SchedulerOptions> = { ...yamlSched, ...options.config?.scheduler };
|
|
242
260
|
|
|
243
261
|
const logFilePath = (yamlBootstrap.logging as Record<string, unknown> | undefined)?.filePath as string | undefined;
|
|
@@ -256,20 +274,25 @@ export async function runNodeApplication<TAppConfig = unknown, TEvents extends E
|
|
|
256
274
|
// ── Node-owned plugins ──────────────────────────────────────────────
|
|
257
275
|
const plugins: Plugin[] = [];
|
|
258
276
|
let dbAdapter: DbAdapterLike | undefined = options.services?.db;
|
|
259
|
-
const rawTel = { ...telemetryOpts } as Record<string, unknown>;
|
|
260
277
|
|
|
261
278
|
// Node OTel telemetry as a failFast plugin
|
|
262
|
-
|
|
279
|
+
const otlpEndpoint = telemetryOpts.endpoint;
|
|
280
|
+
if (telemetryOpts.enabled !== false && otlpEndpoint) {
|
|
281
|
+
const otlpHeaders = telemetryOpts.headers;
|
|
282
|
+
const serviceName = telemetryOpts.serviceName ?? 'ts-libs';
|
|
263
283
|
plugins.push({
|
|
264
284
|
name: 'builtin:node-telemetry',
|
|
265
285
|
version: '0.0.0',
|
|
266
286
|
failFast: true,
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
// Providers must register during loadAll: the OTel metrics API has no
|
|
288
|
+
// proxy provider, so instruments created by telemetryPlugin's onStart
|
|
289
|
+
// (`initMetrics` pre-warm) bind permanently to whatever meter provider
|
|
290
|
+
// is global at that moment. loadAll runs before every onStart.
|
|
291
|
+
onLoad: async () => {
|
|
269
292
|
initNodeTelemetry({
|
|
270
|
-
serviceName
|
|
271
|
-
endpoint:
|
|
272
|
-
headers:
|
|
293
|
+
serviceName,
|
|
294
|
+
endpoint: otlpEndpoint,
|
|
295
|
+
...(otlpHeaders ? { headers: otlpHeaders } : {}),
|
|
273
296
|
});
|
|
274
297
|
},
|
|
275
298
|
onStop: async () => {
|