@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.
Files changed (59) hide show
  1. package/README.md +96 -22
  2. package/dist/api-client.d.ts +44 -1
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +144 -22
  5. package/dist/application/index.d.ts.map +1 -1
  6. package/dist/application/index.js +3 -3
  7. package/dist/application/plugins/builtins.d.ts.map +1 -1
  8. package/dist/application/plugins/builtins.js +9 -7
  9. package/dist/application/types.d.ts +1 -1
  10. package/dist/application-node.d.ts +18 -2
  11. package/dist/application-node.d.ts.map +1 -1
  12. package/dist/application-node.js +12 -7
  13. package/dist/event-bus/event-bus.d.ts +22 -2
  14. package/dist/event-bus/event-bus.d.ts.map +1 -1
  15. package/dist/event-bus/event-bus.js +63 -3
  16. package/dist/event-bus/index.d.ts +1 -1
  17. package/dist/event-bus/index.d.ts.map +1 -1
  18. package/dist/event-bus/types.d.ts +18 -0
  19. package/dist/event-bus/types.d.ts.map +1 -1
  20. package/dist/events.d.ts +5 -2
  21. package/dist/events.d.ts.map +1 -1
  22. package/dist/events.js +5 -2
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/job-queue/db-job-queue.d.ts +5 -1
  27. package/dist/job-queue/db-job-queue.d.ts.map +1 -1
  28. package/dist/job-queue/db-job-queue.js +55 -3
  29. package/dist/job-queue/types.d.ts +8 -0
  30. package/dist/job-queue/types.d.ts.map +1 -1
  31. package/dist/scheduler/action.js +1 -1
  32. package/dist/scheduler/node.d.ts.map +1 -1
  33. package/dist/scheduler/node.js +21 -11
  34. package/dist/scheduler/wrap-handler.js +1 -1
  35. package/dist/telemetry/metrics.d.ts.map +1 -1
  36. package/dist/telemetry/metrics.js +21 -1
  37. package/dist/telemetry/sdk.d.ts +7 -2
  38. package/dist/telemetry/sdk.d.ts.map +1 -1
  39. package/dist/telemetry/tracing.d.ts.map +1 -1
  40. package/dist/telemetry/tracing.js +18 -2
  41. package/package.json +5 -5
  42. package/src/api-client.ts +212 -24
  43. package/src/application/index.ts +4 -3
  44. package/src/application/plugins/builtins.ts +9 -7
  45. package/src/application/types.ts +1 -1
  46. package/src/application-node.ts +33 -10
  47. package/src/event-bus/event-bus.ts +72 -5
  48. package/src/event-bus/index.ts +1 -0
  49. package/src/event-bus/types.ts +19 -0
  50. package/src/events.ts +5 -2
  51. package/src/index.ts +9 -1
  52. package/src/job-queue/db-job-queue.ts +59 -4
  53. package/src/job-queue/types.ts +9 -0
  54. package/src/scheduler/action.ts +1 -1
  55. package/src/scheduler/node.ts +20 -12
  56. package/src/scheduler/wrap-handler.ts +1 -1
  57. package/src/telemetry/metrics.ts +20 -1
  58. package/src/telemetry/sdk.ts +7 -2
  59. 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
  }
@@ -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
- /** Master switch — when false, all tracing degrades to no-ops. */
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`. Controlled by `OTEL_DB_STATEMENT_DEBUG` env var.
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,kEAAkE;IAClE,OAAO,EAAE,OAAO,CAAC;IACjB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;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
+ {"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,EAAW,KAAK,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,MAAM,EAAS,MAAM,oBAAoB,CAAC;AAG9F;;;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,CAYZ;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,CAY3G;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
+ {"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.9",
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.9",
81
- "@gobing-ai/ts-runtime": "^0.3.9",
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.9",
115
- "@gobing-ai/ts-runtime": "^0.3.9",
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
- /** HTTP error with status code and response body text. */
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 async request<T>(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
76
- const url = this.buildUrl(path);
77
- const headers: Record<string, string> = {
78
- 'Content-Type': 'application/json',
79
- ...this.defaultHeaders,
80
- ...opts?.headers,
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: body !== undefined ? JSON.stringify(body) : undefined,
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
- if (!response.ok) {
127
- const text = await response.text();
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': `HTTP_${response.status}`,
220
+ 'error.type': 'Timeout',
131
221
  });
132
- throw new APIError(response.status, text);
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
  }
@@ -143,9 +143,10 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
143
143
  attachDefaultObservers(lifecycleBus);
144
144
  }
145
145
 
146
- const events = options.services?.events
147
- ? options.services.events
148
- : new EventBus<TEvents>({ lifecycleBus: lifecycleBus as EventBus<BusLifecycleEvents> | undefined });
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
  }
@@ -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 `resolveBootstrapConfig`.
78
+ * Constructed internally by `runApplication`.
79
79
  */
80
80
  export interface ApplicationBootstrapConfig {
81
81
  readonly logging: Readonly<
@@ -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?: ApplicationBootstrapOptions<TAppConfig, TEvents>['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<TelemetryOptions> | undefined;
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<TelemetryOptions> = { ...yamlTel, ...options.config?.telemetry };
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
- if (rawTel.enabled !== false && rawTel.endpoint) {
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
- onLoad: async () => {},
268
- onStart: async () => {
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: (rawTel.serviceName as string | undefined) ?? 'ts-libs',
271
- endpoint: rawTel.endpoint as string,
272
- headers: rawTel.headers as Record<string, string> | undefined,
293
+ serviceName,
294
+ endpoint: otlpEndpoint,
295
+ ...(otlpHeaders ? { headers: otlpHeaders } : {}),
273
296
  });
274
297
  },
275
298
  onStop: async () => {