@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
@@ -1,8 +1,9 @@
1
- import type { JobQueue } from '../job-queue/types';
1
+ import type { JobHandler, JobQueue } from '../job-queue/types';
2
2
  import { getLogger, type Logger } from '../logger';
3
3
  import { getEventbusEmitsTotal, getEventbusErrorsTotal } from '../telemetry/metrics';
4
4
  import type {
5
5
  AsyncEnqueuedDetail,
6
+ AsyncEventJobPayload,
6
7
  BusLifecycleEvents,
7
8
  EmitDoneDetail,
8
9
  EventMap,
@@ -23,22 +24,25 @@ function busLogger(): Logger {
23
24
  export class EventBus<TEvents extends EventMap> {
24
25
  private readonly syncHandlers = new Map<keyof TEvents, Set<TEvents[keyof TEvents]>>();
25
26
  private readonly asyncHandlers = new Map<keyof TEvents, Set<TEvents[keyof TEvents]>>();
26
- private readonly asyncHandlerIds = new WeakMap<TEvents[keyof TEvents], string>();
27
+ private readonly asyncHandlerIds = new Map<TEvents[keyof TEvents], string>();
28
+ private readonly asyncHandlersById = new Map<string, TEvents[keyof TEvents]>();
27
29
  private readonly jobQueue: JobQueue | null;
28
30
  private readonly lifecycleBus: EventBus<BusLifecycleEvents> | null;
31
+ private readonly logger: Logger | undefined;
29
32
  private nextAsyncHandlerId = 0;
30
-
31
33
  constructor(opts?: {
32
34
  jobQueue?: JobQueue;
33
35
  lifecycleBus?: EventBus<BusLifecycleEvents>;
36
+ logger?: Logger;
34
37
  }) {
35
38
  this.jobQueue = opts?.jobQueue ?? null;
36
39
  this.lifecycleBus = opts?.lifecycleBus ?? null;
40
+ this.logger = opts?.logger;
37
41
  }
38
42
 
39
43
  on<K extends keyof TEvents>(event: K, handler: TEvents[K], opts?: SubscribeOptions): void {
40
44
  if (opts?.async) {
41
- this.registerAsync(event, handler);
45
+ this.registerAsync(event, handler, opts.name);
42
46
  } else {
43
47
  this.registerSync(event, handler);
44
48
  }
@@ -68,16 +72,23 @@ export class EventBus<TEvents extends EventMap> {
68
72
  if (asyncSet.size === 0) {
69
73
  this.asyncHandlers.delete(event);
70
74
  }
75
+ this.releaseAsyncIdIfUnsubscribed(handler);
71
76
  }
72
77
  }
73
78
 
74
79
  removeAllListeners<K extends keyof TEvents>(event?: K): void {
75
80
  if (event !== undefined) {
76
81
  this.syncHandlers.delete(event);
82
+ const asyncSet = this.asyncHandlers.get(event);
77
83
  this.asyncHandlers.delete(event);
84
+ if (asyncSet) {
85
+ for (const handler of asyncSet) this.releaseAsyncIdIfUnsubscribed(handler);
86
+ }
78
87
  } else {
79
88
  this.syncHandlers.clear();
80
89
  this.asyncHandlers.clear();
90
+ this.asyncHandlerIds.clear();
91
+ this.asyncHandlersById.clear();
81
92
  }
82
93
  }
83
94
 
@@ -88,6 +99,12 @@ export class EventBus<TEvents extends EventMap> {
88
99
  let asyncCount = 0;
89
100
  let errors = 0;
90
101
 
102
+ this.logger?.debug('event.emit', {
103
+ event: eventName,
104
+ syncHandlers: this.syncHandlers.get(event)?.size ?? 0,
105
+ asyncHandlers: this.asyncHandlers.get(event)?.size ?? 0,
106
+ });
107
+
91
108
  const syncSet = this.syncHandlers.get(event);
92
109
  if (syncSet) {
93
110
  syncCount = syncSet.size;
@@ -179,13 +196,22 @@ export class EventBus<TEvents extends EventMap> {
179
196
  set.add(handler);
180
197
  }
181
198
 
182
- private registerAsync<K extends keyof TEvents>(event: K, handler: TEvents[K]): void {
199
+ private registerAsync<K extends keyof TEvents>(event: K, handler: TEvents[K], name?: string): void {
183
200
  let set = this.asyncHandlers.get(event);
184
201
  if (!set) {
185
202
  set = new Set();
186
203
  this.asyncHandlers.set(event, set);
187
204
  }
188
205
  set.add(handler);
206
+
207
+ if (!this.asyncHandlerIds.has(handler)) {
208
+ const id = name ?? `handler-${++this.nextAsyncHandlerId}`;
209
+ if (this.asyncHandlersById.has(id)) {
210
+ throw new Error(`Duplicate async handler name: "${id}"`);
211
+ }
212
+ this.asyncHandlerIds.set(handler, id);
213
+ this.asyncHandlersById.set(id, handler);
214
+ }
189
215
  }
190
216
 
191
217
  private getAsyncHandlerId(handler: TEvents[keyof TEvents]): string {
@@ -194,9 +220,50 @@ export class EventBus<TEvents extends EventMap> {
194
220
 
195
221
  const id = `handler-${++this.nextAsyncHandlerId}`;
196
222
  this.asyncHandlerIds.set(handler, id);
223
+ this.asyncHandlersById.set(id, handler);
197
224
  return id;
198
225
  }
199
226
 
227
+ /** Drop a handler's id mappings once it is subscribed to no event at all. */
228
+ private releaseAsyncIdIfUnsubscribed(handler: TEvents[keyof TEvents]): void {
229
+ for (const set of this.asyncHandlers.values()) {
230
+ if (set.has(handler)) return;
231
+ }
232
+ const id = this.asyncHandlerIds.get(handler);
233
+ if (id !== undefined) {
234
+ this.asyncHandlerIds.delete(handler);
235
+ this.asyncHandlersById.delete(id);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Bridge for queue-backed async dispatch: returns a `JobHandler` to
241
+ * register on a queue consumer for each async event's job type. It
242
+ * dispatches the enqueued `{ event, args, handlerId }` payload back to the
243
+ * matching async handler on THIS bus instance.
244
+ *
245
+ * Use named async subscriptions (`SubscribeOptions.name`) when jobs may be
246
+ * consumed after a process restart — anonymous handler ids are
247
+ * process-local and not stable across restarts.
248
+ *
249
+ * @throws when the payload references an unknown handler id, so the job
250
+ * lands in the queue's retry/fail path instead of vanishing silently.
251
+ */
252
+ createJobHandler(): JobHandler<AsyncEventJobPayload> {
253
+ return async (job) => {
254
+ const { event, args, handlerId } = job.payload;
255
+ const handler = this.asyncHandlersById.get(handlerId);
256
+ if (!handler) {
257
+ throw new Error(`No async handler registered for id "${handlerId}" (event "${event}")`);
258
+ }
259
+ // Serialization round-trips args through unknown[]; the handler type
260
+ // is `TEvents[K] & ((...args: unknown[]) => void)` — both satisfy the
261
+ // same constraint. Use unknown => unknown to bridge the covariance gap.
262
+ type AnyHandler = (...args: unknown[]) => unknown;
263
+ await (handler as unknown as AnyHandler)(...args);
264
+ };
265
+ }
266
+
200
267
  private publishEmitDone(detail: EmitDoneDetail): void {
201
268
  if (this.lifecycleBus) {
202
269
  try {
@@ -8,6 +8,7 @@ export { EventBus } from './event-bus';
8
8
  export { attachFileObserver, type FileObserverWriter } from './file-observer';
9
9
  export type {
10
10
  AsyncEnqueuedDetail,
11
+ AsyncEventJobPayload,
11
12
  BusLifecycleEvents,
12
13
  EmitDoneDetail,
13
14
  EventMap,
@@ -9,6 +9,25 @@ export type EventMap = Record<string, (...args: never[]) => void>;
9
9
  export interface SubscribeOptions {
10
10
  /** When true, the handler is dispatched through the async handler path. */
11
11
  async?: boolean;
12
+ /**
13
+ * Stable identifier for an async handler. Used as the `handlerId` in jobs
14
+ * enqueued through an injected `JobQueue`, so a queue consumer (see
15
+ * `EventBus.createJobHandler`) can dispatch jobs back to this handler —
16
+ * including after a process restart. Anonymous async handlers get a
17
+ * process-local generated id that is NOT stable across restarts.
18
+ * Must be unique per bus instance.
19
+ */
20
+ name?: string;
21
+ }
22
+
23
+ /** Payload shape the bus enqueues for queue-backed async handlers. */
24
+ export interface AsyncEventJobPayload {
25
+ /** Event name the handler was subscribed to. */
26
+ event: string;
27
+ /** Arguments the event was emitted with. */
28
+ args: unknown[];
29
+ /** Async handler id (subscription `name` or a generated id). */
30
+ handlerId: string;
12
31
  }
13
32
 
14
33
  // ── Lifecycle events ────────────────────────────────────────────────
package/src/events.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * Infrastructure-level event definitions for ts-infra observability.
3
3
  *
4
4
  * Mirrors the package-local pattern in `@gobing-ai/ts-ai-runner` (`events.ts`):
5
- * only the events ts-infra components actually emit live here. Application-domain
6
- * events (history import, HTTP server, …) belong to the consuming app, not this
5
+ * these maps define the infra-level event *contract*. ts-infra itself currently
6
+ * emits only `queue.stats` (`QueueStatsAction`) and `scheduler.job.executed`
7
+ * (`wrapScheduledHandler`); the remaining events are contracts for the consuming
8
+ * app or higher-level wiring to emit on the same bus. Application-domain events
9
+ * (history import, HTTP server, …) belong to the consuming app, not this
7
10
  * library. Process events belong to `@gobing-ai/ts-runtime` (the owner of
8
11
  * `ProcessExecutor`) and are intentionally not re-exported here.
9
12
  *
package/src/index.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  // API Client
2
- export { APIClient, type APIClientConfig, APIError, type RequestOptions } from './api-client';
2
+ export {
3
+ APIClient,
4
+ type APIClientConfig,
5
+ APIError,
6
+ type RawHttpResponse,
7
+ type RawRequestOptions,
8
+ type RequestOptions,
9
+ } from './api-client';
3
10
 
4
11
  // Event Bus
5
12
  export {
13
+ type AsyncEventJobPayload,
6
14
  attachDefaultObservers,
7
15
  attachFileObserver,
8
16
  attachLogObserver,
@@ -1,4 +1,7 @@
1
1
  import type { QueueJobDao, QueueJobRecord, QueueStats } from '@gobing-ai/ts-db';
2
+ import type { EventBus } from '../event-bus/event-bus';
3
+ import type { QueueEvents } from '../events';
4
+ import { getLogger, type Logger } from '../logger';
2
5
  import {
3
6
  getQueueJobCompletedTotal,
4
7
  getQueueJobEnqueuedTotal,
@@ -8,19 +11,34 @@ import {
8
11
  import { addSpanAttributes, traceAsync } from '../telemetry/tracing';
9
12
  import type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig } from './types';
10
13
 
14
+ let _queueLogger: Logger | undefined;
15
+ function queueLogger(): Logger {
16
+ if (!_queueLogger) _queueLogger = getLogger('job-queue');
17
+ return _queueLogger;
18
+ }
19
+
11
20
  /** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
12
21
  export class DBJobQueue<T = unknown> implements JobQueue<T> {
13
- constructor(readonly dao: QueueJobDao) {}
22
+ constructor(
23
+ readonly dao: QueueJobDao,
24
+ private readonly events?: EventBus<QueueEvents>,
25
+ ) {}
14
26
 
15
27
  async enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string> {
16
28
  const id = await this.dao.enqueue(type, payload, options);
17
29
  getQueueJobEnqueuedTotal().add(1, { type });
30
+ await this.events?.emit('queue.job.enqueued', { jobId: id, type });
18
31
  return id;
19
32
  }
20
33
 
21
34
  async enqueueBatch(jobs: Array<{ type: string; payload: T } & EnqueueOptions>): Promise<string[]> {
22
35
  const ids = await this.dao.enqueueBatch(jobs);
23
36
  getQueueJobEnqueuedTotal().add(jobs.length);
37
+ if (this.events) {
38
+ for (const [index, jobId] of ids.entries()) {
39
+ await this.events.emit('queue.job.enqueued', { jobId, type: jobs[index]?.type ?? 'unknown' });
40
+ }
41
+ }
24
42
  return ids;
25
43
  }
26
44
 
@@ -39,6 +57,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
39
57
  private readonly baseDelay: number;
40
58
  private readonly maxDelay: number;
41
59
  private readonly drainTimeoutMs: number;
60
+ private readonly events: EventBus<QueueEvents> | undefined;
42
61
  private timer: ReturnType<typeof setTimeout> | null = null;
43
62
  private running = false;
44
63
  private inFlight = 0;
@@ -54,6 +73,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
54
73
  this.baseDelay = config.baseDelay ?? 1_000;
55
74
  this.maxDelay = config.maxDelay ?? 60_000;
56
75
  this.drainTimeoutMs = config.drainTimeoutMs ?? 30_000;
76
+ this.events = config.events;
57
77
  }
58
78
 
59
79
  register(type: string, handler: JobHandler<T>): void {
@@ -64,9 +84,11 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
64
84
  if (this.running) return;
65
85
  this.running = true;
66
86
  this.schedule(0);
87
+ await this.events?.emit('queue.consumer.started');
67
88
  }
68
89
 
69
90
  async stop(): Promise<void> {
91
+ const wasRunning = this.running;
70
92
  this.running = false;
71
93
  if (this.timer !== null) {
72
94
  clearTimeout(this.timer);
@@ -77,6 +99,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
77
99
  while (this.inFlight > 0 && Date.now() < deadline) {
78
100
  await sleep(10);
79
101
  }
102
+ if (wasRunning) await this.events?.emit('queue.consumer.stopped');
80
103
  }
81
104
 
82
105
  async stats(): Promise<QueueStats> {
@@ -122,13 +145,28 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
122
145
  if (!this.running) return;
123
146
  try {
124
147
  await this.processOnce();
148
+ } catch (error) {
149
+ // The timer fires poll() as a floating promise — a DAO/processing error
150
+ // must be contained here or it becomes an unhandled rejection that can
151
+ // kill the process. Log and let the next cycle retry.
152
+ queueLogger().error('queue poll cycle failed', {
153
+ error: error instanceof Error ? error.message : String(error),
154
+ });
125
155
  } finally {
126
156
  if (this.running) this.schedule(this.pollInterval);
127
157
  }
128
158
  }
129
159
 
130
160
  private async processJob(record: QueueJobRecord): Promise<void> {
131
- const job = toJob<T>(record);
161
+ let job: Job<T>;
162
+ try {
163
+ job = toJob<T>(record);
164
+ } catch (error) {
165
+ // Corrupt payload — the job can never parse; route it through the
166
+ // retry/fail path instead of rejecting the whole batch.
167
+ await this.failOrRetry(record, error);
168
+ return;
169
+ }
132
170
  return traceAsync('queue.job.process', async () => {
133
171
  addSpanAttributes({
134
172
  'queue.job_id': job.id,
@@ -148,6 +186,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
148
186
  await this.dao.markCompleted(job.id);
149
187
  getQueueJobCompletedTotal().add(1, { type: job.type });
150
188
  getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
189
+ await this.events?.emit('queue.job.completed', { jobId: job.id, type: job.type });
151
190
  } catch (error) {
152
191
  getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
153
192
  await this.failOrRetry(job, error);
@@ -155,17 +194,33 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
155
194
  });
156
195
  }
157
196
 
158
- private async failOrRetry(job: Job<T>, error: unknown): Promise<void> {
197
+ private async failOrRetry(
198
+ job: Pick<Job<T>, 'id' | 'type' | 'attempts' | 'maxRetries'>,
199
+ error: unknown,
200
+ ): Promise<void> {
159
201
  const attempts = job.attempts + 1;
160
202
  const message = error instanceof Error ? error.message : String(error);
161
203
  if (attempts >= job.maxRetries) {
162
204
  await this.dao.markFailed(job.id, attempts, message);
163
205
  getQueueJobFailedTotal().add(1, { type: job.type });
206
+ await this.events?.emit('queue.job.failed', {
207
+ jobId: job.id,
208
+ type: job.type,
209
+ error: message,
210
+ attempt: attempts,
211
+ });
164
212
  return;
165
213
  }
166
214
 
167
215
  const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
168
- await this.dao.markForRetry(job.id, attempts, message, Date.now() + delay);
216
+ const nextRetryAt = Date.now() + delay;
217
+ await this.dao.markForRetry(job.id, attempts, message, nextRetryAt);
218
+ await this.events?.emit('queue.job.retrying', {
219
+ jobId: job.id,
220
+ type: job.type,
221
+ attempt: attempts,
222
+ nextRetryAt,
223
+ });
169
224
  }
170
225
  }
171
226
 
@@ -5,6 +5,9 @@
5
5
  * `DBJobQueue` and `DBQueueConsumer`.
6
6
  */
7
7
 
8
+ import type { EventBus } from '../event-bus/event-bus';
9
+ import type { QueueEvents } from '../events';
10
+
8
11
  /** A queued job with status tracking, retry metadata, and timestamps. */
9
12
  export interface Job<T = unknown> {
10
13
  id: string;
@@ -54,6 +57,12 @@ export interface QueueConsumerConfig {
54
57
  baseDelay?: number;
55
58
  maxDelay?: number;
56
59
  drainTimeoutMs?: number;
60
+ /**
61
+ * Optional bus for queue lifecycle events: `queue.consumer.started` /
62
+ * `queue.consumer.stopped` and `queue.job.completed` / `queue.job.failed` /
63
+ * `queue.job.retrying`. Omitting it leaves the consumer silent (default).
64
+ */
65
+ events?: EventBus<QueueEvents>;
57
66
  }
58
67
 
59
68
  /** Consumer interface for the job queue — register handlers and control the processing loop. */
@@ -110,7 +110,7 @@ export class QueueStatsAction implements SchedulerAction {
110
110
  const dao = await this.daoProvider();
111
111
  const stats = await dao.getStats();
112
112
  logger.info('Queue stats snapshot', { action: this.name, ...stats });
113
- this.systemBus?.emit('queue.stats', stats);
113
+ await this.systemBus?.emit('queue.stats', stats);
114
114
  }
115
115
  }
116
116
 
@@ -2,6 +2,7 @@
2
2
  * Node.js scheduler adapter using a simple setInterval-based approach.
3
3
  * No external cron library dependency — cron expressions are parsed minimally.
4
4
  */
5
+ import { getLogger } from '../logger';
5
6
  import {
6
7
  getSchedulerJobDuration,
7
8
  getSchedulerJobExecutedTotal,
@@ -13,21 +14,28 @@ import type { ScheduledAction, SchedulerAdapter } from './types';
13
14
  function parseInterval(cron: string): number {
14
15
  // Support simple patterns: "* * * * *" (every minute), "*/5 * * * *" (every 5 min)
15
16
  // Also support direct ms strings like "60000"
16
- const num = Number(cron);
17
- if (!Number.isNaN(num)) return num;
18
-
19
- const parts = cron.trim().split(/\s+/);
20
- if (parts.length === 5 && parts[0] === '*') {
21
- return 60_000; // every minute default
22
- }
17
+ const trimmed = cron.trim();
18
+ const num = Number(trimmed);
19
+ if (trimmed !== '' && !Number.isNaN(num)) {
20
+ // Guard against 0/negative intervals — setInterval would spin hot.
21
+ if (num > 0) return num;
22
+ } else {
23
+ const parts = trimmed.split(/\s+/);
24
+ if (parts.length === 5 && parts[0] === '*') {
25
+ return 60_000; // every minute
26
+ }
23
27
 
24
- // */N pattern
25
- const match = parts[0]?.match(/^\*\/(\d+)$/);
26
- if (match) {
27
- return Number(match[1]) * 60_000;
28
+ // */N pattern
29
+ const match = parts[0]?.match(/^\*\/(\d+)$/);
30
+ if (match && Number(match[1]) > 0) {
31
+ return Number(match[1]) * 60_000;
32
+ }
28
33
  }
29
34
 
30
- return 60_000; // fallback: every minute
35
+ // Real cron field expressions ("0 3 * * *") are NOT supported — running
36
+ // them every minute silently would badly misfire, so warn on fallback.
37
+ getLogger('scheduler.node').warn('Unsupported cron expression — falling back to a 60s interval', { cron });
38
+ return 60_000;
31
39
  }
32
40
 
33
41
  interface ScheduledEntry {
@@ -43,7 +43,7 @@ export function wrapScheduledHandler(
43
43
  const durationMs = Math.round(performance.now() - startTime);
44
44
  addSpanAttributes({ 'scheduler.duration_ms': durationMs });
45
45
 
46
- systemBus?.emit('scheduler.job.executed', {
46
+ void systemBus?.emit('scheduler.job.executed', {
47
47
  name,
48
48
  durationMs,
49
49
  ...(execError !== undefined ? { error: execError } : {}),
@@ -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 { type Counter, type Histogram, metrics } from '@opentelemetry/api';
5
+ import { type Counter, createNoopMeter, type Histogram, metrics } from '@opentelemetry/api';
6
+ import { getResolvedConfig } from './sdk';
6
7
 
7
8
  export type { Counter, Histogram } from '@opentelemetry/api';
8
9
 
@@ -25,7 +26,24 @@ function getMeter() {
25
26
 
26
27
  const instruments: Record<string, Counter | Histogram | undefined> = {};
27
28
 
29
+ // Master switch (`TelemetryConfig.enabled`): when explicitly disabled, getters
30
+ // return shared no-op instruments WITHOUT touching the cache, so a later
31
+ // re-enable rebuilds real instruments against the live meter.
32
+ let _noopCounter: Counter | undefined;
33
+ let _noopHistogram: Histogram | undefined;
34
+
35
+ function noopCounter(): Counter {
36
+ if (!_noopCounter) _noopCounter = createNoopMeter().createCounter('noop');
37
+ return _noopCounter;
38
+ }
39
+
40
+ function noopHistogram(): Histogram {
41
+ if (!_noopHistogram) _noopHistogram = createNoopMeter().createHistogram('noop');
42
+ return _noopHistogram;
43
+ }
44
+
28
45
  function getOrCreateCounter(key: string, name: string, description: string, unit = '{operation}'): Counter {
46
+ if (!getResolvedConfig().enabled) return noopCounter();
29
47
  if (!instruments[key]) {
30
48
  instruments[key] = getMeter().createCounter(name, { description, unit });
31
49
  }
@@ -33,6 +51,7 @@ function getOrCreateCounter(key: string, name: string, description: string, unit
33
51
  }
34
52
 
35
53
  function getOrCreateHistogram(key: string, name: string, description: string, unit = 'ms'): Histogram {
54
+ if (!getResolvedConfig().enabled) return noopHistogram();
36
55
  if (!instruments[key]) {
37
56
  instruments[key] = getMeter().createHistogram(name, { description, unit });
38
57
  }
@@ -16,7 +16,11 @@ import { type Tracer, trace } from '@opentelemetry/api';
16
16
  * environment, and debug-level DB statement capture.
17
17
  */
18
18
  export interface TelemetryConfig {
19
- /** Master switch — when false, all tracing degrades to no-ops. */
19
+ /**
20
+ * Master switch — when false, infra-created spans and metric instruments
21
+ * degrade to no-ops even if a global OTel provider is registered.
22
+ * Explicitly injected tracer ports (ADR-009 addendum) are unaffected.
23
+ */
20
24
  enabled: boolean;
21
25
  /** Logical service name emitted on every span. */
22
26
  serviceName: string;
@@ -29,7 +33,8 @@ export interface TelemetryConfig {
29
33
  * attribute. SQL text is redacted — parameter values, literals, and
30
34
  * identifiers are stripped before capture.
31
35
  *
32
- * Default: `false`. Controlled by `OTEL_DB_STATEMENT_DEBUG` env var.
36
+ * Default: `false`. Set via config — ts-infra core never reads env vars
37
+ * (ADR-011); map an env var to this flag in your bootstrap if desired.
33
38
  */
34
39
  dbStatementDebug: boolean;
35
40
  }
@@ -2,8 +2,22 @@
2
2
  * High-level tracing helpers for application code.
3
3
  */
4
4
 
5
- import { context, type Span, type SpanOptions, type Tracer, trace } from '@opentelemetry/api';
6
- import { getTracer } from './sdk';
5
+ import { context, INVALID_SPAN_CONTEXT, type Span, type SpanOptions, type Tracer, trace } from '@opentelemetry/api';
6
+ import { getResolvedConfig, getTracer } from './sdk';
7
+
8
+ /**
9
+ * Master switch (`TelemetryConfig.enabled`): when explicitly disabled, infra
10
+ * helpers bypass the global provider entirely. An explicitly injected tracer
11
+ * (ADR-009 addendum structural port) is honored regardless — the caller opted in.
12
+ */
13
+ function isSuppressed(tracer: Tracer | undefined): boolean {
14
+ return tracer === undefined && !getResolvedConfig().enabled;
15
+ }
16
+
17
+ /** A non-recording span satisfying the `Span` interface — every method is a no-op. */
18
+ function nonRecordingSpan(): Span {
19
+ return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
20
+ }
7
21
 
8
22
  /**
9
23
  * Execute an async function within an active OTel span.
@@ -15,6 +29,8 @@ export async function traceAsync<T>(
15
29
  options?: SpanOptions,
16
30
  tracer?: Tracer,
17
31
  ): Promise<T> {
32
+ if (isSuppressed(tracer)) return fn(nonRecordingSpan());
33
+
18
34
  const resolvedTracer = tracer ?? getTracer();
19
35
  return resolvedTracer.startActiveSpan(name, options ?? {}, async (span) => {
20
36
  try {
@@ -33,6 +49,8 @@ export async function traceAsync<T>(
33
49
  * The span is automatically ended and its status set on error.
34
50
  */
35
51
  export function traceSync<T>(name: string, fn: (span: Span) => T, options?: SpanOptions, tracer?: Tracer): T {
52
+ if (isSuppressed(tracer)) return fn(nonRecordingSpan());
53
+
36
54
  const resolvedTracer = tracer ?? getTracer();
37
55
  return resolvedTracer.startActiveSpan(name, options ?? {}, (span) => {
38
56
  try {